import { existsSync } from "node:fs"; import { promises as fsp } from "node:fs"; import path from "node:path"; import { z } from "zod"; import { WORK_DIR, workFile, readJson } from "../store.js"; import { type DoneEntry, type QueueItem, type WorkDoc, doneEntriesOf, listWorkBoardOf, parseWorkDoc, projectV1ToLanes, queueItemsOf, renderWorkDoc, renderWorkDocForWrite, zeroIsUnparsed, workDocIssues, workDocLegacyWriteIssues, LANES_V0_WRITE_ISSUE, } from "../work.js"; import { parseFactsDoc, type FactEntry, type Priority } from "@davidbalzan/groundwork-seam"; import { loadScopes, ownsDocument } from "./scopes.js"; import { AGENTS_FILE } from "../store.js"; import type { AgentRegistry } from "./shared.js"; import { summarize, blockedBy } from "./records.js"; // ---------- work state as data (Phase 8 Task 5) ---------- // // THE MARKDOWN IS AUTHORITATIVE. This store is a derived index: `import_work` // fills it from the repo's documents, `export_work` renders those documents // back byte-for-byte, and `list_work` falls back to reading the files directly // when no import has happened. Delete ~/agent-coord/work/ and nothing is lost — // the documents still stand alone, which is the property the round-trip test // pins. // // NOT A WRITE GATE. `export_work` is a write PATH (it can rewrite the // documents on request), not an interception point for anyone else's writes: // agents still edit these files with ordinary file tools and this code never // sees that. It reports the Task 4 scope verdict alongside a write so the // caller can see whose document it is, and it never refuses on that basis — // Task 4 shipped scopes as declaration + detection, and quietly upgrading that // to enforcement here would break the promise it made. // Which documents make up a project's work state, in precedence order: the // split QUEUE/DONE layout wins, and the legacy single BACKLOG.md is read only // when neither split file exists (already-documented behaviour). export const QUEUE_DOC = "docs/QUEUE.md"; export const DONE_DOC = "docs/DONE.md"; export const BOARD_DOC = "docs/WORKSTREAMS.md"; export const LEGACY_DOC = "docs/BACKLOG.md"; export const FACTS_DOC = "docs/FACTS.md"; export type WorkFileKind = "queue" | "done" | "board" | "legacy" | "facts"; export type StoredWorkDoc = { kind: "queue" | "done" | "board" | "legacy"; path: string; doc: WorkDoc; /** * The bytes as read. Kept because a COUNT OF ZERO cannot be interpreted * without them: `zeroIsUnparsed` needs to know whether the document had * content the parser did not understand, and the parsed doc has already * discarded that distinction (q-3e7c81a5). */ source: string; }; export type StoredFactsDoc = { kind: "facts"; path: string; source: string; entries: FactEntry[]; issues: string[]; }; export type StoredDoc = StoredWorkDoc | StoredFactsDoc; function isFactsDoc(d: StoredDoc): d is StoredFactsDoc { return d.kind === "facts"; } async function loadDoc(repo: string, f: { kind: WorkFileKind; path: string }): Promise { const source = await fsp.readFile(path.join(repo, f.path), "utf8"); if (f.kind === "facts") { const parsed = parseFactsDoc(source); return { kind: "facts", path: f.path, source, entries: parsed.entries, issues: parsed.issues }; } return { kind: f.kind, path: f.path, doc: parseWorkDoc(source), source }; } function docIssues(d: StoredDoc): string[] { if (isFactsDoc(d)) return d.issues.map((issue) => `${d.path}: ${issue}`); return [...workDocIssues(d.doc), ...workDocLegacyWriteIssues(d.doc)].map( (issue) => `${d.path}: ${issue}`, ); } function importedSummary(d: StoredDoc) { if (isFactsDoc(d)) { return { path: d.path, kind: d.kind, facts: d.entries.length, ...(d.issues.length ? { issues: d.issues } : {}), }; } const issues = [...workDocIssues(d.doc), ...workDocLegacyWriteIssues(d.doc)]; const counts = { queue: queueItemsOf(d.doc).length, done: doneEntriesOf(d.doc).length, board: listWorkBoardOf(d.doc).length, }; // A ZERO THAT CANNOT BE TRUSTED IS NAMED, NOT RETURNED BARE. // // These three were plain `.length`, so a document the parser did not // understand reported the same zero as an empty one. A consumer's dashboard // read that and showed a fleet as IDLE on a night it merged 27 PRs; they // declined to enable these verbs rather than ship a display they could not // trust (q-3e7c81a5). // // The counts keep their shape and meaning — a reader that only wants numbers // is unaffected — and `unparsed` says which of them are unanswered questions // rather than answers. The predicate is the SEAM's, shared with `doctor`'s // queue-done-loop rather than restated here. // ONLY THE AXES THIS DOCUMENT CLAIMS TO CARRY. // // Caught by the test's own output rather than by review: qualifying all three // for every file reported `docs/QUEUE.md` as having unparsed `done` and `board` // axes, which is nonsense — a queue document is not expected to hold done // entries, so its zero there is not an unanswered question, it is a category // error on my part. Scoping the qualification to the document's KIND is the // same population discipline the count itself needed: a claim about an axis a // file never carried is noise, and noise is what teaches a reader to skip the // real one. `legacy` is the single-file BACKLOG.md, which carries both regions. const AXES_BY_KIND = { queue: ["queue"], done: ["done"], board: ["board"], legacy: ["queue", "done"], } as const; // THE AXIS IS PASSED, NOT JUST ITERATED. It was already in scope here and was // not handed to the predicate, so seam 0.1.16's queue-axis fix (#234) changed // nothing any caller could observe: a pruned-but-healthy QUEUE.md kept // reporting `unparsed: ["queue"]` because the predicate fell back to measuring // authored CONTENT, and a queue keeps its headings and prose by design. // // Without the argument the queue axis asks "is there any prose here?" — which // on a queue document is always yes. With it, it asks "did somebody write a // ROW that failed to parse?", which is the question the zero actually needs. // Every axis is passed its own name; only "queue" is treated differently // inside the predicate. `done` and `board` keep the content measure // deliberately — prose under an empty done log IS a fair reason to doubt that // zero — so this is a narrowing of one axis, not a relaxation of all three. const unparsed = (AXES_BY_KIND[d.kind] as readonly (keyof typeof counts)[]).filter((axis) => zeroIsUnparsed(counts[axis], d.source, axis), ); return { path: d.path, kind: d.kind, ...counts, ...(unparsed.length ? { unparsed, unparsedNote: `${d.path} has content the parser did not understand: ${unparsed.join(", ")} parsed to ZERO. ` + `That is an unanswered question, not an empty document — do not render it as idle, empty or done. ` + `Either the file uses a shape this grammar does not accept, or it is malformed; both need a human, and ` + `neither is "nothing there".`, } : {}), ...(issues.length ? { issues } : {}), }; } export type WorkState = { project: string; repo: string; importedAt: number; // `legacy` is present instead of queue/done when the split files are absent. docs: StoredDoc[]; }; function resolveDocs(repo: string): { kind: WorkFileKind; path: string }[] { const has = (rel: string) => existsSync(path.join(repo, rel)); const out: { kind: WorkFileKind; path: string }[] = []; if (has(QUEUE_DOC) || has(DONE_DOC)) { if (has(QUEUE_DOC)) out.push({ kind: "queue", path: QUEUE_DOC }); if (has(DONE_DOC)) out.push({ kind: "done", path: DONE_DOC }); } else if (has(LEGACY_DOC)) { // One file carrying `## Queue` and `## Done` regions — same parser, since // the sections are what select the record kind, not the filename. out.push({ kind: "legacy", path: LEGACY_DOC }); } if (has(BOARD_DOC)) out.push({ kind: "board", path: BOARD_DOC }); if (has(FACTS_DOC)) out.push({ kind: "facts", path: FACTS_DOC }); return out; } async function loadState(project: string): Promise { return readJson(workFile(project), null); } /** * WHICH STORED DOCS HAVE BEEN OVERTAKEN BY THE FILE ON DISK. * * THE FRESHNESS OF A RESPONSE IS THE FRESHNESS OF ITS STALEST FIELD, and before * this the store was trusted because it existed. Measured on this fleet: a store * imported at 17:29 already disagreed with 3 of its 4 documents by 17:42 — * **thirteen minutes.** On a bus where several seats write coordination docs, the * staleness window is minutes, not the weeks a dated store suggests. * * The instrument is mtime, and its limit is stated rather than hidden: an edit * that PRESERVES mtime is not detected. mtime is used because the cheap question * ("might the store be stale?") must not cost a full re-read of ~800KB of * documents on every call; when the answer is yes, the re-read happens anyway. * A missing file counts as stale — it cannot be compared, and "could not check" * is not "checked and clean". */ async function staleDocs(state: WorkState): Promise<{ path: string; why: string }[]> { const out: { path: string; why: string }[] = []; for (const d of state.docs) { const full = path.join(state.repo, d.path); try { const st = await fsp.stat(full); if (st.mtimeMs > state.importedAt) { out.push({ path: d.path, why: `modified ${new Date(st.mtimeMs).toISOString()}, after the store was imported at ${new Date(state.importedAt).toISOString()}`, }); } } catch (e) { out.push({ path: d.path, why: `cannot be read (${(e as Error).message}) — unverifiable, not assumed clean` }); } } return out; } async function saveState(state: WorkState): Promise { await fsp.mkdir(WORK_DIR, { recursive: true }); await fsp.writeFile(workFile(state.project), JSON.stringify(state, null, 2) + "\n", "utf8"); } // ---------- import_work ---------- export const importWorkSchema = { project: z.string().min(1), // Repo root holding docs/. Defaults to the server's cwd. repo: z.string().optional(), }; export async function importWorkTool(args: { project: string; repo?: string }) { const repo = args.repo ?? process.cwd(); const found = resolveDocs(repo); if (!found.length) { return { ok: false as const, error: `no work documents under '${repo}' — expected ${QUEUE_DOC}/${DONE_DOC} or the legacy ${LEGACY_DOC} (optional ${FACTS_DOC})`, }; } const docs: StoredDoc[] = []; for (const f of found) docs.push(await loadDoc(repo, f)); const state: WorkState = { project: args.project, repo, importedAt: Date.now(), docs }; await saveState(state); const allIssues = docs.flatMap(docIssues); return { ok: true as const, project: args.project, repo, file: workFile(args.project), imported: docs.map(importedSummary), ...(allIssues.length ? { warning: `${allIssues.length} issue(s) (board arity / unknown grammar / facts) — documents round-trip unchanged. See imported[].issues.`, } : {}), note: "the markdown remains authoritative — this store is a derived index", }; } // ---------- list_work ---------- export const listWorkSchema = { project: z.string().min(1), kind: z.enum(["queue", "done", "board", "facts"]).optional(), priority: z.enum(["P1", "P2", "P3"]).optional(), // Include queue items already ticked off (default false). includeDone: z.boolean().optional(), // native (default): discriminated v1 | lanes-v0. lanes: lossy projection of v1. view: z.enum(["native", "lanes"]).optional(), repo: z.string().optional(), // Fetch ONE full record by id — the second call Task 15.1 asks for. // Identity rows (below) never carry the body; this is how a caller gets it, // and only for the one row it actually needs rather than every row's. id: z.string().optional(), }; // A queue row without its body — id, priority, a bounded headline, and // blocked-by, the fields the doc names. `owner` is NOT included: QueueItem // carries no structured owner field, and the free-text `origin:` phrase some // items end with ("origin: coordinator retracting its own broadcast canon…") // is prose, not an id — extracting one heuristically would fabricate a field // this data model does not have. Omitted rather than guessed, same // discipline as `list_agents`' heartbeatSource (Task 13.5). export type QueueIdentityRow = { id: string; priority: Priority | null; headline: string; truncated: boolean; blockedBy: string | null; }; function identityRowOf(item: QueueItem): QueueIdentityRow { const flat = String(item.text).replace(/\s+/g, " ").replace(/\*\*/g, "").trim(); return { id: item.id, priority: item.priority, headline: summarize(item.text), // `summarize` itself decides where to cut; re-deriving "did it cut" here // from a second pass over the same text would be the two-matchers-one- // question shape this phase keeps finding elsewhere (refsIn, COMMIT_REF). // Comparing against its own bound is exact and free. truncated: flat.length > 96, blockedBy: blockedBy(item), }; } export async function listWorkTool(args: { project: string; kind?: "queue" | "done" | "board" | "facts"; priority?: "P1" | "P2" | "P3"; includeDone?: boolean; view?: "native" | "lanes"; repo?: string; id?: string; }) { // No import yet (or the store was deleted) → read the documents directly. // This is the fallback that makes "the markdown stands alone" true in code // rather than in a comment. let state = await loadState(args.project); let source: "store" | "markdown" = "store"; // A STALE STORE IS NEVER SERVED SILENTLY. `staleStore` is present in the // response whenever the store lost a race with the documents, so the caller // learns it from the ANSWER rather than from a `source` field they would have // to know to check. The confound this removes: `issues` carried live-looking // diagnostics beside a stale queue, so the field that made a careful reader // trust the payload was the one field that was current. let staleStore: { reparsed: boolean; docs: { path: string; why: string }[]; note: string } | undefined; if (!state) { const imported = await importFromDisk(args.project, args.repo ?? process.cwd()); if (!imported) { return { ok: false as const, error: `no work state for '${args.project}' and no documents under '${args.repo ?? process.cwd()}'` }; } state = imported; source = "markdown"; } else { const stale = await staleDocs(state); if (stale.length) { const fresh = await importFromDisk(state.project, state.repo); if (fresh) { state = fresh; source = "markdown"; staleStore = { reparsed: true, docs: stale, note: `the store was older than ${stale.length} of its document(s) and was NOT used — these rows were re-parsed from disk. ` + `Every field below therefore shares one provenance.`, }; } else { // Cannot re-read, so the stale store is all there is. It is still // reported, because an answer that cannot be refreshed is the one most // in need of saying so. staleStore = { reparsed: false, docs: stale, note: `the store is older than ${stale.length} of its document(s) and could NOT be re-parsed from disk — ` + `the rows below are as stale as the store and must not be read as current.`, }; } } } const queue: QueueItem[] = []; const done: DoneEntry[] = []; const facts: FactEntry[] = []; let board = []; const issues: string[] = []; for (const d of state.docs) { if (isFactsDoc(d)) { facts.push(...d.entries); issues.push(...docIssues(d)); continue; } queue.push(...queueItemsOf(d.doc)); done.push(...doneEntriesOf(d.doc)); board.push(...listWorkBoardOf(d.doc)); issues.push(...docIssues(d)); } if (args.view === "lanes") board = board.map(projectV1ToLanes); // THE SECOND CALL — one row, whole. Searches queue then done (a done entry // shares the id space; an id is unique within a project's work state), so // the caller does not have to know which kind its own id belongs to. if (args.id !== undefined) { const queueHit = queue.find((q) => q.id === args.id); if (queueHit) return { ok: true as const, project: state.project, repo: state.repo, source, ...(staleStore ? { staleStore } : {}), kind: "queue" as const, item: queueHit }; const doneHit = done.find((d) => d.id === args.id); if (doneHit) return { ok: true as const, project: state.project, repo: state.repo, source, ...(staleStore ? { staleStore } : {}), kind: "done" as const, item: doneHit }; return { ok: false as const, error: `no queue item or DONE entry with id '${args.id}' in project '${state.project}'` }; } const openQueue = args.includeDone ? queue : queue.filter((q) => !q.done); const filtered = args.priority ? openQueue.filter((q) => q.priority === args.priority) : openQueue; return { ok: true as const, project: state.project, repo: state.repo, source, ...(staleStore ? { staleStore } : {}), // PROVENANCE OF THE WHOLE PAYLOAD, including the instrument and its limit. // Done-def 4: if any field can outpace another, the response says so rather // than a comment saying it. freshness: { source, importedAt: new Date(state.importedAt).toISOString(), checkedAgainst: "file mtime vs store importedAt", limit: "an edit that preserves mtime is not detected; a re-parse is triggered only when mtime is newer", }, // IDENTITY ONLY (Task 15.1) — id, priority, a bounded headline, and // blocked-by; never the full item text. Call again with `id` for one // row's body. This is the change that makes the tool cheap: the same // question that used to return every open item's ~1,500-char body now // returns a fixed-width row per item. ...(args.kind === "queue" || args.kind === undefined ? { queue: filtered.map(identityRowOf) } : {}), ...(args.kind === "done" || args.kind === undefined ? { done } : {}), ...(args.kind === "board" || args.kind === undefined ? { board } : {}), ...(args.kind === "facts" || args.kind === undefined ? { facts } : {}), ...(issues.length ? { issues } : {}), }; } // Parse the documents without persisting — used by the list fallback so a // read never has the side effect of writing a store file. async function importFromDisk(project: string, repo: string): Promise { const found = resolveDocs(repo); if (!found.length) return null; const docs: StoredDoc[] = []; for (const f of found) docs.push(await loadDoc(repo, f)); return { project, repo, importedAt: Date.now(), docs }; } // ---------- export_work ---------- export const exportWorkSchema = { project: z.string().min(1), // Default false: an export REPORTS by default and only writes when asked. write: z.boolean().optional(), // Where to write; defaults to the repo the state was imported from. repo: z.string().optional(), // Whose write this is, for the (advisory) Task 4 scope verdict. agentId: z.string().optional(), }; export async function exportWorkTool(args: { project: string; write?: boolean; repo?: string; agentId?: string; }) { const state = await loadState(args.project); // Refuse rather than render nothing. An export from an absent or empty store // would blank a real document — the store is derived, so "no records" means // "not imported", never "the queue is empty". if (!state || !state.docs.length) { return { ok: false as const, error: `no imported work state for '${args.project}' — run import_work first. (Refusing to export from an empty store: that would blank the documents, and the markdown is authoritative.)`, }; } const repo = args.repo ?? state.repo; const scopes = await loadScopes(); const reg = await readJson(AGENTS_FILE, {}); const write = args.write ?? false; const files = []; for (const d of state.docs) { const target = path.join(repo, d.path); if (isFactsDoc(d)) { // FACTS is not an export write target — set-fact.mjs owns the file. const current = existsSync(target) ? await fsp.readFile(target, "utf8") : null; files.push({ path: d.path, bytes: Buffer.byteLength(d.source, "utf8"), identical: current === d.source, written: false, note: "facts is not an export write target (set-fact.mjs owns the file)", ...(d.issues.length ? { issues: d.issues } : {}), }); continue; } // DELIBERATELY THE PURE RENDER, NOT THE STAMPING ONE (q-c50e9b83). // // I routed this through `renderWorkDocForWrite` first and it broke three // pre-existing tests that assert `import → export` writes the real documents // back BYTE-IDENTICALLY. They were right and the change was wrong: this is a // TRANSPORT, not an authoring path. Its contract is to reproduce a document, // and a transport that silently edits content the caller never asked it to // touch is a worse defect than the one being fixed — it would also mean // exporting into ANOTHER project's documents mutates them. // // So the primary is scoped to the AUTHORING writer (`land`, via records.ts), // and an unstamped item arriving through this path is the backstop's // population: it cannot reach `main` without passing a push. const rendered = renderWorkDoc(d.doc); const current = existsSync(target) ? await fsp.readFile(target, "utf8") : null; const declared = scopes.documents.find((s) => s.path === d.path); // ADVISORY ONLY — reported, never enforced. See the header note. const scope = declared ? { owner: declared.owner, mode: declared.mode, callerOwns: args.agentId ? ownsDocument(args.agentId, reg[args.agentId], declared.owner) : null, advisory: true as const, } : undefined; const issues = [...workDocIssues(d.doc), ...workDocLegacyWriteIssues(d.doc)]; const wouldWrite = write && rendered !== current; const refuseLegacyWrite = wouldWrite && workDocLegacyWriteIssues(d.doc).length > 0; if (wouldWrite && !refuseLegacyWrite) await fsp.writeFile(target, rendered, "utf8"); files.push({ path: d.path, bytes: Buffer.byteLength(rendered, "utf8"), identical: rendered === current, written: wouldWrite && !refuseLegacyWrite, // Refused rows replay verbatim — the write is byte-faithful — but a // caller rewriting a document should hear that some rows carry no // record, rather than infer health from `identical:true`. ...(refuseLegacyWrite ? { error: LANES_V0_WRITE_ISSUE } : {}), ...(issues.length ? { issues } : {}), ...(scope ? { scope } : {}), }); } const refused = files.filter((f) => "error" in f); return { ok: refused.length === 0, project: args.project, repo, write, files, ...(refused.length ? { error: `refused ${refused.length} new lanes-v0 write(s) — ${LANES_V0_WRITE_ISSUE}` } : {}), ...(write ? {} : { note: "dry run — pass write:true to rewrite the documents" }), }; }