From 9b7c461a53a10d99cc0b8cc16939db0bb44abf9d Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:57:48 -0700 Subject: [PATCH] feat(s07): wire plan grounding into the plan park as advisory, default off The plan-grounding check (PR #53) now has its caller: when a run parks on plan approval and SHIP_PLAN_GROUNDING=on, the report is computed inside the existing plan-think step and rides additively on its recorded result -- no new durable step, so the step sequence stays a function of the run input and a replay reads the report back without re-running git. Flag off records the exact pre-wiring {text, usage} bytes. The run page's plan-review card renders the advisory block (ungrounded first) with the caveat that grounded means the name exists, not that the plan is right. A workspace with no git repository omits the field; grounding never blocks and never edits the plan. --- AUDIT_OPEN.md | 2 +- docs/SHIP_RELEASE_PROGRAMME_2026-09-21.md | 2 +- src/durable.ts | 20 +- src/plan-grounding-wiring.test.ts | 297 ++++++++++++++++++++++ web/src/lib/plan-grounding-view.test.ts | 57 +++++ web/src/lib/plan-grounding-view.ts | 76 ++++++ web/src/lib/run-data.server.ts | 11 + web/src/routes/runs/[id].tsx | 27 ++ 8 files changed, 489 insertions(+), 3 deletions(-) create mode 100644 src/plan-grounding-wiring.test.ts create mode 100644 web/src/lib/plan-grounding-view.test.ts create mode 100644 web/src/lib/plan-grounding-view.ts diff --git a/AUDIT_OPEN.md b/AUDIT_OPEN.md index f4e83fe..81d9f7c 100644 --- a/AUDIT_OPEN.md +++ b/AUDIT_OPEN.md @@ -46,7 +46,7 @@ All off by default or observe-only; flags are in the programme's wave-4 table. client was planned against. - **S19 (PR #49):** doctor's store and clock checks report unknown until real probes exist; the restore comparison has no snapshot producer. -- **S07 (PR #53):** unwired; wiring must not add a durable step to existing runs. +- **S07 (PR #53):** wired at the plan park 2026-10-04 behind `SHIP_PLAN_GROUNDING` (default off): the grounding report is an additive `grounding` field on the `plan-think` step result — no durable step added, flag-off bytes unchanged — and the run page's plan-review card renders it as advisory text with the name-existence caveat. Still open: the rest of S07 (planning-time use beyond the park, other consumers). - **Agent hygiene:** parallel agents share one scratch directory and the fixed grading port 8901. A backup-file collision corrupted one agent's mutation backup (caught and fixed before push) and spurious grader-test failures diff --git a/docs/SHIP_RELEASE_PROGRAMME_2026-09-21.md b/docs/SHIP_RELEASE_PROGRAMME_2026-09-21.md index 837d271..995236c 100644 --- a/docs/SHIP_RELEASE_PROGRAMME_2026-09-21.md +++ b/docs/SHIP_RELEASE_PROGRAMME_2026-09-21.md @@ -550,7 +550,7 @@ Same rules as wave 3: every change is off by default or observe-only, with tests | S13 | #50 | `SHIP_HARNESS_RECORD=on` (off) | Probes for plan-review, steer and investigate across native, claude-code and opencode (fake binaries); harness identity recorded as a sibling key on run-started (step fingerprint unchanged); "Ran on" block on the run page; credential-lifecycle tests | 15 root and 4 web tests, negative controls | Real vendor binaries; five operations unprobed; probes not run at startup or in CI | | S21 | #51 | `SHIP_KNOWLEDGE_PROVENANCE=off|shadow|on` (off) | Sidecar provenance store (new table, no migration); notes screened and labelled fresh, stale or unknown in `on`; redaction cascades on delete; summaries recorded as agent claims | 25 tests, 6 negative controls, durable step sequence identical across modes | No live Nucleus; freshness coarse; replay-exact derivation not available; cli.ts and agent.ts paths unwired; project scope is repo-only | | S24 | #52 | `SHIP_TOOL_MANIFEST=shadow|on`, `SHIP_EVENT_ENVELOPE=on` (both off) | `tool validate` dry-run CLI; manifest conformance findings recorded per tool call (never block); signed event envelope as extra webhook headers; client verification example | 14 tests, 4 negative controls, default-off header and body byte identity | The loop sees only the action kind, so host and secret findings cannot fire until the executor reports them; no tool registry or web view | -| S07 | #53 | `SHIP_PLAN_GROUNDING=on` (off; no caller yet) | `src/plan-grounding.ts`: whether files, symbols, scripts and make targets a plan names exist in the committed tree at a stated revision | 15 tests on real git repos, 7 mutation controls | Not wired; "grounded" means the name exists, not that the plan is right; most of S07 not started | +| S07 | #53 | `SHIP_PLAN_GROUNDING=on` (off) | `src/plan-grounding.ts`: whether files, symbols, scripts and make targets a plan names exist in the committed tree at a stated revision. Wired at the plan park (2026-10-04): the report rides additively on the `plan-think` step result (no new durable step) and renders as an advisory block on the run page's plan review | 15 tests on real git repos, 7 mutation controls; wiring: default-off byte identity, real-path park test on a real git workspace, negative control, no-repo omission | "Grounded" means the name exists, not that the plan is right; only the park point is wired (not denial paths or other consumers); most of S07 not started | | S27 | #54 | `SHIP_DEPLOY_ADAPTER=teploy` (off) | `TeployAdapter` and an optional adapter path in `delivery.ts` and `worker.ts`; capabilities declare what it cannot do | 28 tests against a fake CLI as a real process, conformance 22 pass, 5 skipped for declared gaps, 10 mutations | No real teploy run; a manual deploy landing inside Ship's deploy is overwritten and still reads back confirmed (pinned by a test); stricter than legacy (unreadable status holds); S27 acceptance not met | Upstream feature requests for the Teploy CLI, **not yet filed**: a deploy dry-run; a deploy generation in `status --json` with a compare-and-set or lock that manual deploys honour; documented `logs` flags and a status that separates "no deployment yet" from "unreachable". diff --git a/src/durable.ts b/src/durable.ts index 7421e57..cf1e241 100644 --- a/src/durable.ts +++ b/src/durable.ts @@ -86,6 +86,8 @@ import type { NetworkTier } from "./egress.js"; import type { CodeSearch } from "./code-index.js"; import { CHANGE_EVENT, MERGE_EVENT, PLAN_EVENT } from "./plan.js"; import type { ChangeDecisionPayload, MergeDecisionPayload } from "./plan.js"; +import { groundPlanFromExecutor, planGroundingEnabled } from "./plan-grounding.js"; +import type { GroundingReport } from "./plan-grounding.js"; import { classifyChange, mergeParkSummary, midRunParkReasons, parseNumstat } from "./change-class.js"; import type { ChangedFile, ChangeVerdict } from "./change-class.js"; import type { PlanDecisionPayload } from "./plan.js"; @@ -1838,7 +1840,23 @@ export function nativeAdapter(config: DurableAgentConfig): HarnessAdapter { messages.push({ role: "user", content: PLAN_REQUEST }); const planStep = await ws.ctx.step(`${p}plan-think`, async () => { const generated = await generateText({ model: config.model, messages }); - return { text: generated.text, usage: generated.usage }; + // S07 plan grounding (advisory, SHIP_PLAN_GROUNDING, default off): + // computed INSIDE this step and attached as an additive `grounding` + // field on its result — never a new step, so the recorded sequence is + // a function of the input whatever the executing worker's env says, + // and a replay reads the report back from the log without re-running + // git. A workspace that is not a git repository (a bare run) has no + // committed tree to ground against: the field is omitted, and the + // park proceeds exactly as without the flag. + let grounding: GroundingReport | undefined; + if (planGroundingEnabled()) { + try { + grounding = await groundPlanFromExecutor(ws.executor, generated.text); + } catch { + grounding = undefined; + } + } + return { text: generated.text, usage: generated.usage, ...(grounding !== undefined ? { grounding } : {}) }; }); addUsage(planStep.usage); diff --git a/src/plan-grounding-wiring.test.ts b/src/plan-grounding-wiring.test.ts new file mode 100644 index 0000000..52c5663 --- /dev/null +++ b/src/plan-grounding-wiring.test.ts @@ -0,0 +1,297 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { test } from "node:test"; + +import type { AdapterGenerateResult, ModelAdapter } from "@neutron-build/ai"; +import { LocalExecutor } from "@neutron-build/agents"; +import type { AgentExecutor } from "@neutron-build/agents"; +import { MemoryEventStore, deliverEvent, executeRun } from "@neutron-build/workflow"; + +import { PLAN_EVENT, durableAgent } from "./durable.js"; +import type { ExecutorProvider } from "./durable.js"; + +/** + * S07 wiring, advisory. Every case here drives the REAL plan-park path — + * durableAgent, a scripted model, `plan: true`, the park on PLAN_EVENT — with + * the workspace backed by a real git repository read through a real + * LocalExecutor, the same way a run's checkout is read. There is no mock of + * git and no shortcut around the park emission. + */ + +const GIT = ["-c", "user.email=t@example.com", "-c", "user.name=t", "-c", "commit.gpgsign=false"]; +function git(cwd: string, ...args: string[]): string { + return execFileSync("git", [...GIT, ...args], { cwd, encoding: "utf8" }).trim(); +} + +const TREE = { + "package.json": JSON.stringify({ name: "x", scripts: { build: "tsc", test: "node --test" } }), + "src/billing/invoice.ts": "export function computeProration(a: number) { return a; }\n", + "src/index.ts": "export const x = 1;\n", +}; + +/** A real committed tree; the workspace executor runs inside it. */ +async function repoWorkspace(tree: Record): Promise<{ + root: string; + sha: string; + provider: ExecutorProvider; + execCount: () => number; + done: () => Promise; +}> { + const root = await mkdtemp(join(tmpdir(), "plan-grounding-park-")); + git(root, "init", "-q", "-b", "main"); + for (const [p, text] of Object.entries(tree)) { + await mkdir(dirname(join(root, p)), { recursive: true }); + await writeFile(join(root, p), text); + } + git(root, "add", "-A"); + git(root, "commit", "-q", "-m", "init"); + const sha = git(root, "rev-parse", "HEAD"); + let execs = 0; + const attach = (): AgentExecutor => { + const inner = new LocalExecutor({ root }); + return { + async exec(cmd, opts) { + execs++; + return inner.exec(cmd, opts); + }, + putFile: (p, d) => inner.putFile(p, d), + getFile: (p) => inner.getFile(p), + destroy: () => inner.destroy(), + }; + }; + const shared = attach(); + return { + root, + sha, + execCount: () => execs, + provider: { + async create() { + return { handle: root }; + }, + attach() { + return shared; + }, + }, + done: () => rm(root, { recursive: true, force: true }), + }; +} + +// A model that reacts to the last observation (durable.test.ts's shape), so +// the run can be driven past the park and through the finish gate. +function reactiveModel(turns: Array string)>): { model: ModelAdapter; callCount: () => number } { + let index = 0; + let calls = 0; + return { + callCount: () => calls, + model: { + provider: "scripted", + modelId: "s1", + async doGenerate(options): Promise { + calls++; + const lastUser = [...options.messages].reverse().find((m) => m.role === "user"); + const obs = typeof lastUser?.content === "string" ? lastUser.content : ""; + const turn = turns[index++] ?? "```finish\nout of script\n```"; + const text = typeof turn === "function" ? turn(obs) : turn; + return { content: [{ type: "text", text }], finishReason: "stop", usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, raw: null }; + }, + async *doStream() { + throw new Error("unused"); + }, + }, + }; +} + +const FLAG = "SHIP_PLAN_GROUNDING"; +async function withFlag(value: string | undefined, fn: () => Promise): Promise { + const before = process.env[FLAG]; + if (value === undefined) delete process.env[FLAG]; + else process.env[FLAG] = value; + try { + return await fn(); + } finally { + if (before === undefined) delete process.env[FLAG]; + else process.env[FLAG] = before; + } +} + +type Logged = { type: string; name?: string; data?: unknown }; + +async function planStepOf(store: MemoryEventStore, runId: string): Promise<{ result: Record; names: string[] }> { + const events: Logged[] = await store.load(runId); + const step = events.find((e) => e.type === "step-completed" && e.name === "plan-think"); + assert.ok(step, "no plan-think step"); + return { + result: (step.data as { result: Record }).result, + names: events.filter((e) => e.type === "step-completed").map((e) => e.name ?? ""), + }; +} + +const WORK_TURNS = [ + "```bash\necho ok > done.txt\n```", + (obs: string) => (obs.includes("exit 0") ? "```finish\nplanned and done\n```" : "```bash\necho hmm\n```"), + (obs: string) => (obs.includes("Before finishing") ? "```bash\nls\n```" : "```bash\necho hmm\n```"), + (obs: string) => (obs.includes("exit 0") ? "```finish\nplanned and done\n```" : "```bash\necho hmm\n```"), +]; + +const PLAN = [ + "1. Edit `src/billing/invoice.ts` so `computeProration()` rounds.", + "2. Then edit `src/billing/ledger.ts` and run `pnpm run migrate` after `pnpm run build`.", +].join("\n"); + +const statusOf = (grounding: unknown, name: string): string | undefined => { + const refs = (grounding as { refs?: { name: string; status: string }[] } | undefined)?.refs; + return refs?.find((r) => r.name === name)?.status; +}; + +test("default off: the park's recorded plan step is byte-identical with the flag unset and explicitly off", async () => { + const run = async (flag: string | undefined) => { + const w = await repoWorkspace(TREE); + try { + return await withFlag(flag, async () => { + const { model } = reactiveModel([PLAN, ...WORK_TURNS]); + const store = new MemoryEventStore(); + const parked = await executeRun({ workflow: durableAgent({ model, executor: w.provider }), runId: "park-off", store, input: { task: "t", plan: true } }); + assert.equal(parked.status, "waiting"); + assert.equal(parked.eventName, PLAN_EVENT); + return planStepOf(store, "park-off"); + }); + } finally { + await w.done(); + } + }; + const unset = await run(undefined); + const off = await run("off"); + assert.equal("grounding" in unset.result, false); + assert.equal(JSON.stringify(unset.result), JSON.stringify(off.result), "unset and explicit off record the same bytes"); + assert.equal(JSON.stringify(unset.result), JSON.stringify({ text: PLAN, usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } }), "the pre-wiring {text, usage} shape, unchanged"); +}); + +test("flag on: the real park path grounds the plan against the workspace's committed tree, adds no step, and never re-runs on replay", async () => { + const w = await repoWorkspace(TREE); + try { + await withFlag("on", async () => { + const { model, callCount } = reactiveModel([PLAN, ...WORK_TURNS]); + const store = new MemoryEventStore(); + const parked = await executeRun({ workflow: durableAgent({ model, executor: w.provider }), runId: "park-on", store, input: { task: "t", plan: true } }); + assert.equal(parked.status, "waiting", "a grounding report never keeps the run from parking"); + assert.equal(parked.eventName, PLAN_EVENT); + const { result, names } = await planStepOf(store, "park-on"); + const g = result.grounding as { revision: string; counts: Record; allCheckedGrounded: boolean }; + assert.ok(g, "the park's plan step carries a grounding report"); + assert.equal(g.revision, w.sha); + assert.equal(statusOf(g, "src/billing/invoice.ts"), "grounded"); + assert.equal(statusOf(g, "computeProration"), "grounded"); + assert.equal(statusOf(g, "src/billing/ledger.ts"), "ungrounded"); + assert.equal(statusOf(g, "pnpm run migrate"), "ungrounded"); + assert.equal(statusOf(g, "pnpm run build"), "grounded"); + assert.equal(g.counts.ungrounded, 2); + assert.equal(g.allCheckedGrounded, false); + assert.equal(result.text, PLAN, "the plan text itself is untouched"); + + await deliverEvent(store, "park-on", PLAN_EVENT, { approved: true }); + const done = await executeRun({ workflow: durableAgent({ model, executor: w.provider }), runId: "park-on", store }); + assert.equal(done.status, "completed"); + assert.equal((done.output as { agentSummary: string }).agentSummary, "planned and done"); + + // Same script with the flag off: identical step sequence (no new step). + const offW = await repoWorkspace(TREE); + try { + const offNames = await withFlag(undefined, async () => { + const { model: m } = reactiveModel([PLAN, ...WORK_TURNS]); + const s = new MemoryEventStore(); + const p = await executeRun({ workflow: durableAgent({ model: m, executor: offW.provider }), runId: "park-seq", store: s, input: { task: "t", plan: true } }); + assert.equal(p.status, "waiting"); + await deliverEvent(s, "park-seq", PLAN_EVENT, { approved: true }); + await executeRun({ workflow: durableAgent({ model: m, executor: offW.provider }), runId: "park-seq", store: s }); + return (await s.load("park-seq")).filter((e: Logged) => e.type === "step-completed").map((e: Logged) => e.name ?? ""); + }); + const onNames = (await store.load("park-on")).filter((e: Logged) => e.type === "step-completed").map((e: Logged) => e.name ?? ""); + assert.deepEqual(onNames, offNames, "the flag adds no step and removes none"); + } finally { + await offW.done(); + } + + // Replay of the finished run: the report is read back, git never re-runs. + const execsBefore = w.execCount(); + const callsBefore = callCount(); + const again = await executeRun({ workflow: durableAgent({ model, executor: w.provider }), runId: "park-on", store }); + assert.equal(again.status, "completed"); + assert.equal(w.execCount(), execsBefore, "replay must not re-run the grounding git queries"); + assert.equal(callCount(), callsBefore); + }); + } finally { + await w.done(); + } +}); + +test("negative control: a fully ungrounded plan still parks, lists what is missing, and approves unchanged", async () => { + const w = await repoWorkspace(TREE); + try { + await withFlag("on", async () => { + const ghost = "1. Edit `src/ghost_dir/ghost.ts` and call `ghostFunction()` and `ghostHelper.now`, then run `pnpm run exorcise`."; + const { model } = reactiveModel([ghost, ...WORK_TURNS]); + const store = new MemoryEventStore(); + const parked = await executeRun({ workflow: durableAgent({ model, executor: w.provider }), runId: "park-ghost", store, input: { task: "t", plan: true } }); + assert.equal(parked.status, "waiting", "ungrounded never blocks the park"); + assert.equal(parked.eventName, PLAN_EVENT); + const { result } = await planStepOf(store, "park-ghost"); + const g = result.grounding as { counts: Record; refs: { name: string; status: string; detail: string }[] }; + assert.equal(result.text, ghost, "grounding never mutates the plan under review"); + assert.equal(statusOf(g, "src/ghost_dir/ghost.ts"), "ungrounded"); + assert.equal(statusOf(g, "ghostFunction"), "ungrounded"); + assert.equal(statusOf(g, "ghostHelper.now"), "ungrounded"); + assert.equal(statusOf(g, "pnpm run exorcise"), "ungrounded"); + assert.equal(g.counts.grounded, 0); + assert.equal(g.counts.ungrounded, 4); + assert.ok(g.refs.every((r) => r.status !== "grounded")); + assert.ok(g.refs.find((r) => r.name === "src/ghost_dir/ghost.ts")?.detail.includes("no such path")); + + await deliverEvent(store, "park-ghost", PLAN_EVENT, { approved: true }); + const done = await executeRun({ workflow: durableAgent({ model, executor: w.provider }), runId: "park-ghost", store }); + assert.equal(done.status, "completed", "the operator's approve is the authority, not the advisory"); + }); + } finally { + await w.done(); + } +}); + +test("flag on but the workspace is not a git repository: the field is omitted, the park is unchanged", async () => { + const root = await mkdtemp(join(tmpdir(), "plan-grounding-bare-")); + try { + await writeFile(join(root, "loose.txt"), "not a repo\n"); + let execs = 0; + const inner = new LocalExecutor({ root }); + const executor: AgentExecutor = { + async exec(cmd, opts) { + execs++; + return inner.exec(cmd, opts); + }, + putFile: (p, d) => inner.putFile(p, d), + getFile: (p) => inner.getFile(p), + destroy: () => inner.destroy(), + }; + const provider: ExecutorProvider = { + async create() { + return { handle: root }; + }, + attach() { + return executor; + }, + }; + await withFlag("on", async () => { + const { model } = reactiveModel([PLAN, ...WORK_TURNS]); + const store = new MemoryEventStore(); + const parked = await executeRun({ workflow: durableAgent({ model, executor: provider }), runId: "park-bare", store, input: { task: "t", plan: true } }); + assert.equal(parked.status, "waiting"); + assert.equal(parked.eventName, PLAN_EVENT); + const { result } = await planStepOf(store, "park-bare"); + assert.equal("grounding" in result, false, "no committed tree, no report — not an empty all-ungrounded one"); + assert.equal(result.text, PLAN); + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/web/src/lib/plan-grounding-view.test.ts b/web/src/lib/plan-grounding-view.test.ts new file mode 100644 index 0000000..bc1d52c --- /dev/null +++ b/web/src/lib/plan-grounding-view.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { PLAN_GROUNDING_CAVEAT, planGroundingView } from "./plan-grounding-view.js"; + +const step = (result: unknown) => [{ type: "step-completed", name: "plan-think", data: { result } }]; + +const report = { + mode: "advisory", + revision: "abcdef1234567890", + refs: [ + { kind: "file", name: "src/nope.ts", line: 1, creates: false, status: "ungrounded", detail: "no such path" }, + { kind: "file", name: "src/ok.ts", line: 2, creates: false, status: "grounded", detail: "present" }, + { kind: "symbol", name: "fineFn", line: 3, creates: false, status: "unchecked", detail: "not searched" }, + ], + counts: { grounded: 1, ungrounded: 1, proposed: 0, unchecked: 1 }, + allCheckedGrounded: false, +}; + +test("a run recorded without grounding projects to undefined (page unchanged)", () => { + assert.equal(planGroundingView(step({ text: "the plan", usage: {} })), undefined); + assert.equal(planGroundingView([]), undefined); +}); + +test("a recorded report projects ungrounded-first with counts, revision and the caveat", () => { + const v = planGroundingView(step({ text: "p", grounding: report })); + assert.ok(v); + assert.match(v.headline, /1 grounded, 1 ungrounded, 0 proposed, 1 unchecked at revision abcdef123456/); + assert.deepEqual(v.refs.map((r) => `${r.status}:${r.name}`), [ + "ungrounded:src/nope.ts", + "unchecked:fineFn", + "grounded:src/ok.ts", + ]); + assert.equal(v.mismatch, undefined); + assert.equal(v.caveat, PLAN_GROUNDING_CAVEAT); + assert.match(v.caveat, /not that the plan is right/); +}); + +test("a revision mismatch is shown as such, never folded into the counts", () => { + const v = planGroundingView(step({ grounding: { ...report, revisionMismatch: { expected: "deadbeef", actual: "abcdef1234567890" } } })); + assert.ok(v); + assert.match(v.mismatch ?? "", /deadbeef/); + assert.match(v.mismatch ?? "", /no reference was checked/); +}); + +test("malformed grounding is ignored rather than thrown on, and the listing is bounded", () => { + assert.equal(planGroundingView(step({ grounding: "nope" })), undefined); + assert.equal(planGroundingView(step({ grounding: { refs: [] } })), undefined); + assert.equal(planGroundingView(step({ grounding: { revision: "", refs: [] } })), undefined); + const many = { ...report, refs: Array.from({ length: 500 }, (_, i) => ({ kind: "file", name: `f${i}.ts`, status: "grounded", detail: "d" })) }; + const v = planGroundingView(step({ grounding: many })); + assert.ok(v); + assert.equal(v.refs.length, 60); + assert.equal(v.omitted, 440); + const junk = planGroundingView(step({ grounding: { ...report, refs: [...report.refs, { status: "banana", name: "x" }, { status: "grounded" }] } })); + assert.ok(junk); + assert.equal(junk.refs.length, 3); +}); diff --git a/web/src/lib/plan-grounding-view.ts b/web/src/lib/plan-grounding-view.ts new file mode 100644 index 0000000..714b309 --- /dev/null +++ b/web/src/lib/plan-grounding-view.ts @@ -0,0 +1,76 @@ +/** + * The run page's plan-grounding block (S07, advisory). A read-time + * projection of the `grounding` field the `plan-think` step recorded when the + * run was made with SHIP_PLAN_GROUNDING=on. No step is added and nothing is + * recomputed: a run made without the flag has no field and projects to + * undefined, so its page is unchanged. + * + * The caveat is the point, not decoration: "grounded" means the name exists + * in the committed tree at the stated revision — it does not mean the plan is + * right, and a fully grounded plan can still be wrong. Advisory for the + * reviewer, never a gate. + */ +export interface PlanGroundingLogEvent { + type: string; + name?: string; + data?: unknown; +} + +export interface PlanGroundingRefView { + status: string; + kind: string; + name: string; + detail: string; +} + +export interface PlanGroundingView { + /** Counts plus revision, one line. */ + headline: string; + /** Set when the plan's expected revision did not match the tree; then nothing was checked. */ + mismatch?: string; + /** Ungrounded first, grounded last; bounded. */ + refs: PlanGroundingRefView[]; + /** References past the listing cap (still counted in the headline). */ + omitted?: number; + /** The honesty line from plan-grounding.ts, mirrored for the reviewer. */ + caveat: string; +} + +export const PLAN_GROUNDING_CAVEAT = + '"Grounded" only means the name exists in the committed tree at that revision — not that the plan is right. A plan can be fully grounded and wrong. This advisory never blocks the run and never edits the plan.'; + +const ORDER = ["ungrounded", "unchecked", "proposed", "grounded"]; +const MAX_REFS = 60; +const clip = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n - 1)}…` : s); + +const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0); + +export function planGroundingView(events: readonly PlanGroundingLogEvent[]): PlanGroundingView | undefined { + const step = events.find((e) => e.type === "step-completed" && e.name === "plan-think"); + const grounding = (step?.data as { result?: { grounding?: unknown } } | undefined)?.result?.grounding; + if (typeof grounding !== "object" || grounding === null) return undefined; + const g = grounding as Record; + if (typeof g.revision !== "string" || g.revision === "" || !Array.isArray(g.refs)) return undefined; + const counts = (typeof g.counts === "object" && g.counts !== null ? g.counts : {}) as Record; + const c = { grounded: num(counts.grounded), ungrounded: num(counts.ungrounded), proposed: num(counts.proposed), unchecked: num(counts.unchecked) }; + const refs = g.refs + .filter((r): r is Record => typeof r === "object" && r !== null) + .filter((r) => ["grounded", "ungrounded", "proposed", "unchecked"].includes(String(r.status)) && typeof r.name === "string" && (r.name as string) !== "") + .map((r) => ({ + status: String(r.status), + kind: typeof r.kind === "string" ? clip(r.kind, 20) : "", + name: clip(String(r.name), 200), + detail: typeof r.detail === "string" ? clip(r.detail, 300) : "", + })); + const ordered = ORDER.flatMap((s) => refs.filter((r) => r.status === s)); + const mm = g.revisionMismatch as { expected?: unknown; actual?: unknown } | undefined; + return { + headline: `${c.grounded} grounded, ${c.ungrounded} ungrounded, ${c.proposed} proposed, ${c.unchecked} unchecked at revision ${g.revision.slice(0, 12)} — advisory, name-existence only`, + ...(typeof mm === "object" && mm !== null && typeof mm.expected === "string" + ? { mismatch: `Revision mismatch: the plan is about ${clip(mm.expected, 64)}, the tree is at ${g.revision.slice(0, 12)}; no reference was checked.` } + : {}), + refs: ordered.slice(0, MAX_REFS), + ...(ordered.length > MAX_REFS ? { omitted: ordered.length - MAX_REFS } : {}), + caveat: PLAN_GROUNDING_CAVEAT, + }; +} diff --git a/web/src/lib/run-data.server.ts b/web/src/lib/run-data.server.ts index 1e1951d..a967bbc 100644 --- a/web/src/lib/run-data.server.ts +++ b/web/src/lib/run-data.server.ts @@ -34,6 +34,8 @@ import { taskRootRunId as resolveTaskRoot } from "../../../dist/task-session.js" import { taskRecord } from "teploy-ship/task-record"; import type { DeliveryOutcome } from "teploy-ship/task-record"; import { findingContinuityLines } from "./finding-continuity-view.js"; +import { planGroundingView } from "./plan-grounding-view.js"; +import type { PlanGroundingView } from "./plan-grounding-view.js"; import { harnessIdentityView } from "./harness-identity-view.js"; import type { HarnessIdentityView } from "./harness-identity-view.js"; import { taskStateView } from "./task-state.js"; @@ -79,6 +81,13 @@ export interface RunData { eventCount: number; /** The agent's proposed plan, when this run is parked on plan approval. */ plan?: string; + /** + * Advisory plan grounding (S07): whether the files, symbols and commands + * the plan names exist in the committed tree, read off the `plan-think` + * step's additive `grounding` field. Absent unless the run was made with + * SHIP_PLAN_GROUNDING=on; never an input to the decision. + */ + planGrounding?: PlanGroundingView; /** * What a scan run found (L2 / D3), read off its `scan-findings` step. * @@ -215,6 +224,7 @@ export async function runData({ params, request }: { params: { id: string }; req const startedInput = (started?.data as { input?: { steer?: boolean; harness?: { id?: unknown }; harnessAttempts?: { id?: unknown }[] } } | undefined)?.input; const steerable = startedInput?.steer === true && harnessSupports(startedInput, "steer"); const plan = planFrom(events); + const planGrounding = planGroundingView(events); const scanned = findingsFrom(events); const executing = meta !== null && !["completed", "failed", "cancelled", "cancelling"].includes(meta.status); const question = meta?.eventName !== undefined && isAskEvent(meta.eventName) ? pendingQuestion(events) : undefined; @@ -372,6 +382,7 @@ export async function runData({ params, request }: { params: { id: string }; req followUpRequestId: randomUUID(), eventCount: events.length, ...(plan !== undefined ? { plan } : {}), + ...(planGrounding !== undefined ? { planGrounding } : {}), findings: scanned.findings, findingsNotes: scanned.notes, ...(findingContinuityLines(events) !== undefined ? { findingContinuity: findingContinuityLines(events)! } : {}), diff --git a/web/src/routes/runs/[id].tsx b/web/src/routes/runs/[id].tsx index 6ed4fe6..7432b4f 100644 --- a/web/src/routes/runs/[id].tsx +++ b/web/src/routes/runs/[id].tsx @@ -2,6 +2,7 @@ import { nextRequestId } from "../../views/task-composer.js"; import { taskStatus, taskTitle } from "../../lib/task-status.js"; import { JOURNEYS, parseJourney, journeyOptions } from "teploy-ship/journeys"; import { RichText } from "../../views/rich-text.js"; +import type { PlanGroundingView } from "../../lib/plan-grounding-view.js"; import { runData } from "../../lib/run-data.server.js"; import type { RunData } from "../../lib/run-data.server.js"; import { useEffect, useRef, useState } from "preact/hooks"; @@ -312,6 +313,31 @@ const NOW_TICK = `(function(){ /** Severity -> the palette variable already used elsewhere in the dashboard. */ const SEVERITY_COLOR: Record = { high: "var(--red)", med: "var(--yellow)", low: "var(--fg-dim, inherit)" }; +/** Status -> the word plus a colour already used for verdicts; the word carries the label, colour only echoes it. */ +const GROUNDING_STATUS_CLASS: Record = { ungrounded: "bad", grounded: "good", proposed: "meta", unchecked: "meta" }; + +/** Advisory plan grounding (S07) at the plan park. Wording comes from the projection; see lib/plan-grounding-view.ts. */ +function PlanGroundingNotice({ grounding }: { grounding: PlanGroundingView }) { + return ( +
+

Plan grounding — {grounding.headline}

+ {grounding.mismatch !== undefined &&

{grounding.mismatch}

} +
    + {grounding.refs.map((r, i) => ( +
  • + {r.status}{" "} + {r.kind !== "" ? `${r.kind} ` : ""}{r.name} — {r.detail} +
  • + ))} +
+ {grounding.omitted !== undefined && grounding.omitted > 0 && ( +

… and {grounding.omitted} more, counted in the headline.

+ )} +

{grounding.caveat}

+
+ ); +} + export default function RunDetail({ data: initialData }: { data: RunData }) { const [data, setData] = useState(initialData); const [connection, setConnection] = useState("Live updates connected"); @@ -535,6 +561,7 @@ export default function RunDetail({ data: initialData }: { data: RunData }) { edit the text before approving to redirect the plan + {data.planGrounding !== undefined && } )}