diff --git a/README.md b/README.md index 03e1fd3..7e57996 100644 --- a/README.md +++ b/README.md @@ -90,9 +90,11 @@ Latency target came from the SLO review; keep the existing client API. --no-session --no-extensions`, tools `read,bash,grep,find,ls`, edit/write excluded) with the whole plan file and the claimed goal. The judge finds the goal, re-derives from the cited artifacts rather than trusting claims, runs `verify` if the goal names one, and returns `VERDICT: accept | reject` -plus what's missing. +plus what's missing. It inspects the live working tree, not HEAD: uncommitted work counts, and +committing before sign-off is for durable evidence, not for the judge's visibility. -- accept: a sign-off line is appended to `## Log` and the agent ticks the goal `[x]` itself. The +- accept: a sign-off line is appended to `## Log` and the goal is ticked `[x]` in the same write + (exact goal-line match only; on wording drift the result asks the agent to tick it). The tool-written log line is the audit trail; a hand-tick without one shows in the diff. - reject: the goal stays open and the agent gets the missing list. - judge ran but failed/errored/timed out, or returned no VERDICT line: accepted inconclusive, diff --git a/src/index.ts b/src/index.ts index d35f61f..a7425ef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,8 +15,9 @@ * * The judge subsumes what v1 did in code: goal matching (no findGoal), evidence validation (a * placeholder gets rejected in words), verify execution, and format reading. The extension's only - * write is appending a sign-off line to ## Log — the audit trail. The agent ticks [x] itself; a - * hand-tick without a matching tool-written log line is visible in the diff. + * writes are the sign-off: append a log line to ## Log (the audit trail) and tick the goal [x] when + * an exact goal line matches (on drift the agent ticks, and the result says so). A hand-tick + * without a matching tool-written log line is visible in the diff either way. * * Judge ran but failed/errored/timed out, or returned no VERDICT line => accepted_inconclusive: the * working agent is never blocked on judge infra; the log line says the judge ran but failed. There @@ -233,8 +234,21 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { (task) => runJudge(task, judgeModel, ctx.cwd, signal), ); if (outcome.logEntry) { - writePlan(ctx, appendLog(readPlan(ctx), `${stamp()} ${outcome.logEntry}`)); + // Sign-off write: tick the goal [x] (exact-subject match; dogfood showed agent bookkeeping + // is the drift point) and append the audit log line, one write. On wording drift the tick + // falls to the agent and the result says so -- both paths are explicit, never silent. + let updated = readPlan(ctx); + let tickNote = ""; + if (outcome.logEntry.startsWith("signed off")) { + const ticked = tickGoal(updated, params.goal); + updated = ticked ?? updated; + tickNote = ticked + ? `\n\nGoal ticked [x] in ${PLAN_REL}.` + : `\n\nNo exact goal line matched your wording -- tick it [x] in ${PLAN_REL} yourself.`; + } + writePlan(ctx, appendLog(updated, `${stamp()} ${outcome.logEntry}`)); updateWidget(ctx); + return result(outcome.resultText + tickNote, outcome.isError); } return result(outcome.resultText, outcome.isError); }, @@ -300,7 +314,7 @@ export async function decideSignOff( if (judge.error) { const partial = judge.output ? `\n\npartial judge output:\n${judge.output}` : ""; return { - resultText: `Judge ran but failed (${judge.error}). Accepted inconclusive — logged. Tick the goal [x] in ${PLAN_REL} yourself.${partial}`, + resultText: `Judge ran but failed (${judge.error}). Accepted inconclusive — logged.${partial}`, isError: false, logEntry: `signed off "${input.goal}" (judge inconclusive: ran but failed: ${oneLine(judge.error)})`, }; @@ -312,7 +326,7 @@ export async function decideSignOff( if (verdict === "accept") { return { - resultText: `Sign-off ACCEPTED. Tick the goal [x] in ${PLAN_REL} (the log line is already appended).\n\n--- judge ---\n${reasoning}`, + resultText: `Sign-off ACCEPTED (log line appended).\n\n--- judge ---\n${reasoning}`, isError: false, logEntry: `signed off "${input.goal}" (judge accept)`, }; @@ -327,13 +341,25 @@ export async function decideSignOff( } // No VERDICT line: same fail-forward as a judge error -- the judge ran but didn't answer. return { - resultText: `Judge returned no VERDICT line. Accepted inconclusive — logged. Tick the goal [x] in ${PLAN_REL} yourself.\n\n--- judge ---\n${reasoning || "(no output)"}`, + resultText: `Judge returned no VERDICT line. Accepted inconclusive — logged.\n\n--- judge ---\n${reasoning || "(no output)"}`, isError: false, logEntry: `signed off "${input.goal}" (judge inconclusive: no VERDICT line)`, }; } -/** Append one line under ## Log (creating the section at EOF if absent). The extension's only write. */ +/** Tick the goal line whose subject exactly matches `goal` (trimmed, case-insensitive) to [x]. + * Null when there is no unique exact match (wording drift / duplicates) -- the caller then asks the + * agent to tick it itself. Reuses GOAL_LINE; deliberately NOT fuzzy, that's the judge's job. */ +export function tickGoal(plan: string, goal: string): string | null { + const lines = plan.split("\n"); + const want = goal.trim().toLowerCase(); + const hits = lines.flatMap((l, i) => (GOAL_LINE.exec(l)?.[2].trim().toLowerCase() === want ? [i] : [])); + if (hits.length !== 1) return null; + lines[hits[0]] = lines[hits[0]].replace(/\[[ xX/-]\]/, "[x]"); + return lines.join("\n"); +} + +/** Append one line under ## Log (creating the section at EOF if absent). */ export function appendLog(text: string, entry: string): string { const lines = text.split("\n"); const line = `- ${entry}`; diff --git a/src/prompts.ts b/src/prompts.ts index bf93a1c..6906ccc 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -96,9 +96,11 @@ export const completeGoalDescription = "table plus how to read it, a metric plus what it shows -- not a bare claim). The read must show " + "success POSITIVELY happened, not just that failures were avoided. Then call this with the goal's " + "text (the line after 'goal:'; small wording drift is fine). A fresh read-only judge inspects the " + - "repo, runs the goal's verify command if it names one, and returns accept or reject with what's " + - "missing. On accept (or if the judge itself is unavailable), a sign-off line is appended to ## Log " + - "and YOU tick the goal [x]. On reject the goal stays open."; + "LIVE WORKING TREE (uncommitted changes included; committing first is for durability, not " + + "visibility), runs the goal's verify command if it names one, and returns accept or reject with " + + "what's missing. On accept (or if the judge itself failed), a sign-off line is appended to ## Log " + + "and the goal is ticked [x] for you; the result says if you must tick it yourself. On reject the " + + "goal stays open."; export const completeGoalParamDescription = "The goal's text: the line after 'goal:' in the plan file."; diff --git a/test/tick-goal.test.ts b/test/tick-goal.test.ts new file mode 100644 index 0000000..f5655c8 --- /dev/null +++ b/test/tick-goal.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { tickGoal } from "../src/index.js"; + +const plan = `# Plan + +## Goals + +1. [/] goal: Implement the cache layer + - tasks: + 1. [x] wire client +2. [ ] goal: Ship the docs + +## Log +`; + +describe("tickGoal (sign-off ticks the goal; agent only ticks on wording drift)", () => { + it("ticks the exact-matching goal line, case-insensitive, leaving subtasks alone", () => { + const out = tickGoal(plan, "implement the CACHE layer"); + expect(out).toContain("1. [x] goal: Implement the cache layer"); + expect(out).toContain("1. [x] wire client"); // subtask untouched (was already x) + expect(out).toContain("2. [ ] goal: Ship the docs"); // other goal untouched + }); + + it("returns null on wording drift (fuzzy matching is the judge's job, not TypeScript's)", () => { + expect(tickGoal(plan, "Implement caching")).toBeNull(); + }); + + it("returns null when the subject matches more than one goal line", () => { + const dup = `${plan}3. [ ] goal: Ship the docs\n`; + expect(tickGoal(dup, "Ship the docs")).toBeNull(); + }); +});