diff --git a/.claude/skills/dld-common/scripts/common.sh b/.claude/skills/dld-common/scripts/common.sh index ae74449..9e5c9ae 100755 --- a/.claude/skills/dld-common/scripts/common.sh +++ b/.claude/skills/dld-common/scripts/common.sh @@ -98,6 +98,15 @@ get_run_dir() { # Fail with a clear message when jq is unavailable. # Goal run state is JSON; the dld-goal scripts require jq to read and mutate it. +# Print the caller script's usage from its header comment, stopping at the +# first line that is not a comment. Scripts source common.sh, so the script +# whose usage we want is BASH_SOURCE[1]; BASH_SOURCE[0] is common.sh itself. +usage() { + sed -n '2,$p' "${BASH_SOURCE[1]:-${BASH_SOURCE[0]}}" \ + | sed -n '/^[^#]/q; p' \ + | sed 's/^# \{0,1\}//' >&2 +} + require_jq() { if ! command -v jq >/dev/null 2>&1; then echo "Error: jq is required by the dld-goal scripts but was not found on PATH." >&2 diff --git a/.claude/skills/dld-goal/scripts/append-event.sh b/.claude/skills/dld-goal/scripts/append-event.sh index f0cda5a..93d89d5 100755 --- a/.claude/skills/dld-goal/scripts/append-event.sh +++ b/.claude/skills/dld-goal/scripts/append-event.sh @@ -30,7 +30,7 @@ DATA="{}" while [[ $# -gt 0 ]]; do case "$1" in --data) DATA="$2"; shift 2 ;; - *) echo "Unknown option: $1" >&2; exit 1 ;; + *) echo "Unknown option: $1" >&2; usage; exit 1 ;; esac done diff --git a/.claude/skills/dld-goal/scripts/block-item.sh b/.claude/skills/dld-goal/scripts/block-item.sh index 950ec00..1cc2496 100755 --- a/.claude/skills/dld-goal/scripts/block-item.sh +++ b/.claude/skills/dld-goal/scripts/block-item.sh @@ -33,7 +33,7 @@ while [[ $# -gt 0 ]]; do --reason) REASON="$2"; shift 2 ;; --question) QUESTION="$2"; shift 2 ;; --force) FORCE=true; shift ;; - *) echo "Unknown option: $1" >&2; exit 1 ;; + *) echo "Unknown option: $1" >&2; usage; exit 1 ;; esac done diff --git a/.claude/skills/dld-goal/scripts/create-run.sh b/.claude/skills/dld-goal/scripts/create-run.sh index fc831fe..9f44144 100755 --- a/.claude/skills/dld-goal/scripts/create-run.sh +++ b/.claude/skills/dld-goal/scripts/create-run.sh @@ -34,7 +34,13 @@ while [[ $# -gt 0 ]]; do --max-minutes) MAX_MINUTES="$2"; shift 2 ;; --review) REVIEW="$2"; shift 2 ;; --body-stdin) READ_STDIN=true; shift ;; - *) echo "Unknown option: $1" >&2; exit 1 ;; + *) if [[ "$1" != -* ]]; then + usage + else + echo "Unknown option: $1" >&2 + usage + fi + exit 1 ;; esac done diff --git a/.claude/skills/dld-goal/scripts/guard-preconditions.sh b/.claude/skills/dld-goal/scripts/guard-preconditions.sh index f409793..94a9331 100755 --- a/.claude/skills/dld-goal/scripts/guard-preconditions.sh +++ b/.claude/skills/dld-goal/scripts/guard-preconditions.sh @@ -41,7 +41,7 @@ while [[ $# -gt 0 ]]; do case "$1" in --decisions) DECISIONS="$2"; shift 2 ;; --base) BASE="$2"; shift 2 ;; - *) echo "Unknown option: $1" >&2; exit 1 ;; + *) echo "Unknown option: $1" >&2; usage; exit 1 ;; esac done diff --git a/.claude/skills/dld-goal/scripts/resolve-block.sh b/.claude/skills/dld-goal/scripts/resolve-block.sh index 3c81da0..cf8dae9 100755 --- a/.claude/skills/dld-goal/scripts/resolve-block.sh +++ b/.claude/skills/dld-goal/scripts/resolve-block.sh @@ -30,7 +30,7 @@ while [[ $# -gt 0 ]]; do case "$1" in --answer) ANSWER="$2"; shift 2 ;; --action) ACTION="$2"; shift 2 ;; - *) echo "Unknown option: $1" >&2; exit 1 ;; + *) echo "Unknown option: $1" >&2; usage; exit 1 ;; esac done diff --git a/.claude/skills/dld-goal/scripts/run-state.sh b/.claude/skills/dld-goal/scripts/run-state.sh index 985cfb8..9382238 100755 --- a/.claude/skills/dld-goal/scripts/run-state.sh +++ b/.claude/skills/dld-goal/scripts/run-state.sh @@ -184,7 +184,7 @@ case "$COMMAND" in --decisions) DECISIONS="$2"; shift 2 ;; --check) CHECKS="$(jq --argjson c "$(parse_check "$2")" '. + [$c]' <<<"$CHECKS")"; shift 2 ;; --annotation) ANNOTATIONS="$(jq --arg a "$2" '. + [$a]' <<<"$ANNOTATIONS")"; shift 2 ;; - *) echo "Unknown option: $1" >&2; exit 1 ;; + *) echo "Unknown option: $1" >&2; usage; exit 1 ;; esac done if [[ -z "$DECISIONS" ]]; then diff --git a/decisions/INDEX.md b/decisions/INDEX.md index 09a8317..8c43a56 100644 --- a/decisions/INDEX.md +++ b/decisions/INDEX.md @@ -2,11 +2,12 @@ | ID | Title | Status | Tags | |----|-------|--------|------| +| 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-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 | proposed | dld-goal, extension, execution | -| DL-007 | The extension reads run state directly but delegates every mutation to the skill scripts | proposed | dld-goal, architecture, state | +| DL-007 | The extension reads run state directly but delegates every mutation to the skill scripts | accepted | dld-goal, architecture, state | | DL-006 | dld-kit is a Pi package: TypeScript extension, no build step, bun test | accepted | dld-goal, packaging, tooling | | DL-005 | The skill owns DLD semantics; a Pi extension owns loop mechanics | accepted | dld-goal, architecture | | DL-004 | Runs halt on unsafe preconditions and escalate blocked items as operator questions in the run | accepted | dld-goal, safety, execution | diff --git a/decisions/records/DL-007.md b/decisions/records/DL-007.md index b9d5199..2484606 100644 --- a/decisions/records/DL-007.md +++ b/decisions/records/DL-007.md @@ -2,11 +2,14 @@ id: DL-007 title: "The extension reads run state directly but delegates every mutation to the skill scripts" timestamp: 2026-08-21T12:18:07Z -status: proposed +status: accepted supersedes: [] amends: [] tags: [dld-goal, architecture, state] -references: [] +references: + - path: extensions/dld-goal/run-state.ts + symbol: stateMutations + - path: extensions/dld-goal/run-state.test.ts --- ## Context @@ -15,6 +18,8 @@ Run state is a JSON document plus an append-only event log (DL-001), mutated tod DL-005 named the hazard when it split skill from extension: "Two implementations of the state transitions will exist — bash with jq in the skill, TypeScript in the extension. They must agree." A schema documented in prose is not a mechanism; it is an intention that decays. +The run contract promises atomicity through a temp file plus rename, which is why reads may be unsynchronized but a corrupted document is never expected — a parse failure is evidence of a bug, not a timing artefact. + ## Decision The extension never writes run state. It reads `state.json` directly, and delegates every mutation to the skill scripts by executing them: @@ -24,6 +29,10 @@ The extension never writes run state. It reads `state.json` directly, and delega No TypeScript code constructs a state document, sets an item status, or appends an event. If the extension needs a new mutation, it is added to the scripts and covered by bats, then called. +Unknown values are rejected at the boundary rather than tolerated: a state document whose `status` is not one of the five known values, or whose items have an unknown item status, fails to parse with `invalid-shape`. The scripts write only enumerated values; a document with anything else has been modified by a bug or by a human hand, and silently accepting it is how drift becomes invisible. + +Events are read directly too — a `readEventsFrom` that parses `events.jsonl` line by line. The original plan had this go through `jq` via a shell; that was scrapped because it reintroduced a string-template path and missed line diagnostics, when the point of DL-003 is to avoid exec through a string. + ## Rationale This removes the dual-implementation problem rather than managing it. There is exactly one implementation of every transition, it is already covered by the existing suite, and no amount of drift between two languages is possible because there is no second implementation. @@ -40,4 +49,6 @@ The extension cannot run where the scripts cannot — no bash, no jq, no reposit Error handling crosses a process boundary. The extension must surface script exit codes and stderr rather than throwing typed errors, and script messages become user-facing text in the harness. +The mutation envelope returns the raw script output verbatim — stderr when stdout is empty. That is not incidental: `next-item.sh` uses exit code 2 for a blocked item and writes the operator question to stderr, so a delegation layer that summarised stderr would have cut that question off before the user ever saw it. + Adding a mutation now touches two places: a script plus its bats tests, then the call site. Slower than writing TypeScript inline, and deliberately so. diff --git a/decisions/records/DL-012.md b/decisions/records/DL-012.md new file mode 100644 index 0000000..a55cda2 --- /dev/null +++ b/decisions/records/DL-012.md @@ -0,0 +1,37 @@ +--- +id: DL-012 +title: "Amend DL-007: correct the delegated script list" +timestamp: 2026-08-21T14:20:51Z +status: accepted +supersedes: [] +amends: [DL-007] +tags: [dld-goal, architecture, state] +references: [] +--- + +## Context + +DL-007 split read from write: the extension parses run state directly and delegates every mutation to the skill scripts. As originally written, the Decision named the script set as `run-state.sh, append-event.sh, block-item.sh, resolve-block.sh, verify-item.sh`. + +The implementing module in `extensions/dld-goal/run-state.ts` delegates to that list minus `verify-item.sh` and plus two more the contract also needs: `verify-hashes.sh` (drift detection) and `next-item.sh` (item selection). The omission would have left those two unaccountable — the extension would have been calling scripts not named in the accepted decision, while a script no longer used would have been recorded as still part of the contract. + +## Decision + +Amend DL-007 to replace the named script list with: + +- `run-state.sh` +- `append-event.sh` +- `block-item.sh` +- `resolve-block.sh` +- `verify-hashes.sh` +- `next-item.sh` + +`verify-item.sh` is not delegated to. The rest of DL-007 stands. + +## Rationale + +The list has to match what the module actually calls, because the point of DL-007 is that no mutation goes through anything but a named, tested script. An inaccurate list is not something the extension can be checked against. + +## Consequences + +The amendment mechanism exists for exactly this: DL-007 was accepted and then the review found a mismatch between the record and the code. Correcting the accepted record through `amends` preserves the accepted body as the audit trail instead of rewriting it under the same ID. diff --git a/extensions/dld-goal/run-state.test.ts b/extensions/dld-goal/run-state.test.ts new file mode 100644 index 0000000..37ab51d --- /dev/null +++ b/extensions/dld-goal/run-state.test.ts @@ -0,0 +1,273 @@ +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 { + parseEventsText, + parseStateText, + readEventsFrom, + readRunFrom, + stateMutations, + type RunState, +} from "./run-state.ts"; +import { createFakePi } from "./testing/fake-pi.ts"; + +let workspace: string; + +beforeEach(() => { + workspace = mkdtempSync(join(tmpdir(), "dld-run-state-")); +}); + +afterEach(() => { + rmSync(workspace, { recursive: true, force: true }); +}); + +function validState(overrides: Partial = {}): RunState { + return { + schemaVersion: 1, + slug: "payments", + title: "Payment gateway", + status: "active", + createdAt: "2026-08-20T20:15:30Z", + updatedAt: "2026-08-20T21:02:11Z", + bounds: { maxItems: 8, maxMinutes: 120 }, + review: "enabled", + currentItem: null, + items: [], + blockedQuestions: [], + ...overrides, + }; +} + +describe("read boundary", () => { + test("accepts a well-formed run", () => { + const runDir = join(workspace, ".dld", "runs", "payments"); + mkdirSync(runDir, { recursive: true }); + writeFileSync(join(runDir, "state.json"), JSON.stringify(validState())); + const result = readRunFrom(runDir); + expect(result.ok).toBe(true); + expect((result as { state: RunState }).state.slug).toBe("payments"); + }); + + test("missing state.json is a structured miss, not a throw", () => { + const result = readRunFrom(join(workspace, ".dld", "runs", "ghost")); + expect(result.ok).toBe(false); + expect((result as { error: { kind: string } }).error.kind).toBe("missing"); + }); + + test("rejects unknown run status instead of letting it flow downstream", () => { + const text = JSON.stringify({ ...validState(), status: "flying" }); + const result = parseStateText(text); + expect(result.ok).toBe(false); + expect((result as { error: { kind: string } }).error.kind).toBe("invalid-shape"); + }); + + test("rejects unknown item status even when the run status is valid", () => { + const text = JSON.stringify({ + ...validState(), + items: [ + { + index: 1, + decisions: [], + status: "swimming", + acceptance: { annotations: [], checks: [] }, + attempts: 0, + evidence: [], + }, + ], + }); + const result = parseStateText(text); + expect(result.ok).toBe(false); + }); + + test("rejects unparseable JSON with a named kind", () => { + const result = parseStateText("{ nope"); + expect(result.ok).toBe(false); + expect((result as { error: { kind: string } }).error.kind).toBe("invalid-json"); + }); + + test("rejects a state shape with a schemaVersion the extension does not understand", () => { + const modern = { ...validState(), schemaVersion: 2 }; + const result = parseStateText(JSON.stringify(modern)); + expect(result.ok).toBe(false); + expect((result as { error: { kind: string } }).error.kind).toBe("invalid-shape"); + }); +}); + +describe("events", () => { + test("parses one JSON object per line and tolerates trailing blank lines", () => { + const parsed = parseEventsText('{"kind":"run_started"}\n{"kind":"item_accepted","index":1}\n\n'); + expect(parsed.events).toEqual([{ kind: "run_started" }, { kind: "item_accepted", index: 1 }]); + expect(parsed.errors).toEqual([]); + }); + + test("bad lines are flagged with their 1-based line number, not silently dropped", () => { + const parsed = parseEventsText('{"kind":"a"}\n{bad}\n{"kind":"b"}\n'); + expect(parsed.events).toEqual([{ kind: "a" }, { kind: "b" }]); + expect(parsed.errors).toHaveLength(1); + expect(parsed.errors[0]?.line).toBe(2); + }); + + test("missing event log is an explicit result rather than an empty success", () => { + const parsed = readEventsFrom(join(workspace, ".dld", "runs", "ghost")); + expect(parsed.errors.length).toBeGreaterThan(0); + }); +}); + +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 new file mode 100644 index 0000000..905ca3b --- /dev/null +++ b/extensions/dld-goal/run-state.ts @@ -0,0 +1,243 @@ +import { readFileSync } from "node:fs"; +import { scriptPath } from "./paths.ts"; + +// @decision(DL-001) @decision(DL-007) +// The extension reads run state directly and delegates every mutation to the +// skill scripts. The reader below is a pure JSON parse; the writer goes to +// bash. There is deliberately no function in this module that constructs or +// writes a state document. + +export const RUN_STATUSES = ["active", "paused", "blocked", "complete", "stopped"] as const; +export type RunStatus = (typeof RUN_STATUSES)[number]; + +export const ITEM_STATUSES = [ + "pending", + "implementing", + "verifying", + "accepted", + "blocked", + "skipped", + "failed", +] as const; +export type ItemStatus = (typeof ITEM_STATUSES)[number]; + +export interface DecisionPin { + id: string; + hash: string; +} + +export interface Acceptance { + annotations: string[]; + checks: string[][]; +} + +export const RUN_SCHEMA_VERSION = 1; + +export interface WorkItem { + index: number; + decisions: DecisionPin[]; + status: ItemStatus; + acceptance: Acceptance; + attempts: number; + evidence: unknown[]; +} + +export interface BlockedQuestion { + itemIndex: number; + question: string; + answer?: string; +} + +export interface RunBounds { + maxItems: number; + maxMinutes: number; +} + +export interface RunState { + schemaVersion: number; + slug: string; + title: string; + status: RunStatus; + createdAt: string; + updatedAt: string; + bounds: RunBounds; + review: "enabled" | "disabled"; + currentItem: number | null; + items: WorkItem[]; + blockedQuestions: BlockedQuestion[]; +} + +export interface StateError { + kind: "missing" | "read-error" | "invalid-json" | "invalid-shape"; + detail: string; +} + +export type ReadResult = { ok: true; state: RunState } | { ok: false; error: StateError }; + +export interface EventLineError { + line: number; + detail: string; +} + +export interface EventParseResult { + events: unknown[]; + errors: EventLineError[]; +} + +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; + const run = candidate as Record; + if (run.schemaVersion !== RUN_SCHEMA_VERSION) return false; + if (!RUN_STATUSES.includes(run.status as RunStatus)) return false; + if (!Array.isArray(run.items)) return false; + for (const item of run.items as unknown[]) { + if (typeof item !== "object" || item === null) return false; + if (!ITEM_STATUSES.includes((item as Record).status as ItemStatus)) return false; + } + return true; +} + +export function parseStateText(text: string): ReadResult { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + return { + ok: false, + error: { + kind: "invalid-json", + detail: error instanceof Error ? error.message : "unparseable state", + }, + }; + } + if (!validateStateShape(parsed)) { + return { + ok: false, + error: { + kind: "invalid-shape", + detail: "state.json does not match the run contract schema", + }, + }; + } + return { ok: true, state: parsed as RunState }; +} + +export function readRunFrom(runDir: string): ReadResult { + let text: string; + try { + text = readFileSync(`${runDir}/state.json`, "utf8"); + } catch { + return { + ok: false, + error: { kind: "missing", detail: `no state.json in ${runDir}` }, + }; + } + return parseStateText(text); +} + +export function parseEventsText(text: string): EventParseResult { + const events: unknown[] = []; + const errors: EventLineError[] = []; + const lines = text.split(/\r?\n/); + for (let i = 0; i < lines.length; i += 1) { + const line = (lines[i] ?? "").trim(); + if (line === "") continue; + try { + events.push(JSON.parse(line)); + } catch (error) { + errors.push({ + line: i + 1, + detail: error instanceof Error ? error.message : "invalid event line", + }); + } + } + return { events, errors }; +} + +/** Read the append-only event log directly. There is no delegated path for + * events, so a wrong read is a bug in one place — not a corrupted record. */ +export function readEventsFrom(runDir: string): EventParseResult { + let text: string; + try { + text = readFileSync(`${runDir}/events.jsonl`, "utf8"); + } catch { + return { events: [], errors: [{ line: -1, detail: `no events.jsonl in ${runDir}` }] }; + } + 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/skills/dld-common/scripts/common.sh b/skills/dld-common/scripts/common.sh index ae74449..9e5c9ae 100755 --- a/skills/dld-common/scripts/common.sh +++ b/skills/dld-common/scripts/common.sh @@ -98,6 +98,15 @@ get_run_dir() { # Fail with a clear message when jq is unavailable. # Goal run state is JSON; the dld-goal scripts require jq to read and mutate it. +# Print the caller script's usage from its header comment, stopping at the +# first line that is not a comment. Scripts source common.sh, so the script +# whose usage we want is BASH_SOURCE[1]; BASH_SOURCE[0] is common.sh itself. +usage() { + sed -n '2,$p' "${BASH_SOURCE[1]:-${BASH_SOURCE[0]}}" \ + | sed -n '/^[^#]/q; p' \ + | sed 's/^# \{0,1\}//' >&2 +} + require_jq() { if ! command -v jq >/dev/null 2>&1; then echo "Error: jq is required by the dld-goal scripts but was not found on PATH." >&2 diff --git a/skills/dld-goal/scripts/append-event.sh b/skills/dld-goal/scripts/append-event.sh index f0cda5a..93d89d5 100755 --- a/skills/dld-goal/scripts/append-event.sh +++ b/skills/dld-goal/scripts/append-event.sh @@ -30,7 +30,7 @@ DATA="{}" while [[ $# -gt 0 ]]; do case "$1" in --data) DATA="$2"; shift 2 ;; - *) echo "Unknown option: $1" >&2; exit 1 ;; + *) echo "Unknown option: $1" >&2; usage; exit 1 ;; esac done diff --git a/skills/dld-goal/scripts/block-item.sh b/skills/dld-goal/scripts/block-item.sh index 950ec00..1cc2496 100755 --- a/skills/dld-goal/scripts/block-item.sh +++ b/skills/dld-goal/scripts/block-item.sh @@ -33,7 +33,7 @@ while [[ $# -gt 0 ]]; do --reason) REASON="$2"; shift 2 ;; --question) QUESTION="$2"; shift 2 ;; --force) FORCE=true; shift ;; - *) echo "Unknown option: $1" >&2; exit 1 ;; + *) echo "Unknown option: $1" >&2; usage; exit 1 ;; esac done diff --git a/skills/dld-goal/scripts/create-run.sh b/skills/dld-goal/scripts/create-run.sh index fc831fe..9f44144 100755 --- a/skills/dld-goal/scripts/create-run.sh +++ b/skills/dld-goal/scripts/create-run.sh @@ -34,7 +34,13 @@ while [[ $# -gt 0 ]]; do --max-minutes) MAX_MINUTES="$2"; shift 2 ;; --review) REVIEW="$2"; shift 2 ;; --body-stdin) READ_STDIN=true; shift ;; - *) echo "Unknown option: $1" >&2; exit 1 ;; + *) if [[ "$1" != -* ]]; then + usage + else + echo "Unknown option: $1" >&2 + usage + fi + exit 1 ;; esac done diff --git a/skills/dld-goal/scripts/guard-preconditions.sh b/skills/dld-goal/scripts/guard-preconditions.sh index f409793..94a9331 100755 --- a/skills/dld-goal/scripts/guard-preconditions.sh +++ b/skills/dld-goal/scripts/guard-preconditions.sh @@ -41,7 +41,7 @@ while [[ $# -gt 0 ]]; do case "$1" in --decisions) DECISIONS="$2"; shift 2 ;; --base) BASE="$2"; shift 2 ;; - *) echo "Unknown option: $1" >&2; exit 1 ;; + *) echo "Unknown option: $1" >&2; usage; exit 1 ;; esac done diff --git a/skills/dld-goal/scripts/resolve-block.sh b/skills/dld-goal/scripts/resolve-block.sh index 3c81da0..cf8dae9 100755 --- a/skills/dld-goal/scripts/resolve-block.sh +++ b/skills/dld-goal/scripts/resolve-block.sh @@ -30,7 +30,7 @@ while [[ $# -gt 0 ]]; do case "$1" in --answer) ANSWER="$2"; shift 2 ;; --action) ACTION="$2"; shift 2 ;; - *) echo "Unknown option: $1" >&2; exit 1 ;; + *) echo "Unknown option: $1" >&2; usage; exit 1 ;; esac done diff --git a/skills/dld-goal/scripts/run-state.sh b/skills/dld-goal/scripts/run-state.sh index 985cfb8..9382238 100755 --- a/skills/dld-goal/scripts/run-state.sh +++ b/skills/dld-goal/scripts/run-state.sh @@ -184,7 +184,7 @@ case "$COMMAND" in --decisions) DECISIONS="$2"; shift 2 ;; --check) CHECKS="$(jq --argjson c "$(parse_check "$2")" '. + [$c]' <<<"$CHECKS")"; shift 2 ;; --annotation) ANNOTATIONS="$(jq --arg a "$2" '. + [$a]' <<<"$ANNOTATIONS")"; shift 2 ;; - *) echo "Unknown option: $1" >&2; exit 1 ;; + *) echo "Unknown option: $1" >&2; usage; exit 1 ;; esac done if [[ -z "$DECISIONS" ]]; then