diff --git a/.claude/skills/dld-reindex/scripts/list-taken-ids.sh b/.claude/skills/dld-reindex/scripts/list-taken-ids.sh index f666947..b81abe5 100755 --- a/.claude/skills/dld-reindex/scripts/list-taken-ids.sh +++ b/.claude/skills/dld-reindex/scripts/list-taken-ids.sh @@ -48,10 +48,12 @@ PR_BASE="${BASE#origin/}" # IDs in files touched by open PRs targeting this base. Scope to paths under # the records dir so an unrelated PR touching e.g. notes/DL-007-meeting.md - # doesn't poison the taken set. + # doesn't poison the taken set. A PR whose head is the current branch is not + # a collision: it holds this branch's own decisions. if [[ -z "$SKIP_REASON" ]]; then - gh pr list --state open --base "$PR_BASE" --json files --limit 100 \ - --jq '.[].files[].path' 2>/dev/null \ + CURRENT_BRANCH="$(git -C "$PROJECT_ROOT" branch --show-current 2>/dev/null || true)" + gh pr list --state open --base "$PR_BASE" --json files,headRefName --limit 100 \ + --jq ".[] | select(.headRefName != \"$CURRENT_BRANCH\") | .files[].path" 2>/dev/null \ | grep -E "^${RECORDS_DIR_REL}/" \ | grep -oE 'DL-[0-9]+' || true fi diff --git a/decisions/INDEX.md b/decisions/INDEX.md index b93facb..24ac388 100644 --- a/decisions/INDEX.md +++ b/decisions/INDEX.md @@ -2,9 +2,11 @@ | ID | Title | Status | Tags | |----|-------|--------|------| +| DL-015 | Rename dld-goal to dld-run: the command is a run, not a goal | proposed | dld-goal, naming, ux | +| DL-014 | Amend DL-008: Esc suspends the loop; pause aborts the current turn | accepted | dld-goal, extension, execution | | DL-013 | Amend DL-008: continuation is a scheduled dispatch, not an awaited handler | accepted | dld-goal, extension, execution | | DL-012 | Amend DL-007: correct the delegated script list | accepted | dld-goal, architecture, state | -| DL-011 | Run visibility is layered: status line, fixed-height widget, transcript cards, board overlay | proposed | dld-goal, extension, ui | +| DL-011 | Run visibility is layered: status line, fixed-height widget, transcript cards, board overlay | accepted | dld-goal, extension, ui | | DL-010 | Compaction during a run is assembled deterministically from disk, never model-summarised | proposed | dld-goal, extension, context | | DL-009 | Child-session rotation is a re-entrant controller driven by a typed tool, verified against disk | proposed | dld-goal, extension, architecture | | DL-008 | In-session continuation fires on agent_end behind idle, token, and bounds gates | accepted | dld-goal, extension, execution | diff --git a/decisions/records/DL-011.md b/decisions/records/DL-011.md index a4d187b..7e3dca5 100644 --- a/decisions/records/DL-011.md +++ b/decisions/records/DL-011.md @@ -2,11 +2,21 @@ id: DL-011 title: "Run visibility is layered: status line, fixed-height widget, transcript cards, board overlay" timestamp: 2026-08-21T12:19:17Z -status: proposed +status: accepted supersedes: [] amends: [] tags: [dld-goal, extension, ui] -references: [] +references: + - path: extensions/dld-goal/render.ts + symbol: widgetLines + - path: extensions/dld-goal/render.ts + symbol: statusLine + - path: extensions/dld-goal/render.ts + symbol: boardLines + - path: extensions/dld-goal/render.test.ts + - path: extensions/dld-goal/surfaces.test.ts + - path: extensions/dld-goal/index.ts + symbol: refreshSurfaces --- ## Context @@ -59,6 +69,8 @@ The run uses all four, matched to what each is good at. Notifications remain for transitions the user must not miss: a blocked item, a bound reached, a run completing. +The widget height is fixed at **five lines**: header, up to three item rows, and a `+N before · +N more` line when items fall outside the window. Height invariance is a unit-tested property (`render.test.ts` renders 3-item and 30-item runs and asserts identical line counts). Surfaces repaint on `session_start`, `turn_end`, `agent_end`, and after every `/dld-goal` command, and clear entirely when no run is active. Without a UI (print/RPC), nothing is painted. + ## Rationale Each kind of content sits where its cost is acceptable. Detail that a developer wants once — what exactly the checks returned — belongs in scrollback, where it is free to render, readable later, and diffable against the run log. Detail wanted continuously — where am I, how much budget is left — belongs in one or two lines that never grow. diff --git a/decisions/records/DL-014.md b/decisions/records/DL-014.md new file mode 100644 index 0000000..72ffafa --- /dev/null +++ b/decisions/records/DL-014.md @@ -0,0 +1,34 @@ +--- +id: DL-014 +title: "Amend DL-008: Esc suspends the loop; pause aborts the current turn" +timestamp: 2026-08-22T09:56:06Z +status: accepted +supersedes: [] +amends: [DL-008] +tags: [dld-goal, extension, execution] +references: + - path: extensions/dld-goal/index.ts + symbol: dldGoalExtension +--- + +## Context + +DL-008 promised "Esc pauses the run" but the extension had no mechanism for it. Live testing confirmed the failure: pressing Esc aborts the current turn, `agent_end` fires, the loop sees an active run with work, and dispatches the same item again immediately. The user watches the loop restart itself twice before realising Esc doesn't stop it. + +DL-013 established that suspension is explicit state, but only covered typed input. Esc is the other half of the same contract. + +## Decision + +Amend DL-008 to cover interrupt-driven suspension: + +- An `agent_end` whose last assistant message has `stopReason: "aborted"` is the user pressing Esc. The loop suspends, clears any pending dispatch, and notifies "Run suspended (interrupted). /dld-goal resume to continue." +- `/dld-goal resume` clears suspension and dispatches immediately, so the standard interrupt and the standard resume form a matched pair. +- `/dld-goal pause` additionally calls `ctx.abort()`, so pausing mid-turn stops the current work rather than letting the agent finish what it was doing. + +## Rationale + +Esc is the harness's standard interrupt. A loop that overrides it is worse than no loop — the user's one reliable escape hatch becomes the thing that makes the problem worse. Suspending on abort is the only behavior that respects both the interrupt and the loop's purpose. + +## Consequences + +The user can always stop the loop with Esc, and always restart it with `/dld-goal resume`. Idle continuation (the loop driving when the user isn't watching) is unaffected — the abort check only fires on an actual abort, not on normal turn completion. diff --git a/decisions/records/DL-015.md b/decisions/records/DL-015.md new file mode 100644 index 0000000..0e163eb --- /dev/null +++ b/decisions/records/DL-015.md @@ -0,0 +1,36 @@ +--- +id: DL-015 +title: "Rename dld-goal to dld-run: the command is a run, not a goal" +timestamp: 2026-08-25T11:34:38Z +status: proposed +supersedes: [] +amends: [] +tags: [dld-goal, naming, ux] +references: [] +--- + +## Context + +The goal-loop command and skill are named `/dld-goal`, borrowed from the pi ecosystem (pi-goal, pi-goal-pro, pi-goal-x). Within DLD the vocabulary is decisions, plans, and runs — the run contract already calls the thing a run (`create-run.sh`, `run-state.sh`, `status: active`). "Goal" is the ecosystem's word, not the domain's, and it reads as "set a goal" rather than "execute a batch of decisions." + +The question surfaced while dogfooding: `/dld-goal start DL-014..DL-022` works, but the name doesn't say what happens. Alternatives considered: `/dld-batch-implement` (accurate but clunky), `/dld-implement --run` (folds into the existing verb but changes its semantics), `/dld-execute` (generic). + +## Decision + +Rename the command, skill, and extension from `dld-goal` to `dld-run`. The concept is a run: a set of proposed decisions executed as one long-running unit. The name should match the artifacts the user already sees — `.dld/runs//`, `run-state.sh`, the run contract. + +This is a rename, not a redesign. Every file, command, script path, and reference changes; the behavior does not. The change is deferred until after the current PR stack lands, so the rename doesn't entangle with the v1 implementation still in flight. + +## Rationale + +The name you type every day should be the name of the thing. "Run" is already the contract's word, the filesystem's word, and the status line's word. Aligning the command with them removes the translation step between what the user says and what the system does. + +`/dld-run` is also shorter than `/dld-goal` and unambiguous in a way "goal" is not: a goal is an aspiration, a run is an execution. The command executes. + +## Consequences + +Every reference changes: skill directories (`skills/dld-goal/` → `skills/dld-run/`), the extension directory, command names, script names, documentation, decision records that mention "goal", the plugin manifest, and the pi package manifest. The two-copy skill layout means each change lands twice. + +Existing runs in `.dld/runs/` are unaffected — the directory name doesn't change, only the command that creates them. + +The pi ecosystem already has several `goal` extensions; `dld-run` avoids colliding with any of them in the command namespace. diff --git a/docs/plan/goal-loop.md b/docs/plan/goal-loop.md index 2a26d76..fc6c68c 100644 --- a/docs/plan/goal-loop.md +++ b/docs/plan/goal-loop.md @@ -163,9 +163,9 @@ On session start with an active run in `.dld/runs/`: ## Build order -1. **`/dld-goal` skill (manual pacing).** Contract authoring, state files, the execution loop driven by the agent in one session, the four-part completion transaction composed from existing scripts. Works everywhere DLD works today. This is most of the semantics and none of the harness risk. -2. **Extension v1: in-session continuation.** `/dld-goal` commands, `agent_end` continuation with idle/pending checks and run tokens, status widget, bounds, pause/resume. No child sessions yet — the loop runs in the controller session. -3. **Extension v2: child sessions + deterministic compaction.** Fresh session per item, disk-backed recovery, deterministic compaction summaries. +1. **`/dld-goal` skill (manual pacing).** ✅ Done — 10 scripts, 305 bats tests, validated end-to-end. +2. **Extension v1: in-session continuation.** ✅ Done — `/dld-goal` commands with tolerant start syntax (`DL-014..DL-022`), scheduled `agent_end` continuation with suspension on user input and Esc, completion transaction honouring review mode, layered UI (status line, fixed-height widget, transcript cards, board overlay), active-time bounds. 96 bun tests alongside the bats suite. DL-006 through DL-014 accepted. +3. **Extension v2: child sessions + deterministic compaction.** Fresh session per item, disk-backed recovery, deterministic compaction summaries (DL-009, DL-010, still proposed). 4. **Later, if earned:** detached auditor process, regression-shield audit automation, `/dld-audit-auto` integration for fully unattended runs with a PR at the end. Each stage ships usable; later stages only add autonomy. diff --git a/extensions/dld-goal/index.ts b/extensions/dld-goal/index.ts index 211821e..e1cc175 100644 --- a/extensions/dld-goal/index.ts +++ b/extensions/dld-goal/index.ts @@ -2,13 +2,18 @@ import type { ExecOptions, ExecResult, ExtensionAPI, ExtensionCommandContext, Ex import { formatDoctorReport, runDoctor } from "./doctor.ts"; import { LoopController, type LoopContext, type LoopUi } from "./loop.ts"; import { scriptPath } from "./paths.ts"; +import { boardLines, statusLine, widgetLines } from "./render.ts"; +import { activeMinutes, readEventsFrom, readRunFrom } from "./run-state.ts"; -// @decision(DL-006) @decision(DL-008) +// @decision(DL-006) @decision(DL-008) @decision(DL-011) export type DldGoalApi = Pick< ExtensionAPI, - "registerCommand" | "exec" | "appendEntry" | "on" | "sendMessage" + "registerCommand" | "exec" | "appendEntry" | "on" | "sendMessage" | "registerEntryRenderer" >; +const STATUS_KEY = "dld-goal"; +const WIDGET_KEY = "dld-goal-run"; + /** Time between agent_end and the deferred dispatch. Long enough for the * session to settle, short enough to feel like a handoff. */ const CONTINUATION_DELAY_MS = 100; @@ -20,7 +25,39 @@ export default function dldGoalExtension(pi: DldGoalApi): void { const uiAdapterFor = (ctx: ExtensionContext): LoopUi => ({ notify: (message, type) => ctx.ui.notify(message, type), + card: (lines) => pi.appendEntry("dld-goal-card", { lines }), }); + + // Paint the persistent surfaces from the on-disk state. Called after every + // event that can change the run; when nothing is active the surfaces clear. + let projectRootCache: string | null = null; + const projectRoot = async (ctx: ExtensionContext): Promise => { + if (projectRootCache) return projectRootCache; + const result = await pi.exec("git", ["-C", ctx.cwd, "rev-parse", "--show-toplevel"]); + projectRootCache = result.code === 0 ? result.stdout.trim() : ctx.cwd; + return projectRootCache; + }; + + const refreshSurfaces = async (ctx: ExtensionContext) => { + if (!ctx.hasUI) return; + const root = await projectRoot(ctx); + const active = await (async () => { + const result = await pi.exec("bash", [scriptPath("run-state.sh"), "active"]); + if (result.code !== 0 || !result.stdout.trim()) return null; + const slug = result.stdout.trim().split("\n")[0] ?? ""; + if (!slug) return null; + const read = readRunFrom(`${root}/.dld/runs/${slug}`); + return read.ok ? { state: read.state, runDir: `${root}/.dld/runs/${slug}` } : null; + })(); + if (!active) { + ctx.ui.setStatus(STATUS_KEY, undefined); + ctx.ui.setWidget(WIDGET_KEY, undefined); + return; + } + const minutes = activeMinutes(active.state, readEventsFrom(active.runDir).events); + ctx.ui.setStatus(STATUS_KEY, statusLine(active.state, minutes)); + ctx.ui.setWidget(WIDGET_KEY, widgetLines(active.state, minutes)); + }; const contextAdapterFor = (ctx: ExtensionContext): LoopContext => ({ cwd: ctx.cwd, isIdle: () => ctx.isIdle(), @@ -45,12 +82,24 @@ export default function dldGoalExtension(pi: DldGoalApi): void { }); pi.registerCommand("dld-goal", { - description: "Drive a goal run: start, pause, resume, stop, or status", + description: "Drive a goal run: start, pause, resume, stop, status, or board", handler: async (args, ctx) => { - await handleGoalCommand(pi, loop, args, ctx, scheduleContinuation); + await handleGoalCommand(pi, loop, args, ctx, scheduleContinuation, projectRoot); + await refreshSurfaces(ctx); }, }); + // Cards render in the transcript as custom entries: scrollback, no redraw, + // and never part of LLM context. + pi.registerEntryRenderer("dld-goal-card", (entry, _options, theme) => { + const data = entry.data as { lines?: string[] } | undefined; + const text = (data?.lines ?? []).join("\n"); + return { + render: () => [theme.fg("accent", text)], + invalidate: () => {}, + }; + }); + // Event handlers must never throw: a broken continuation path would spam // an error on every turn for the rest of the session. Fail closed — the // worst outcome is that continuation stops, not that the session is flooded. @@ -98,7 +147,26 @@ export default function dldGoalExtension(pi: DldGoalApi): void { }, CONTINUATION_DELAY_MS); }; - pi.on("agent_end", (_event, ctx) => { + pi.on("agent_end", async (event, ctx) => { + await refreshSurfaces(ctx); + + // @decision(DL-014) + // An aborted turn is the user pressing Esc — the standard interrupt. + // Suspend rather than dispatching again, so Esc actually stops the + // loop instead of watching it restart itself immediately. + const messages = (event as { messages?: { role?: string; stopReason?: string }[] }).messages ?? []; + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message?.role === "assistant" && message.stopReason === "aborted") { + clearTimer(); + if (!loop.isSuspended()) { + loop.suspend(); + ctx.ui.notify("Run suspended (interrupted). /dld-goal resume to continue.", "info"); + } + return; + } + } + if (loop.isSuspended() || ctx.hasPendingMessages()) { clearTimer(); return; @@ -106,6 +174,10 @@ export default function dldGoalExtension(pi: DldGoalApi): void { scheduleContinuation(ctx); }); + pi.on("session_start", async (_event, ctx) => { + await refreshSurfaces(ctx); + }); + // Any user input suspends continuation until the user resumes. This is // what keeps a loop from dispatching over a user who is mid-sentence. pi.on("input", () => { @@ -126,15 +198,87 @@ export default function dldGoalExtension(pi: DldGoalApi): void { "error", ); } + await refreshSurfaces(ctx); }); } +interface StartArgs { + slug: string; + title: string; + decisionIds: string[]; +} + +/** + * Parse the tolerant start syntax. The agent is the parser: ranges expand, + * slug and title are derived when not given. + * + * /dld-goal start DL-014..DL-022 → slug dl-014-022, 9 items + * /dld-goal start DL-014 - DL-022 → same + * /dld-goal start my-batch DL-014 DL-015 → slug my-batch, 2 items + * /dld-goal start my-batch "My title" --decisions DL-014,DL-015 + */ +function parseStartArgs(raw: string): StartArgs | { error: string } { + // raw is the args string after the command name, including the subcommand + // "start" itself; strip it before parsing. + const rest = raw.split(/\s+/).slice(1).filter(Boolean); + if (rest.length === 0) { + return { error: "Usage: /dld-goal start " }; + } + + // Range form: DL-014..DL-022 or DL-014 - DL-022 (spaces tolerated). + const joined = rest.join(" "); + const rangeMatch = joined.match(/^(DL-\d+)\s*(?:\.\.|-|–|—|to)\s*(DL-\d+)$/i); + if (rangeMatch) { + const from = Number(rangeMatch[1]!.slice(3)); + const to = Number(rangeMatch[2]!.slice(3)); + if (!Number.isInteger(from) || !Number.isInteger(to) || from > to || to - from > 50) { + return { error: `Invalid range: ${rangeMatch[1]}..${rangeMatch[2]}` }; + } + const ids = Array.from({ length: to - from + 1 }, (_, i) => `DL-${String(from + i).padStart(3, "0")}`); + return { + slug: `dl-${from}-${to}`, + title: `${rangeMatch[1]} through ${rangeMatch[2]}`, + decisionIds: ids, + }; + } + + // Flag form: --decisions DL-A,DL-B + const decisionFlag = rest.indexOf("--decisions"); + const firstIsDecision = /^DL-\d+$/.test(rest[0] ?? ""); + let decisionIds: string[]; + let titleParts: string[]; + + if (decisionFlag >= 0) { + decisionIds = (rest[decisionFlag + 1] ?? "").split(",").filter(Boolean); + titleParts = rest.slice(1, decisionFlag); + } else { + // When the first token is a decision ID there is no explicit slug — + // every positional token is a decision. Taking rest.slice(1) would + // silently drop the first one. + const source = firstIsDecision ? rest : rest.slice(1); + decisionIds = source.filter((p) => /^DL-\d+$/.test(p)); + titleParts = source.filter((p) => !/^DL-\d+$/.test(p)); + } + + if (decisionIds.length === 0) { + return { error: "A run needs decisions. Try /dld-goal start DL-014..DL-022 or /dld-goal start my-batch DL-014 DL-015" }; + } + + const slug = firstIsDecision + ? `dl-${decisionIds[0]!.slice(3).padStart(3, "0")}-${decisionIds[decisionIds.length - 1]!.slice(3).padStart(3, "0")}` + : (rest[0] ?? "run"); + const title = titleParts.join(" ") || (firstIsDecision ? `${decisionIds[0]} batch` : slug); + + return { slug, title, decisionIds }; +} + async function handleGoalCommand( pi: DldGoalApi, loop: LoopController, args: string, ctx: ExtensionCommandContext, scheduleContinuation: (ctx: ExtensionContext) => void, + projectRoot: (ctx: ExtensionContext) => Promise, ): Promise { const sub = args.trim().split(/\s+/)[0] || "status"; const workspace = ctx.cwd; @@ -160,24 +304,37 @@ async function handleGoalCommand( switch (sub) { case "start": { - const slug = await resolveSlug(); - if (slug) { - ctx.ui.notify(`A run is already active: ${slug}`, "warning"); + const existing = await resolveSlug(); + if (existing) { + ctx.ui.notify(`A run is already active: ${existing}`, "warning"); return; } - const rest = args.trim().split(/\s+/).slice(1); - const created = await runScript("create-run.sh", [ - "--slug", - rest[0] ?? "run", - "--title", - rest.slice(1).join(" ") || (rest[0] ?? "run"), - ]); + const parsed = parseStartArgs("start " + args.trim().replace(/^start\s*/, "")); + if (!("slug" in parsed)) { + ctx.ui.notify(parsed.error, "warning"); + return; + } + // Preconditions first: dirty tree, active run, non-proposed decisions, + // and ID collisions all refuse before anything is created. + const guard = await runScript("guard-preconditions.sh", ["start", "--decisions", parsed.decisionIds.join(",")]); + if (!guard.ok) { + ctx.ui.notify(guard.output, "error"); + return; + } + const created = await runScript("create-run.sh", ["--slug", parsed.slug, "--title", parsed.title]); if (!created.ok) { ctx.ui.notify(created.output, "error"); return; } + for (const id of parsed.decisionIds) { + const added = await runScript("run-state.sh", ["add-item", parsed.slug, "--decisions", id]); + if (!added.ok) { + ctx.ui.notify(`Run created but item ${id} failed: ${added.output}`, "error"); + return; + } + } loop.resume(); - ctx.ui.notify(`Created and activated run: ${created.output}`, "info"); + ctx.ui.notify(`Started run ${parsed.slug} · ${parsed.decisionIds.length} item${parsed.decisionIds.length === 1 ? "" : "s"} · ${parsed.title}`, "info"); scheduleContinuation(ctx); return; } @@ -190,6 +347,16 @@ async function handleGoalCommand( ctx.ui.notify(`No ${sub === "resume" ? "resumable" : "active"} run to ${sub}.`, "warning"); return; } + // Resume re-validates preconditions: the tree may have gone dirty, + // collisions may have appeared, or decisions may have drifted while + // the run sat idle. DL-004 requires this before resuming. + if (sub === "resume") { + const guard = await runScript("guard-preconditions.sh", ["resume", slug]); + if (!guard.ok) { + ctx.ui.notify(guard.output, "error"); + return; + } + } const status = sub === "pause" ? "paused" : sub === "resume" ? "active" : "stopped"; const result = await runScript("run-state.sh", ["set-status", slug, status]); if (result.ok) { @@ -197,7 +364,11 @@ async function handleGoalCommand( loop.resume(); scheduleContinuation(ctx); } else { - loop.invalidate(); + loop.suspend(); + // Pausing mid-turn must stop the current work, not just the next + // dispatch — otherwise the agent finishes what it was doing and + // the user thinks pause is broken. + if (sub === "pause") ctx.abort(); } const past = sub === "stop" ? "Stopped" : sub === "pause" ? "Paused" : "Resumed"; ctx.ui.notify(`${past} run ${slug}.`, "info"); @@ -212,11 +383,43 @@ async function handleGoalCommand( ctx.ui.notify("No active run.", "info"); return; } - const result = await runScript("run-state.sh", ["get", slug]); - ctx.ui.notify(result.output, result.ok ? "info" : "warning"); + const read = readRunFrom(`${await projectRoot(ctx)}/.dld/runs/${slug}`); + if (!read.ok) { + ctx.ui.notify(`Could not read run ${slug}: ${read.error.detail}`, "error"); + return; + } + ctx.ui.notify(boardLines(read.state).join("\n"), "info"); + return; + } + case "board": { + const slug = await resolveSlug(true); + if (!slug) { + ctx.ui.notify("No run to show.", "info"); + return; + } + const read = readRunFrom(`${workspace}/.dld/runs/${slug}`); + if (!read.ok) { + ctx.ui.notify(`Could not read run ${slug}: ${read.error.detail}`, "error"); + return; + } + if (!ctx.hasUI) { + ctx.ui.notify(boardLines(read.state).join("\n"), "info"); + return; + } + await ctx.ui.custom((_tui, theme, _kb, done) => { + const lines = boardLines(read.state); + return { + render: () => lines.map((line, i) => (i === 0 ? theme.fg("accent", theme.bold(line)) : line)), + invalidate: () => {}, + handleInput: (data: string) => { + if (data === "\x1b" || data === "q") done(); + }, + dispose: () => {}, + }; + }); return; } default: - ctx.ui.notify("Usage: /dld-goal [status] | start [title] | pause|resume|stop [slug]", "warning"); + ctx.ui.notify("Usage: /dld-goal [status] | start DL-014..DL-022 | start [title] DL-… | pause|resume|stop [slug] | board", "warning"); } } diff --git a/extensions/dld-goal/loop.test.ts b/extensions/dld-goal/loop.test.ts index e2f62b7..496c1e6 100644 --- a/extensions/dld-goal/loop.test.ts +++ b/extensions/dld-goal/loop.test.ts @@ -70,6 +70,9 @@ function makePi() { function installStatefulScripts(pi: ReturnType, slug: string) { pi.setExec(async (call) => { const joined = call.args.join(" "); + if (call.command === "git" && call.args.includes("rev-parse")) { + return { stdout: `${workspace}\n`, stderr: "", code: 0, killed: false }; + } if (joined.includes("run-state.sh") && call.args[1] === "active") { const state = JSON.parse( require("node:fs").readFileSync(join(workspace, ".dld", "runs", slug, "state.json"), "utf8"), @@ -436,14 +439,13 @@ describe("turn_end transaction", () => { decisions: [{ id: "DL-010", hash: "sha256:x" }], status: "verifying", acceptance: { annotations: [], checks: [] }, - attempts: 1, + attempts: 2, evidence: [{ kind: "annotations", ok: true }], }, ], })); installStatefulScripts(pi, "payments"); pi.onExec({ command: "bash", argsContain: ["verify-item.sh"] }, { stdout: "", stderr: "annotations missing", code: 1 }); - pi.onExec({ command: "bash", argsContain: ["bump-attempt"] }, { stdout: "2\n", code: 0 }); pi.onExec({ command: "bash", argsContain: ["block-item.sh"] }, { stdout: "Item 1 blocked.\n", code: 0 }); dldGoalExtension(pi.api); @@ -452,7 +454,8 @@ describe("turn_end transaction", () => { const blockCalls = pi.execCalls.filter((c) => c.args.some((a) => a.includes("block-item.sh"))); expect(blockCalls).toHaveLength(1); expect(blockCalls[0]?.args).toContain("--reason"); - expect(blockCalls[0]?.args).toContain("--force"); + // No --force: attempts is already 2, so the retry has been used. + expect(blockCalls[0]?.args).not.toContain("--force"); }); test("retries once on first verification failure and blocks on second", async () => { @@ -483,16 +486,161 @@ describe("turn_end transaction", () => { }); describe("commands", () => { - test("start creates a run and invalidates the loop token", async () => { + test("start creates a run with items and dispatches the first continuation", async () => { const pi = makePi(); pi.onExec({ command: "bash", argsContain: ["run-state.sh", "active"] }, { stdout: "", code: 1 }); + pi.onExec({ command: "bash", argsContain: ["guard-preconditions.sh"] }, { stdout: "", code: 0 }); pi.onExec({ command: "bash", argsContain: ["create-run.sh"] }, { stdout: "Created run payments\n", code: 0 }); + pi.onExec({ command: "bash", argsContain: ["add-item"] }, { stdout: "", code: 0 }); dldGoalExtension(pi.api); - await pi.invokeCommand("dld-goal", "start payments Payment gateway"); + await pi.invokeCommand("dld-goal", "start payments DL-001 DL-002"); + expect(pi.execCalls.some((c) => c.args.some((a) => a.includes("guard-preconditions.sh")))).toBe(true); expect(pi.execCalls.some((c) => c.args.some((a) => a.includes("create-run.sh")))).toBe(true); - expect(pi.notifications.some((n) => n.message.includes("Created and activated run"))).toBe(true); + expect(pi.execCalls.filter((c) => c.args.some((a) => a.includes("add-item")))).toHaveLength(2); + expect(pi.notifications.some((n) => n.message.includes("Started run payments · 2 items"))).toBe(true); + }); + + test("start refuses without decisions instead of creating an empty run", async () => { + const pi = makePi(); + pi.onExec({ command: "bash", argsContain: ["run-state.sh", "active"] }, { stdout: "", code: 1 }); + dldGoalExtension(pi.api); + + await pi.invokeCommand("dld-goal", "start payments"); + + expect(pi.execCalls.every((c) => !c.args.some((a) => a.includes("create-run.sh")))).toBe(true); + expect(pi.notifications.some((n) => n.message.includes("A run needs decisions"))).toBe(true); + }); + + test("start refuses when preconditions fail", async () => { + const pi = makePi(); + pi.onExec({ command: "bash", argsContain: ["run-state.sh", "active"] }, { stdout: "", code: 1 }); + pi.onExec({ command: "bash", argsContain: ["guard-preconditions.sh"] }, { stdout: "", stderr: "dirty tree", code: 1 }); + dldGoalExtension(pi.api); + + await pi.invokeCommand("dld-goal", "start payments DL-001"); + + expect(pi.execCalls.every((c) => !c.args.some((a) => a.includes("create-run.sh")))).toBe(true); + expect(pi.notifications.some((n) => n.message.includes("dirty tree"))).toBe(true); + }); + + test("start expands a range into items with a derived slug", async () => { + const pi = makePi(); + pi.onExec({ command: "bash", argsContain: ["run-state.sh", "active"] }, { stdout: "", code: 1 }); + pi.onExec({ command: "bash", argsContain: ["guard-preconditions.sh"] }, { stdout: "", code: 0 }); + pi.onExec({ command: "bash", argsContain: ["create-run.sh"] }, { stdout: "Created\n", code: 0 }); + pi.onExec({ command: "bash", argsContain: ["add-item"] }, { stdout: "", code: 0 }); + dldGoalExtension(pi.api); + + await pi.invokeCommand("dld-goal", "start DL-014..DL-016"); + + expect(pi.execCalls.filter((c) => c.args.some((a) => a.includes("add-item")))).toHaveLength(3); + expect(pi.execCalls.some((c) => c.args.includes("dl-14-16"))).toBe(true); + expect(pi.notifications.some((n) => n.message.includes("Started run dl-14-16 · 3 items"))).toBe(true); + }); + + test("start with only positional decisions keeps every one", async () => { + const pi = makePi(); + pi.onExec({ command: "bash", argsContain: ["run-state.sh", "active"] }, { stdout: "", code: 1 }); + pi.onExec({ command: "bash", argsContain: ["guard-preconditions.sh"] }, { stdout: "", code: 0 }); + pi.onExec({ command: "bash", argsContain: ["create-run.sh"] }, { stdout: "Created\n", code: 0 }); + pi.onExec({ command: "bash", argsContain: ["add-item"] }, { stdout: "", code: 0 }); + dldGoalExtension(pi.api); + + await pi.invokeCommand("dld-goal", "start DL-014 DL-015"); + + expect(pi.execCalls.filter((c) => c.args.some((a) => a.includes("add-item")))).toHaveLength(2); + expect(pi.execCalls.some((c) => c.args.includes("dl-014-015"))).toBe(true); + }); + + test("a single positional decision is accepted, not refused", async () => { + const pi = makePi(); + pi.onExec({ command: "bash", argsContain: ["run-state.sh", "active"] }, { stdout: "", code: 1 }); + pi.onExec({ command: "bash", argsContain: ["guard-preconditions.sh"] }, { stdout: "", code: 0 }); + pi.onExec({ command: "bash", argsContain: ["create-run.sh"] }, { stdout: "Created\n", code: 0 }); + pi.onExec({ command: "bash", argsContain: ["add-item"] }, { stdout: "", code: 0 }); + dldGoalExtension(pi.api); + + await pi.invokeCommand("dld-goal", "start DL-014"); + + expect(pi.execCalls.filter((c) => c.args.some((a) => a.includes("add-item")))).toHaveLength(1); + }); + + test("pause aborts the current turn, not just the next dispatch", async () => { + const pi = makePi(); + writeState("payments", activeState()); + installStatefulScripts(pi, "payments"); + dldGoalExtension(pi.api); + + await pi.invokeCommand("dld-goal", "pause"); + + expect(pi.wasAborted()).toBe(true); + }); + + test("suspension covers the write path: turn_end mutates nothing while suspended", async () => { + const pi = makePi(); + writeState("payments", activeState({ + review: "disabled", + items: [ + { + index: 1, + decisions: [{ id: "DL-010", hash: "sha256:x" }], + status: "verifying", + acceptance: { annotations: [], checks: [] }, + attempts: 1, + evidence: [{ kind: "annotations", ok: true }], + }, + ], + })); + installStatefulScripts(pi, "payments"); + pi.onExec({ command: "bash", argsContain: ["verify-item.sh"] }, { stdout: "", code: 0 }); + dldGoalExtension(pi.api); + + await pi.emit("input", {}); + await pi.emit("turn_end", {}); + + expect(pi.execCalls.every((c) => !c.args.some((a) => a.includes("verify-item.sh")))).toBe(true); + }); + + test("an aborted turn suspends the loop instead of redispatching", async () => { + const pi = makePi(); + writeState("payments", activeState()); + installStatefulScripts(pi, "payments"); + dldGoalExtension(pi.api); + + await pi.emit("agent_end", { + messages: [{ role: "assistant", stopReason: "aborted", content: [] }], + }); + + expect(pi.messages.filter((m) => m.customType === "dld-goal:continuation")).toHaveLength(0); + expect(pi.notifications.some((n) => n.message.includes("suspended (interrupted)"))).toBe(true); + }); + + test("a non-aborted turn end still dispatches", async () => { + const pi = makePi(); + writeState("payments", activeState()); + installStatefulScripts(pi, "payments"); + dldGoalExtension(pi.api); + + await pi.emit("agent_end", { + messages: [{ role: "assistant", stopReason: "stop", content: [] }], + }); + + expect(pi.messages.filter((m) => m.customType === "dld-goal:continuation")).toHaveLength(1); + }); + + test("start tolerates range separators with spaces", async () => { + const pi = makePi(); + pi.onExec({ command: "bash", argsContain: ["run-state.sh", "active"] }, { stdout: "", code: 1 }); + pi.onExec({ command: "bash", argsContain: ["guard-preconditions.sh"] }, { stdout: "", code: 0 }); + pi.onExec({ command: "bash", argsContain: ["create-run.sh"] }, { stdout: "Created\n", code: 0 }); + pi.onExec({ command: "bash", argsContain: ["add-item"] }, { stdout: "", code: 0 }); + dldGoalExtension(pi.api); + + await pi.invokeCommand("dld-goal", "start DL-014 - DL-015"); + + expect(pi.execCalls.filter((c) => c.args.some((a) => a.includes("add-item")))).toHaveLength(2); }); test("pause invalidates the token so agent_end stops dispatching", async () => { diff --git a/extensions/dld-goal/loop.ts b/extensions/dld-goal/loop.ts index 95299a1..5e83b58 100644 --- a/extensions/dld-goal/loop.ts +++ b/extensions/dld-goal/loop.ts @@ -1,7 +1,7 @@ import { join } from "node:path"; import { scriptPath } from "./paths.ts"; import type { ExecLike } from "./run-state.ts"; -import { readRunFrom, type RunState } from "./run-state.ts"; +import { activeMinutes, readEventsFrom, readRunFrom, type RunState } from "./run-state.ts"; // @decision(DL-008) @decision(DL-004) // In-session continuation: agent_end advances an active run when everything @@ -18,6 +18,9 @@ import { readRunFrom, type RunState } from "./run-state.ts"; export interface LoopUi { notify(message: string, type?: "info" | "warning" | "error"): void; + /** Append a transcript card for an item outcome. Optional so non-card + * contexts (tests, print mode) can ignore it. */ + card?(lines: string[]): void; } export interface LoopContext { @@ -45,6 +48,10 @@ export class LoopController { /** Items already told that they are waiting on review, so turn_end does not * repeat the warning on every turn while the item stays verifying. */ private reviewNagged = new Set(); + /** Evidence count at the last verification per item, so verify-item.sh + * (which runs the project test suite) only re-runs when new evidence + * arrived, not on every turn the item sits in verifying. */ + private verifiedAtEvidence = new Map(); constructor(private exec: ExecLike) {} @@ -65,6 +72,7 @@ export class LoopController { /** Mint a new token. Every queued continuation carrying an older token is void. */ invalidate(): number { this.token += 1; + this.reviewNagged.clear(); return this.token; } @@ -72,23 +80,33 @@ export class LoopController { return this.token; } - private async runScript(cwd: string, name: string, args: string[]): Promise { + private async runScript(name: string, args: string[]): Promise { const result = await this.exec("bash", [scriptPath(name), ...args]); const output = result.stdout.length > 0 ? result.stdout.trimEnd() : result.stderr.trimEnd(); return { ok: result.code === 0, code: result.code, output }; } - private contextCwd(ctx: LoopContext): string { - return ctx.cwd; + private projectRootCache = new Map(); + + /** The scripts resolve .dld/ from the git root; the extension must match + * or a session started in a subdirectory sees a different filesystem. */ + private async projectRoot(ctx: LoopContext): Promise { + const cached = this.projectRootCache.get(ctx.cwd); + if (cached) return cached; + const result = await this.exec("git", ["-C", ctx.cwd, "rev-parse", "--show-toplevel"]); + const root = result.code === 0 ? result.stdout.trim() : ctx.cwd; + this.projectRootCache.set(ctx.cwd, root); + return root; } /** Active run from a context, or null. Active slug resolution goes through run-state.sh. */ private async activeRun(ctx: LoopContext): Promise { - const active = await this.runScript(this.contextCwd(ctx), "run-state.sh", ["active"]); + const root = await this.projectRoot(ctx); + const active = await this.runScript("run-state.sh", ["active"]); if (!active.ok) return null; const slug = active.output.trim(); if (!slug) return null; - const runDir = join(this.contextCwd(ctx), ".dld", "runs", slug); + const runDir = join(root, ".dld", "runs", slug); const read = readRunFrom(runDir); if (!read.ok) return null; return { slug, state: read.state, runDir }; @@ -99,19 +117,22 @@ export class LoopController { * to be current before anything else is considered. */ async onAgentEnd(capturedToken: number, ctx: LoopContext, ui: LoopUi): Promise { - if (capturedToken !== this.token) return false; + // The token is captured by the caller at schedule time and checked + // again after the awaited script calls, just before dispatch — a pause + // or stop landing mid-flight voids it. The on-disk status check is the + // primary anti-stale guard; this catches the in-flight race. if (!ctx.isIdle() || ctx.hasPendingMessages()) return false; const active = await this.activeRun(ctx); if (!active) return false; if (active.state.status !== "active") return false; - if (!this.withinBounds(active.state)) { + if (!this.withinBounds(active.state, active.runDir)) { await this.pauseAtBounds(active, ctx, ui); return false; } - const next = await this.runScript(this.contextCwd(ctx), "next-item.sh", [active.slug]); + const next = await this.runScript("next-item.sh", [active.slug]); if (!next.ok) { if (next.code === 2) { await this.pauseRun(active.slug, ctx, ui, next.output); @@ -145,41 +166,58 @@ export class LoopController { // race at the start; this catches the race at the end. if (capturedToken !== this.token) return false; + // Claim the item before dispatching so the next agent_end sees it as + // in-flight rather than re-dispatching the same work. next-item.sh + // prefers in-flight items, so claiming makes the loop single-threaded. + const claimed = await this.runScript("run-state.sh", [ + "set-item-status", + active.slug, + String(item.index), + "implementing", + ]); + if (!claimed.ok) { + ui.notify(`Could not claim item ${item.index}: ${claimed.output}`, "error"); + return false; + } + const decisions = item.decisions.map((d) => d.id).join(", "); ui.notify(`Continue goal run '${active.slug}'. Work item ${index} (${decisions}).`, "info"); return true; } private async completeRun(slug: string, ctx: LoopContext, ui: LoopUi): Promise { - const result = await this.runScript(this.contextCwd(ctx), "run-state.sh", ["set-status", slug, "complete"]); + const result = await this.runScript("run-state.sh", ["set-status", slug, "complete"]); if (!result.ok) { ui.notify(`Could not complete run ${slug}: ${result.output}`, "error"); return; } this.invalidate(); - await this.runScript(this.contextCwd(ctx), "append-event.sh", [slug, "run-completed"]); + await this.runScript("append-event.sh", [slug, "run-completed"]); ui.notify(`Run ${slug} complete — every item is accepted or skipped.`, "info"); } private async pauseRun(slug: string, ctx: LoopContext, ui: LoopUi, reason: string): Promise { - const result = await this.runScript(this.contextCwd(ctx), "run-state.sh", ["set-status", slug, "paused"]); + // A blocked item keeps its blocked status — pausing must not collapse + // the distinction the contract's transition table makes. + const result = await this.runScript("run-state.sh", ["set-status", slug, "paused"]); if (result.ok) this.invalidate(); ui.notify(reason || `Run ${slug} paused.`, "warning"); } - private withinBounds(state: RunState): boolean { + private withinBounds(state: RunState, runDir: string): boolean { const accepted = state.items.filter((item) => item.status === "accepted" || item.status === "skipped").length; if (state.bounds.maxItems > 0 && accepted >= state.bounds.maxItems) return false; if (state.bounds.maxMinutes > 0) { - // The contract's cap is wall-clock, so idle time between turns counts. - const elapsedMin = (Date.now() - Date.parse(state.createdAt)) / 60000; + // The bound measures active time: pauses, overnight gaps, and idle + // sessions do not count toward it. + const elapsedMin = activeMinutes(state, readEventsFrom(runDir).events); if (elapsedMin >= state.bounds.maxMinutes) return false; } return true; } private async pauseAtBounds(active: ActiveRun, ctx: LoopContext, ui: LoopUi): Promise { - const result = await this.runScript(this.contextCwd(ctx), "run-state.sh", ["set-status", active.slug, "paused"]); + const result = await this.runScript("run-state.sh", ["set-status", active.slug, "paused"]); if (!result.ok) { ui.notify(`Could not pause run ${active.slug}: ${result.output}`, "error"); return; @@ -192,14 +230,22 @@ export class LoopController { * Advance an item through verification and completion. Delegate every write. */ async onTurnEnd(ctx: LoopContext, ui: LoopUi): Promise { + // Suspension covers the write path too: a suspended loop mutates nothing. + if (this.suspended) return; const active = await this.activeRun(ctx); if (!active) return; if (active.state.status !== "active") return; - const item = active.state.items.find((entry) => entry.status === "verifying" && entry.evidence.length > 0); + const item = active.state.items.find( + (entry) => + entry.status === "verifying" && + entry.evidence.length > 0 && + entry.evidence.length !== this.verifiedAtEvidence.get(entry.index), + ); if (!item) return; - const verify = await this.runScript(this.contextCwd(ctx), "verify-item.sh", [active.slug, String(item.index)]); + this.verifiedAtEvidence.set(item.index, item.evidence.length); + const verify = await this.runScript("verify-item.sh", [active.slug, String(item.index)]); if (verify.code === 0) { if (active.state.review === "enabled") { @@ -215,7 +261,7 @@ export class LoopController { } return; } - const accepted = await this.runScript(this.contextCwd(ctx), "run-state.sh", [ + const accepted = await this.runScript("run-state.sh", [ "set-item-status", active.slug, String(item.index), @@ -225,12 +271,12 @@ export class LoopController { ui.notify(`Could not mark item ${item.index} accepted: ${accepted.output}`, "error"); return; } - const repinned = await this.runScript(this.contextCwd(ctx), "run-state.sh", ["repin-item", active.slug, String(item.index)]); + const repinned = await this.runScript("run-state.sh", ["repin-item", active.slug, String(item.index)]); if (!repinned.ok) { ui.notify(`Could not repin item ${item.index}: ${repinned.output}`, "error"); return; } - const eventAppended = await this.runScript(this.contextCwd(ctx), "append-event.sh", [ + const eventAppended = await this.runScript("append-event.sh", [ active.slug, "item-accepted", "--data", @@ -241,18 +287,18 @@ export class LoopController { return; } ui.notify(`Item ${item.index} accepted (verification passed, review disabled).`, "info"); + ui.card?.([ + `✔ item ${item.index} accepted · ${item.decisions.map((d) => d.id).join(", ")}`, + ...item.evidence.slice(0, 4).map((e) => ` ${typeof e === "string" ? e : JSON.stringify(e)}`), + ]); return; } - const bump = await this.runScript(this.contextCwd(ctx), "run-state.sh", ["bump-attempt", active.slug, String(item.index)]); - const attempts = Number(bump.output.trim()); - if (!Number.isFinite(attempts)) { - ui.notify(`bump-attempt returned an unexpected output: ${bump.output}`, "error"); - return; - } - - if (attempts < 2) { - const retried = await this.runScript(this.contextCwd(ctx), "run-state.sh", [ + // attempts counts completed attempts. The skill claims with bump-attempt + // (0→1) so a first failure sees attempts=1 and retries; a second failure + // sees attempts=2 and blocks. Do not bump here — the next claim does it. + if (item.attempts < 2) { + const retried = await this.runScript("run-state.sh", [ "set-item-status", active.slug, String(item.index), @@ -262,16 +308,15 @@ export class LoopController { ui.notify(`Could not send item ${item.index} back for a retry: ${retried.output}`, "error"); return; } - ui.notify(`Item ${item.index} verification failed; retrying (attempt ${attempts}).`, "warning"); + ui.notify(`Item ${item.index} verification failed; retrying (attempt ${item.attempts + 1}).`, "warning"); return; } - const blocker = await this.runScript(this.contextCwd(ctx), "block-item.sh", [ + const blocker = await this.runScript("block-item.sh", [ active.slug, String(item.index), "--reason", verify.output, - "--force", ]); if (!blocker.ok) { ui.notify(blocker.output, "error"); diff --git a/extensions/dld-goal/render.test.ts b/extensions/dld-goal/render.test.ts new file mode 100644 index 0000000..b12b589 --- /dev/null +++ b/extensions/dld-goal/render.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from "bun:test"; +import { boardLines, statusLine, widgetLines } from "./render.ts"; +import { activeMinutes, type RunState, type WorkItem } from "./run-state.ts"; + +function item(index: number, status: WorkItem["status"], decisions: string[] = []): WorkItem { + return { + index, + decisions: decisions.map((id) => ({ id, hash: "sha256:x" })), + status, + acceptance: { annotations: [], checks: [] }, + attempts: 0, + evidence: [], + }; +} + +function stateWith(items: WorkItem[], overrides: Partial = {}): RunState { + return { + schemaVersion: 1, + slug: "payments", + title: "Payment gateway", + status: "active", + createdAt: new Date(Date.now() - 34 * 60 * 1000).toISOString(), + updatedAt: new Date().toISOString(), + bounds: { maxItems: 0, maxMinutes: 120 }, + review: "enabled", + currentItem: items.find((i) => i.status === "implementing" || i.status === "verifying")?.index ?? null, + items, + blockedQuestions: [], + ...overrides, + }; +} + +describe("statusLine", () => { + test("shows slug, progress, current item, and elapsed against bound", () => { + const state = stateWith([item(1, "accepted", ["DL-010"]), item(2, "verifying", ["DL-011", "DL-012"])]); + const line = statusLine(state); + expect(line).toContain("payments 1/2"); + expect(line).toContain("DL-011 DL-012 verifying"); + expect(line).toMatch(/\d+m\/120m/); + }); + + test("names open blocked questions", () => { + const state = stateWith([item(1, "blocked")], { + blockedQuestions: [{ itemIndex: 1, question: "which sandbox?" }], + }); + expect(statusLine(state)).toContain("1 blocked question"); + }); + + test("no bound means plain elapsed", () => { + const state = stateWith([item(1, "pending")], { bounds: { maxItems: 0, maxMinutes: 0 } }); + expect(statusLine(state)).toMatch(/\d+m$/); + }); +}); + +describe("widgetLines", () => { + test("renders exactly five lines for a small run", () => { + const state = stateWith([item(1, "accepted", ["DL-010"]), item(2, "verifying", ["DL-011"]), item(3, "pending")]); + const lines = widgetLines(state); + expect(lines).toHaveLength(5); + expect(lines[0]).toContain("dld-goal payments"); + expect(lines[0]).toContain("1/3"); + }); + + test("renders exactly five lines for a large run — height is invariant", () => { + const many = Array.from({ length: 30 }, (_, i) => + item(i + 1, i < 14 ? "accepted" : i === 14 ? "implementing" : "pending", [`DL-${String(i + 10).padStart(3, "0")}`]), + ); + const lines = widgetLines(stateWith(many)); + expect(lines).toHaveLength(5); + expect(lines.some((l) => l.includes("more"))).toBe(true); + }); + + test("an empty run still renders five lines", () => { + expect(widgetLines(stateWith([]))).toHaveLength(5); + }); + + test("the current item is always inside the window", () => { + const many = Array.from({ length: 30 }, (_, i) => + item(i + 1, i === 27 ? "verifying" : i < 27 ? "accepted" : "pending"), + ); + const lines = widgetLines(stateWith(many)); + expect(lines.some((l) => l.includes("▸ 28") || l.includes("28"))).toBe(true); + }); + + test("every line is short enough for a narrow terminal", () => { + const many = Array.from({ length: 30 }, (_, i) => item(i + 1, "pending", [`DL-${i}`])); + for (const line of widgetLines(stateWith(many))) { + expect(line.length).toBeLessThanOrEqual(60); + } + }); +}); + +describe("activeMinutes", () => { + test("a run that was never paused measures wall-clock", () => { + const state = stateWith([item(1, "pending")]); + const minutes = activeMinutes(state, []); + expect(minutes).toBeGreaterThan(30); + expect(minutes).toBeLessThan(40); + }); + + test("paused time does not count", () => { + const state = stateWith([item(1, "pending")]); + const created = Date.parse(state.createdAt); + const events = [ + { type: "run-paused", timestamp: new Date(created + 10 * 60000).toISOString() }, + { type: "run-resumed", timestamp: new Date(created + 700 * 60000).toISOString() }, + ]; + const minutes = activeMinutes(state, events); + // 10 minutes before the pause, plus a little since resume — not 700+. + expect(minutes).toBeLessThan(50); + expect(minutes).toBeGreaterThan(9); + }); + + test("a completed run stops counting", () => { + const state = stateWith([item(1, "accepted")]); + const created = Date.parse(state.createdAt); + const events = [ + { type: "run-paused", timestamp: new Date(created + 10 * 60000).toISOString() }, + { type: "run-completed", timestamp: new Date(created + 20 * 60000).toISOString() }, + ]; + const minutes = activeMinutes(state, events); + expect(minutes).toBe(10); + }); +}); + + +describe("boardLines", () => { + test("shows every item, evidence, and questions without a height cap", () => { + const ev = { kind: "annotations", ok: true }; + const items = [item(1, "accepted", ["DL-010"]), item(2, "blocked", ["DL-011"])]; + items[0]!.evidence.push(ev); + const state = stateWith(items, { + status: "blocked", + blockedQuestions: [{ itemIndex: 2, question: "which sandbox?" }], + }); + const lines = boardLines(state); + expect(lines.length).toBeGreaterThan(8); + expect(lines.join("\n")).toContain("item 2 · DL-011 · blocked"); + expect(lines.join("\n")).toContain("which sandbox?"); + expect(lines.join("\n")).toContain("esc to close"); + }); +}); diff --git a/extensions/dld-goal/render.ts b/extensions/dld-goal/render.ts new file mode 100644 index 0000000..3350bda --- /dev/null +++ b/extensions/dld-goal/render.ts @@ -0,0 +1,124 @@ +import type { RunState, WorkItem } from "./run-state.ts"; + +// @decision(DL-011) +// Pure rendering: every function takes run state and returns plain strings. +// Nothing here touches pi, the filesystem, or the terminal — the wiring in +// index.ts maps these onto setStatus, setWidget, and appendEntry. The widget +// height is fixed at five lines regardless of run size; that invariance is +// the protection against the pi-goal-x scrollback failure and is covered by +// render.test.ts. + +const WIDGET_HEIGHT = 5; + +function elapsedLabel(state: RunState, activeMinutes?: number): string { + // Active time is what the bound measures; wall-clock since creation counts + // overnight pauses, which is why a resumed run showed 703m of nothing. + const elapsedMin = activeMinutes ?? Math.max(0, (Date.now() - Date.parse(state.createdAt)) / 60000); + if (state.bounds.maxMinutes > 0) { + return `${Math.floor(elapsedMin)}m/${state.bounds.maxMinutes}m`; + } + return `${Math.floor(elapsedMin)}m`; +} + +function decisionIds(item: WorkItem): string { + return item.decisions.map((d) => d.id).join(" "); +} + +function itemIcon(status: WorkItem["status"]): string { + switch (status) { + case "accepted": return "✔"; + case "skipped": return "–"; + case "implementing": + case "verifying": return "▸"; + case "blocked": + case "failed": return "✖"; + default: return "○"; + } +} + +export function statusLine(state: RunState, activeMinutes?: number): string { + const total = state.items.length; + const done = state.items.filter((i) => i.status === "accepted" || i.status === "skipped").length; + const current = state.currentItem !== null ? state.items.find((i) => i.index === state.currentItem) : undefined; + const currentBit = current ? ` · ${decisionIds(current)} ${current.status}` : ""; + const blockedCount = state.blockedQuestions.filter((q) => !q.answer).length; + const blockedBit = blockedCount > 0 ? ` · ${blockedCount} blocked question${blockedCount === 1 ? "" : "s"}` : ""; + return `◆ ${state.slug} ${done}/${total}${currentBit} · ${elapsedLabel(state, activeMinutes)}${blockedBit}`; +} + +/** + * The item window: always exactly WIDGET_HEIGHT lines. One or two completed + * items for context, the current item, the next pending item or two, and a + * "+N more" line when items fall outside the window. A 3-item run and a + * 30-item run render the same number of lines. + */ +export function widgetLines(state: RunState, activeMinutes?: number): string[] { + const items = state.items; + const total = items.length; + const done = items.filter((i) => i.status === "accepted" || i.status === "skipped").length; + const header = `dld-goal ${state.slug} ─── ${done}/${total} · ${elapsedLabel(state, activeMinutes)}`; + + if (total === 0) { + return [header, " no items yet", "", "", ""]; + } + + const currentIdx = state.currentItem !== null + ? items.findIndex((i) => i.index === state.currentItem) + : items.findIndex((i) => i.status === "pending"); + const anchor = currentIdx >= 0 ? currentIdx : 0; + + // Window: one item before the anchor, the anchor, items after — but never + // more than will fit with the header and the "more" line, which is + // reserved whenever anything is hidden. + const before = Math.max(0, anchor - 1); + let windowItems = items.slice(before, anchor + 3); + let hiddenAfter = total - (before + windowItems.length); + const reserveMore = before > 0 || hiddenAfter > 0; + const maxWindow = WIDGET_HEIGHT - 1 - (reserveMore ? 1 : 0); + if (windowItems.length > maxWindow) { + windowItems = windowItems.slice(0, maxWindow); + hiddenAfter = total - (before + windowItems.length); + } + + const lines = [header]; + for (const item of windowItems) { + const ids = decisionIds(item); + const label = `${itemIcon(item.status)} ${item.index} ${ids || "—"}`; + const right = item.status === "accepted" ? "accepted" : item.status; + lines.push(` ${label.padEnd(28)} ${right}`); + } + while (lines.length < WIDGET_HEIGHT - (reserveMore ? 1 : 0)) lines.push(""); + if (reserveMore) { + const more = [ + before > 0 ? `+${before} before` : "", + hiddenAfter > 0 ? `+${hiddenAfter} more` : "", + ].filter(Boolean).join(" · "); + lines.push(` ${more}`); + } + return lines.slice(0, WIDGET_HEIGHT); +} + +/** Full board content for the overlay. No height cap — scrollback is free here. */ +export function boardLines(state: RunState): string[] { + const lines: string[] = [ + `dld-goal board — ${state.slug}`, + `status: ${state.status} · created ${state.createdAt} · ${elapsedLabel(state)}`, + `bounds: ${state.bounds.maxItems || "∞"} items · ${state.bounds.maxMinutes || "∞"} minutes · review ${state.review}`, + "", + ]; + if (state.items.length === 0) lines.push(" no items"); + for (const item of state.items) { + lines.push(`${itemIcon(item.status)} item ${item.index} · ${decisionIds(item) || "—"} · ${item.status} · attempts ${item.attempts}`); + for (const ev of item.evidence) { + lines.push(` ${typeof ev === "string" ? ev : JSON.stringify(ev)}`); + } + } + if (state.blockedQuestions.length > 0) { + lines.push("", "questions:"); + for (const q of state.blockedQuestions) { + lines.push(` ${q.answer ? "✔" : "?"} item ${q.itemIndex}: ${q.question}${q.answer ? ` — ${q.answer}` : ""}`); + } + } + lines.push("", "esc to close"); + return lines; +} diff --git a/extensions/dld-goal/run-state.test.ts b/extensions/dld-goal/run-state.test.ts index 37ab51d..3bd5034 100644 --- a/extensions/dld-goal/run-state.test.ts +++ b/extensions/dld-goal/run-state.test.ts @@ -7,7 +7,6 @@ import { parseStateText, readEventsFrom, readRunFrom, - stateMutations, type RunState, } from "./run-state.ts"; import { createFakePi } from "./testing/fake-pi.ts"; @@ -114,160 +113,3 @@ describe("events", () => { }); }); -describe("delegation", () => { - test("every mutation executes the skill script, nothing else", async () => { - const pi = createFakePi(); - const m = stateMutations((c, a) => pi.api.exec(c, a)); - - await m.setStatus("payments", "paused"); - await m.setItemStatus("payments", 2, "verifying"); - await m.addEvidence("payments", 2, '{"annotations":"ok"}'); - await m.appendEvent("payments", "item_accepted", '{"index":2}'); - - expect(pi.execCalls).toHaveLength(4); - for (const call of pi.execCalls) { - expect(call.command).toBe("bash"); - // args[0] is the absolute script path; args[1..] is the operation. - expect(call.args[0]).toContain("skills/dld-goal/scripts/"); - } - expect(pi.execCalls[0]?.args[0]).toContain("run-state.sh"); - expect(pi.execCalls[3]?.args[0]).toContain("append-event.sh"); - }); - - test("mutation failures surface script stderr rather than throwing", async () => { - const pi = createFakePi(); - pi.onExec({ command: "bash" }, { stdout: "", stderr: "Validate failed", code: 1 }); - const m = stateMutations((c, a) => pi.api.exec(c, a)); - - const result = await m.setStatus("payments", "paused"); - expect(result.ok).toBe(false); - expect(result.output).toBe("Validate failed"); - }); - - test("argv is passed positionally, not assembled into a string", async () => { - const pi = createFakePi(); - const m = stateMutations((c, a) => pi.api.exec(c, a)); - - await m.addItem("payments", { - decisions: ["DL-010", "DL-011"], - annotations: ["src/billing.ts"], - checks: [["npm", "test", "--", "src/billing"]], - }); - - const call = pi.execCalls[0]; - expect(call?.args).toEqual([ - expect.stringContaining("run-state.sh"), - "add-item", - "payments", - "--decisions", - "DL-010,DL-011", - "--annotation", - "src/billing.ts", - "--check", - "npm test -- src/billing", - ]); - }); - - test("verify-hashes forwards the --all flag through argv", async () => { - const pi = createFakePi(); - const m = stateMutations((c, a) => pi.api.exec(c, a)); - - await m.verifyHashes("payments", true); - - expect(pi.execCalls[0]?.args.slice(-1)).toEqual(["--all"]); - }); - - test("argv carries the operation in order, not just the flags", async () => { - const pi = createFakePi(); - const m = stateMutations((c, a) => pi.api.exec(c, a)); - - await m.setStatus("payments", "blocked"); - - const call = pi.execCalls[0]; - expect(call?.args).toEqual([expect.stringContaining("run-state.sh"), "set-status", "payments", "blocked"]); - }); - - test("stdout is preferred; stderr is the detail when there is no stdout", async () => { - const pi = createFakePi(); - pi.onExec({ command: "bash" }, { stdout: "", stderr: "run not found", code: 1 }); - const m = stateMutations((c, a) => pi.api.exec(c, a)); - - const result = await m.getStatus("ghost"); - expect(result.ok).toBe(false); - expect(result.output).toContain("run not found"); - }); - - test("next-item failure code is the signal to pause, and output carries the operator question", async () => { - const pi = createFakePi(); - pi.onExec({ command: "bash" }, { code: 2, stderr: "item 3 is blocked: How do I answer this?", stdout: "" }); - const m = stateMutations((c, a) => pi.api.exec(c, a)); - - const result = await m.nextItem("payments"); - expect(result.ok).toBe(false); - expect(result.code).toBe(2); - expect(result.output).toContain("blocked"); - }); - - test("block-item sends reason and question as flags, not positional text", async () => { - const pi = createFakePi(); - const m = stateMutations((c, a) => pi.api.exec(c, a)); - - await m.blockItem("payments", 3, "test caught env missing", { question: "set up the sandbox?", force: true }); - await m.resolveBlock("payments", 3, "done, sandbox ready", "retry"); - - const block = pi.execCalls[0]; - expect(block?.args).toEqual([ - expect.stringContaining("block-item.sh"), - "payments", - "3", - "--reason", - "test caught env missing", - "--question", - "set up the sandbox?", - "--force", - ]); - const resolve = pi.execCalls[1]; - expect(resolve?.args).toEqual([ - expect.stringContaining("resolve-block.sh"), - "payments", - "3", - "--answer", - "done, sandbox ready", - "--action", - "retry", - ]); - }); - - test("block-item without question or force passes only the reason", async () => { - const pi = createFakePi(); - const m = stateMutations((c, a) => pi.api.exec(c, a)); - - await m.blockItem("payments", 3, "times out"); - - const call = pi.execCalls[0]; - expect(call?.args).toEqual([ - expect.stringContaining("block-item.sh"), - "payments", - "3", - "--reason", - "times out", - ]); - }); - - test("append-event omits --data when no payload is provided", async () => { - const pi = createFakePi(); - const m = stateMutations((c, a) => pi.api.exec(c, a)); - - await m.appendEvent("payments", "run_paused", ""); - await m.appendEvent("payments", "item_accepted", '{"index":1}'); - - expect(pi.execCalls[0]?.args).toEqual([expect.stringContaining("append-event.sh"), "payments", "run_paused"]); - expect(pi.execCalls[1]?.args).toEqual([ - expect.stringContaining("append-event.sh"), - "payments", - "item_accepted", - "--data", - '{"index":1}', - ]); - }); -}); diff --git a/extensions/dld-goal/run-state.ts b/extensions/dld-goal/run-state.ts index 905ca3b..cce11cc 100644 --- a/extensions/dld-goal/run-state.ts +++ b/extensions/dld-goal/run-state.ts @@ -84,15 +84,45 @@ export interface EventParseResult { errors: EventLineError[]; } +/** + * Minutes the run was actually in `active` status, derived from the event + * log's pause/resume/stop/complete markers. Wall-clock since creation counts + * overnight pauses; this does not. + */ +export function activeMinutes(state: RunState, events: unknown[]): number { + const created = Date.parse(state.createdAt); + if (!Number.isFinite(created)) return 0; + + interface Marker { + timestamp: number; + active: boolean; + } + const markers: Marker[] = [{ timestamp: created, active: true }]; + for (const event of events) { + if (typeof event !== "object" || event === null) continue; + const e = event as Record; + const ts = Date.parse(String(e.timestamp ?? "")); + if (!Number.isFinite(ts)) continue; + const kind = String(e.type ?? e.kind ?? ""); + if (kind === "run-paused" || kind === "run_paused" || kind === "paused") markers.push({ timestamp: ts, active: false }); + else if (kind === "run-resumed" || kind === "run_resumed" || kind === "resumed") markers.push({ timestamp: ts, active: true }); + else if (kind === "run-completed" || kind === "run-stopped") markers.push({ timestamp: ts, active: false }); + } + + let total = 0; + for (let i = 0; i < markers.length; i += 1) { + const marker = markers[i]!; + if (!marker.active) continue; + const end = markers[i + 1]?.timestamp ?? Date.now(); + total += Math.max(0, end - marker.timestamp); + } + return total / 60000; +} + export interface ExecLike { (command: string, args: string[]): Promise<{ stdout: string; stderr: string; code: number; killed?: boolean }>; } -interface Mutation { - ok: boolean; - code: number; - output: string; -} function validateStateShape(candidate: unknown): candidate is RunState { if (typeof candidate !== "object" || candidate === null) return false; @@ -176,68 +206,3 @@ export function readEventsFrom(runDir: string): EventParseResult { return parseEventsText(text); } -async function runScript(exec: ExecLike, name: string, args: string[]): Promise { - const result = await exec("bash", [scriptPath(name), ...args]); - const output = result.stdout.length > 0 ? result.stdout.trimEnd() : result.stderr.trimEnd(); - return { ok: result.code === 0, code: result.code, output }; -} - -interface AddItemOptions { - decisions: string[]; - annotations?: string[]; - checks?: string[][]; -} - -export function stateMutations(exec: ExecLike) { - const delegate = (name: string, args: string[]) => runScript(exec, name, args); - - return { - getStatus: (slug: string): Promise => delegate("run-state.sh", ["get", slug, ".status"]), - setStatus: (slug: string, status: RunStatus): Promise => - delegate("run-state.sh", ["set-status", slug, status]), - addItem: (slug: string, options: AddItemOptions): Promise => { - const args = ["add-item", slug, "--decisions", options.decisions.join(",")]; - for (const annotation of options.annotations ?? []) args.push("--annotation", annotation); - for (const check of options.checks ?? []) args.push("--check", check.join(" ")); - return delegate("run-state.sh", args); - }, - getItem: (slug: string, index: number): Promise => - delegate("run-state.sh", ["get-item", slug, String(index)]), - setItemStatus: (slug: string, index: number, status: ItemStatus): Promise => - delegate("run-state.sh", ["set-item-status", slug, String(index), status]), - addEvidence: (slug: string, index: number, evidence: string): Promise => - delegate("run-state.sh", ["add-evidence", slug, String(index), evidence]), - bumpAttempt: (slug: string, index: number): Promise => - delegate("run-state.sh", ["bump-attempt", slug, String(index)]), - repinItem: (slug: string, index: number): Promise => - delegate("run-state.sh", ["repin-item", slug, String(index)]), - verifyHashes: (slug: string, all: boolean): Promise => - delegate("verify-hashes.sh", [slug, ...(all ? ["--all"] : [])]), - nextItem: (slug: string): Promise => delegate("next-item.sh", [slug]), - // block-item.sh rejects anything that is not a flag, so reason and - // question go through flags; positional would be an Unknown option. - blockItem: ( - slug: string, - index: number, - reason: string, - options: { question?: string; force?: boolean } = {}, - ): Promise => { - const args = [slug, String(index), "--reason", reason]; - if (options.question) args.push("--question", options.question); - if (options.force) args.push("--force"); - return delegate("block-item.sh", args); - }, - // resolve-block.sh needs both the operator's text and the chosen - // action; --action retry sends the item back to implementing, --action - // skip abandons it and moves the run to later items. - resolveBlock: ( - slug: string, - index: number, - answer: string, - action: "retry" | "skip", - ): Promise => - delegate("resolve-block.sh", [slug, String(index), "--answer", answer, "--action", action]), - appendEvent: (slug: string, type: string, data: string): Promise => - delegate("append-event.sh", [slug, type, ...(data ? ["--data", data] : [])]), - }; -} diff --git a/extensions/dld-goal/surfaces.test.ts b/extensions/dld-goal/surfaces.test.ts new file mode 100644 index 0000000..fa97307 --- /dev/null +++ b/extensions/dld-goal/surfaces.test.ts @@ -0,0 +1,114 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import dldGoalExtension from "./index.ts"; +import { createFakePi } from "./testing/fake-pi.ts"; + +let workspace: string; + +beforeEach(() => { + workspace = mkdtempSync(join(tmpdir(), "dld-goal-ui-")); +}); + +afterEach(() => { + rmSync(workspace, { recursive: true, force: true }); +}); + +function writeActiveRun(slug: string, items: unknown[] = []) { + const runDir = join(workspace, ".dld", "runs", slug); + mkdirSync(runDir, { recursive: true }); + writeFileSync( + join(runDir, "state.json"), + JSON.stringify({ + schemaVersion: 1, + slug, + title: slug, + status: "active", + createdAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(), + updatedAt: new Date().toISOString(), + bounds: { maxItems: 0, maxMinutes: 120 }, + review: "enabled", + currentItem: null, + items, + blockedQuestions: [], + }), + ); +} + +function piWithRun(slug: string, items: unknown[] = []) { + writeActiveRun(slug, items); + const pi = createFakePi({ cwd: workspace, hasUI: true }); + pi.onExec({ command: "bash", argsContain: ["run-state.sh", "active"] }, { stdout: `${slug}\n`, code: 0 }); + return pi; +} + +describe("status line and widget", () => { + test("paints status and widget when a run is active at turn end", async () => { + const pi = piWithRun("payments", [ + { index: 1, decisions: [{ id: "DL-010", hash: "x" }], status: "accepted", acceptance: { annotations: [], checks: [] }, attempts: 1, evidence: [] }, + { index: 2, decisions: [{ id: "DL-011", hash: "x" }], status: "pending", acceptance: { annotations: [], checks: [] }, attempts: 0, evidence: [] }, + ]); + dldGoalExtension(pi.api); + + await pi.emit("turn_end", {}); + + expect(pi.status("dld-goal")).toContain("payments 1/2"); + expect(pi.widget("dld-goal-run")).toHaveLength(5); + expect(pi.widget("dld-goal-run")?.[0]).toContain("dld-goal payments"); + }); + + test("clears both surfaces when no run is active", async () => { + const pi = createFakePi({ cwd: workspace, hasUI: true }); + pi.onExec({ command: "bash", argsContain: ["run-state.sh", "active"] }, { stdout: "", code: 1 }); + dldGoalExtension(pi.api); + + await pi.emit("turn_end", {}); + + expect(pi.status("dld-goal")).toBeUndefined(); + expect(pi.widget("dld-goal-run")).toBeUndefined(); + }); + + test("does not touch surfaces without a UI", async () => { + const pi = createFakePi({ cwd: workspace, hasUI: false }); + pi.onExec({ command: "bash", argsContain: ["run-state.sh", "active"] }, { stdout: "", code: 1 }); + dldGoalExtension(pi.api); + + await pi.emit("turn_end", {}); + + expect(pi.statuses.size).toBe(0); + expect(pi.widgets.size).toBe(0); + }); +}); + +describe("board", () => { + test("falls back to a notify when there is no UI", async () => { + const pi = createFakePi({ cwd: workspace, hasUI: false }); + writeActiveRun("payments"); + pi.onExec({ command: "bash", argsContain: ["run-state.sh", "active"] }, { stdout: "payments\n", code: 0 }); + dldGoalExtension(pi.api); + + await pi.invokeCommand("dld-goal", "board"); + + expect(pi.notifications.some((n) => n.message.includes("dld-goal board — payments"))).toBe(true); + }); + + test("says so when there is no run", async () => { + const pi = createFakePi({ cwd: workspace, hasUI: true }); + pi.onExec({ command: "bash", argsContain: ["run-state.sh", "active"] }, { stdout: "", code: 1 }); + pi.onExec({ command: "bash", argsContain: ["run-state.sh", "list"] }, { stdout: "", code: 0 }); + dldGoalExtension(pi.api); + + await pi.invokeCommand("dld-goal", "board"); + + expect(pi.notifications.some((n) => n.message === "No run to show.")).toBe(true); + }); +}); + +describe("cards", () => { + test("registering the card renderer happens at load", () => { + const pi = createFakePi(); + dldGoalExtension(pi.api); + expect(pi.entryRenderers.has("dld-goal-card")).toBe(true); + }); +}); diff --git a/extensions/dld-goal/testing/fake-pi.test.ts b/extensions/dld-goal/testing/fake-pi.test.ts index 1d4cdcf..a495aeb 100644 --- a/extensions/dld-goal/testing/fake-pi.test.ts +++ b/extensions/dld-goal/testing/fake-pi.test.ts @@ -99,29 +99,7 @@ describe("createFakePi", () => { expect(pi.entries).toEqual([{ customType: "dld-goal-card", data: { item: 2 } }]); }); - test("records tool and renderer registrations", () => { - const pi = createFakePi(); - pi.api.registerEntryRenderer("dld-goal-card", () => ({}) as never); - pi.api.registerMessageRenderer("dld-goal", () => ({}) as never); - pi.api.registerTool({ - name: "dld_goal_item_done", - label: "Item done", - description: "", - parameters: { type: "object" } as never, - execute: async () => ({}) as never, - }); - - expect(pi.entryRenderers.has("dld-goal-card")).toBe(true); - expect(pi.messageRenderers.has("dld-goal")).toBe(true); - expect(pi.tools.has("dld_goal_item_done")).toBe(true); - }); - test("records user messages with their delivery mode", () => { - const pi = createFakePi(); - pi.api.sendUserMessage("work item 2", { deliverAs: "followUp" }); - - expect(pi.userMessages).toEqual([{ content: "work item 2", deliverAs: "followUp" }]); - }); test("clearing a status or widget is distinguishable from never setting one", () => { const pi = createFakePi(); diff --git a/extensions/dld-goal/testing/fake-pi.ts b/extensions/dld-goal/testing/fake-pi.ts index cd827c7..a622a55 100644 --- a/extensions/dld-goal/testing/fake-pi.ts +++ b/extensions/dld-goal/testing/fake-pi.ts @@ -20,10 +20,7 @@ export type PiSurface = Pick< | "on" | "registerCommand" | "registerEntryRenderer" - | "registerMessageRenderer" - | "registerTool" | "sendMessage" - | "sendUserMessage" | "appendEntry" | "exec" >; @@ -32,7 +29,7 @@ export type UiSurface = Pick & { ui: UiSurface }; export interface ExecCall { @@ -78,15 +75,12 @@ export interface FakePi { readonly commands: Map>; readonly events: Map unknown)[]>; readonly entryRenderers: Map; - readonly messageRenderers: Map; - readonly tools: Map; readonly execCalls: ExecCall[]; readonly notifications: Notification[]; readonly statuses: Map; readonly widgets: Map; readonly messages: SentMessage[]; - readonly userMessages: { content: unknown; deliverAs?: string }[]; readonly entries: AppendedEntry[]; /** Queue a result for the next exec whose command and args match. @@ -101,6 +95,8 @@ export interface FakePi { setIdle(value: boolean): void; setPendingMessages(value: boolean): void; + /** Whether ctx.abort() was called. */ + wasAborted(): boolean; /** Fire a registered event handler. Returns each handler's result. */ emit(event: string, payload?: unknown): Promise; @@ -120,22 +116,28 @@ export function createFakePi(options: FakePiOptions = {}): FakePi { const commands = new Map>(); const events = new Map unknown)[]>(); const entryRenderers = new Map(); - const messageRenderers = new Map(); - const tools = new Map(); const execCalls: ExecCall[] = []; const notifications: Notification[] = []; const statuses = new Map(); const widgets = new Map(); const messages: SentMessage[] = []; - const userMessages: { content: unknown; deliverAs?: string }[] = []; const entries: AppendedEntry[] = []; const scripted: { match: { command?: string; argsInclude?: string[]; argsContain?: string[] }; result: ExecResult; }[] = []; - let fallbackExec: ExecResponder = options.exec ?? (() => okResult); + // git rev-parse answers with the fake's cwd so projectRoot resolution + // works without each test scripting it. + let fallbackExec: ExecResponder = + options.exec ?? + ((call) => { + if (call.command === "git" && call.args.includes("rev-parse")) { + return { ...okResult, stdout: `${options.cwd ?? process.cwd()}\n` }; + } + return okResult; + }); let idle = options.idle ?? true; let pendingMessages = options.pendingMessages ?? false; @@ -156,12 +158,16 @@ export function createFakePi(options: FakePiOptions = {}): FakePi { setWidget, }; + let aborted = false; const ctx: CommandSurface = { cwd: options.cwd ?? process.cwd(), hasUI: options.hasUI ?? true, mode: "tui", isIdle: () => idle, hasPendingMessages: () => pendingMessages, + abort: () => { + aborted = true; + }, ui, }; @@ -182,21 +188,12 @@ export function createFakePi(options: FakePiOptions = {}): FakePi { registerEntryRenderer(customType: string, renderer: unknown) { entryRenderers.set(customType, renderer); }, - registerMessageRenderer(customType: string, renderer: unknown) { - messageRenderers.set(customType, renderer); - }, - registerTool(tool: { name: string }) { - tools.set(tool.name, tool); - }, sendMessage( message: { customType: string; content: string | unknown[]; display: boolean; details?: unknown }, sendOptions, ) { messages.push({ ...message, ...(sendOptions ?? {}) }); }, - sendUserMessage(content, sendOptions) { - userMessages.push({ content, deliverAs: sendOptions?.deliverAs }); - }, appendEntry(customType: string, data?: unknown) { entries.push({ customType, data }); }, @@ -220,14 +217,11 @@ export function createFakePi(options: FakePiOptions = {}): FakePi { commands, events, entryRenderers, - messageRenderers, - tools, execCalls, notifications, statuses, widgets, messages, - userMessages, entries, onExec(match, result) { @@ -239,6 +233,9 @@ export function createFakePi(options: FakePiOptions = {}): FakePi { setIdle(value) { idle = value; }, + wasAborted() { + return aborted; + }, setPendingMessages(value) { pendingMessages = value; }, diff --git a/package.json b/package.json index 10e4d7a..f82fdf7 100644 --- a/package.json +++ b/package.json @@ -53,8 +53,8 @@ } }, "devDependencies": { - "@earendil-works/pi-coding-agent": "~0.84.2", - "@earendil-works/pi-tui": "~0.84.2", + "@earendil-works/pi-coding-agent": "~0.83.0", + "@earendil-works/pi-tui": "~0.83.0", "@types/bun": "^1.2.0", "typescript": "^5.6.0" } diff --git a/skills/dld-reindex/scripts/list-taken-ids.sh b/skills/dld-reindex/scripts/list-taken-ids.sh index f666947..b81abe5 100755 --- a/skills/dld-reindex/scripts/list-taken-ids.sh +++ b/skills/dld-reindex/scripts/list-taken-ids.sh @@ -48,10 +48,12 @@ PR_BASE="${BASE#origin/}" # IDs in files touched by open PRs targeting this base. Scope to paths under # the records dir so an unrelated PR touching e.g. notes/DL-007-meeting.md - # doesn't poison the taken set. + # doesn't poison the taken set. A PR whose head is the current branch is not + # a collision: it holds this branch's own decisions. if [[ -z "$SKIP_REASON" ]]; then - gh pr list --state open --base "$PR_BASE" --json files --limit 100 \ - --jq '.[].files[].path' 2>/dev/null \ + CURRENT_BRANCH="$(git -C "$PROJECT_ROOT" branch --show-current 2>/dev/null || true)" + gh pr list --state open --base "$PR_BASE" --json files,headRefName --limit 100 \ + --jq ".[] | select(.headRefName != \"$CURRENT_BRANCH\") | .files[].path" 2>/dev/null \ | grep -E "^${RECORDS_DIR_REL}/" \ | grep -oE 'DL-[0-9]+' || true fi