From a7417a1f2e5b3a06c906261bc290a9905c653498 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 6 Nov 2026 07:40:28 -0600 Subject: [PATCH] fix(project): accept JSON strings for records --- BUILD-LOG.md | 2 + docs/SESSIONS.md | 1 + packages/bus/src/broker.mjs | 6 +- packages/bus/src/client.mjs | 2 +- packages/bus/src/server.mjs | 2 +- packages/harness/src/runner.mjs | 4 +- packages/harness/src/tools.mjs | 4 +- .../tests/write-record-object.test.mjs | 75 +++++++++++++++++++ packages/project/src/index.mjs | 8 ++ 9 files changed, 97 insertions(+), 7 deletions(-) create mode 100644 packages/harness/tests/write-record-object.test.mjs diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 6ab227bd0..ac2bb7c14 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -6299,3 +6299,5 @@ R5 quote fallback room (#797 D3, 2026-11-06, MVP3 lead): r4 review P2 found that 2026-11-06 UTC | #797 stall-guard integration merge | Before: stall-guard tip 6b2c925b9 and integration 5255e4e74 each supplied a Claude activity route; managed Pi also had two potential marker producers. After: BASE's Claude stream helper is authoritative, the Pi monitor is retained and its extension duplicate is disabled for managed turns, runner metadata suppression and newline filtering coexist, instance-running-only cooldown remains, and known/unknown child-signal receipts survive on both paths. Claude empty-delta handling follows BASE's selected helper; helper SHA pin updated. Node 26 final harness 309 pass, CLI main/lane/release 885/61/251 pass with one FED R7 skip, separately verified guard E2Es 11 pass, bus excluding project-files 1615 pass. Bus project-files had 5 pass then timeout/cancellation on both candidate and exact BASE; gate is not green. One earlier harness run failed the known Claude row-66 timing assertion (exact BASE full harness 277/277 and two file runs 14/14; older-base reproduction recorded by prior review); final rerun passed. An initial signal test invoked the removed Claude monitor and was adapted. Queue refused linked worktree; canonical queue read returned nothing. Local merge candidate only, no push or deployment. Full counts, caveats and evidence: /mnt/storage/scratch/mosaic-lead/mvp3/stall-guard-int-report.md and stall-guard-int-evidence/. 2026-11-06 UTC | #797 supervisor schema guard implementation | Before: the 3s-only accounting guard refused the 3t archive store. After: the lead-authorized closed 3s/3t fallback preserves 3s compatibility and accepts 3t; store.mjs performs load-time file reads and in-memory schema/migration execution, so it is not imported into the supervisor. The real-Store current-version regression failed on BASE with test-store-schema-refused; synthetic 3s accepts, 3r/3u/missing refuse. DDL diff adds only project_archives objects. README documents owner diagnosis and restoration or campaign end without reset. Sequential Node 26 whole-file gates: budget 102/0, isolated identity 8/0, browser 13/0, lifecycle 2/0; no skips. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/supervisor-3t-evidence/; local candidate for independent review, no push or deployment. + +2026-11-06 UTC | #797 write_record JSON object compatibility — implementation and verification | Before: JSON-encoded requirements and decisions were refused and project-record-invalid detail was lost before the agent. After: strict object parsing preserves object-form bytes, and only record.write project-record-invalid details reach the tool result; docs remain Markdown. Exact-base negative control: seven behavioral failures. Node 26 serial gates: touched file 7/7, project 50/50, harness 316/316, bus excluding project-files 1642/1642, CLI 1204 pass and one federation acceptance skip reproduced on base. An initial candidate focused run failed one test due to a duplicate code prefix; corrected and rerun green. Evidence and handoff: /mnt/storage/scratch/mosaic-lead/mvp3/write-record-object-evidence/ and /mnt/storage/scratch/mosaic-lead/mvp3/write-record-object-report.md. Linked-worktree queue refused; local candidate for independent review, no push or deployment. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index bac378c2a..11299b17a 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -2096,3 +2096,4 @@ are never rewritten or removed; corrections are new entries. 2026-11-06 UTC | ui797 stall-guard review-fix implementation sub-agent | #797 three corrections on feat/ui797-stall-guard-r2 from 3e754c96a | Adapter stdout parity, signal-specific child-death receipts, instance-running-only cooldown; Node 26 gates and negative controls recorded in /mnt/storage/scratch/mosaic-lead/mvp3/stall-guard-r2-report.md. Local commits only; queue refused linked worktree; independent review pending. No push, merge, deployment, live-host contact, model call, secret, role, policy, contract or schema changes. 2026-11-06 UTC | ui797 stall-guard integration implementation sub-agent | #797 integration of 5255e4e74 into 6b2c925b9 | Reconciled Claude/Pi activity sources, retained cooldown and signal receipts; final Node 26 harness 309 pass, CLI 1197 pass/1 base-reproduced skip, six guard E2Es within 11 pass, bus 1615 pass plus project-files 5 pass/1 timeout also on exact BASE; earlier row-66 timing failure and all comparisons retained. Local merge candidate for independent review; no push, deployment, real model call or protected-host contact. Report: /mnt/storage/scratch/mosaic-lead/mvp3/stall-guard-int-report.md. 2026-11-06 UTC | supervisor-3t implementation sub-agent | #797 closed 3s/3t supervisor guard and recovery guidance above 37f38d8e1 | Real-store regression red on BASE; sequential whole-file gates 125 pass, 0 fail/skip, evidence in /mnt/storage/scratch/mosaic-lead/mvp3/supervisor-3t-evidence/. Queue refused linked worktree; one local commit for independent review, no push, deployment or live access. +2026-11-06 UTC | Terra implementation sub-agent | #797 write_record JSON object compatibility on fix/write-record-object from 13f81354e | Strict parsed JSON objects and scoped agent-visible validation details; exact-base seven-test negative, Node 26 serial gates and base-reproduced CLI skip recorded in /mnt/storage/scratch/mosaic-lead/mvp3/write-record-object-report.md. Queue refused linked worktree; local commit for independent review, no push or deployment. diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 1d6126a05..090effd59 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -238,7 +238,7 @@ const canonical = (x) => Number.isFinite(Date.parse(x)) && new Date(x).toISOString() === x; export class BusError extends Error { - // C2-06 (sage-3599): only the citation guard's refusals carry a reason, and the envelope passes it on. + // Only explicitly scoped refusals carry a reason; the envelope passes it on. constructor(code, reason) { super(code); this.code = code; @@ -6086,7 +6086,9 @@ export class Broker { return result; }); } catch (e) { - throw this.#refused(s, ProjectFailure && e instanceof ProjectFailure ? new BusError(e.code) : e, request); + throw this.#refused(s, ProjectFailure && e instanceof ProjectFailure + ? new BusError(e.code, verb === 'record.write' && e.code === 'project-record-invalid' + ? e.message.slice(`${e.code}: `.length) : undefined) : e, request); } } // Row 148 (#1642): the Console's reads and saves of the spec and docs. Human only: an agent diff --git a/packages/bus/src/client.mjs b/packages/bus/src/client.mjs index 95577f52b..80b5ce29d 100644 --- a/packages/bus/src/client.mjs +++ b/packages/bus/src/client.mjs @@ -41,7 +41,7 @@ export class Client { const r = JSON.parse(input.toString('utf8')); if (r.ok === true) end(null, r.result); else if (r.ok === false && typeof r.error === 'string' && /^[a-z-]{1,64}$/.test(r.error)) - // C2-06 (sage-3599): the citation guard's reason, when the envelope has one. + // Preserve a bounded reason when an explicitly scoped refusal has one. end(new BusError(r.error, typeof r.reason === 'string' ? r.reason.slice(0, 300) : undefined)); else end(new BusError('invalid-response')); } catch { diff --git a/packages/bus/src/server.mjs b/packages/bus/src/server.mjs index a55ad7c7e..7706862c9 100644 --- a/packages/bus/src/server.mjs +++ b/packages/bus/src/server.mjs @@ -10,7 +10,7 @@ const PROJECT_SAVE_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll const LIBRARY_SAVE_AT_START = /"verb":"library\.file\.save"/; const SAVE_VERB_AT_START = { test: text => PROJECT_SAVE_AT_START.test(text) || LIBRARY_SAVE_AT_START.test(text) }; // The refusal envelope. C2-06 (sage-3599): `reason` is added only when the error carries one, which -// only the citation guard's refusals do, so every other envelope is byte-identical to before. +// only explicitly scoped refusals do, so every other envelope is byte-identical to before. const refusal = (e, other) => e instanceof BusError ? { ok: false, error: e.code, ...(typeof e.reason === 'string' ? { reason: e.reason } : {}) } : { ok: false, error: other }; diff --git a/packages/harness/src/runner.mjs b/packages/harness/src/runner.mjs index 3b7279d8f..f10a1d674 100644 --- a/packages/harness/src/runner.mjs +++ b/packages/harness/src/runner.mjs @@ -358,7 +358,9 @@ export function toolServer({ path, tools, client, local }) { const result = Object.hasOwn(local, tool.verb) ? await local[tool.verb](args, { signal: controller.signal }) : await client.call(tool.verb, args); answer({ ok: true, result }); } catch (e) { - answer({ ok: false, error: /^[a-z-]{1,64}$/.test(e.code ?? "") ? e.code : "tool-failed" }); + answer({ ok: false, error: /^[a-z-]{1,64}$/.test(e.code ?? "") ? e.code : "tool-failed", + ...(tool.verb === "record.write" && e.code === "project-record-invalid" && typeof e.reason === "string" + ? { reason: e.reason.slice(0, 300) } : {}) }); } }); }); diff --git a/packages/harness/src/tools.mjs b/packages/harness/src/tools.mjs index f71072e8d..63fe2b41b 100644 --- a/packages/harness/src/tools.mjs +++ b/packages/harness/src/tools.mjs @@ -322,7 +322,7 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project path: str("Named docs Markdown or requirement/decision JSON path", { pattern: "^(docs/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\\.md|(requirements|decisions)/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\\.json)$", }), - record: { type: ["string", "object"], description: "Markdown string or JSON record. For requirements/REQ-1.json use {\"recordVersion\":1,\"id\":\"REQ-1\",\"title\":\"Title\",\"text\":\"Requirement text\",\"status\":\"draft\"}; exactly these five fields, id matching filename, status draft, accepted or complete. Title and text must be nonempty." }, + record: { type: ["string", "object"], description: "JSON object for requirements/ and decisions/ paths; Markdown string for docs/ paths. For requirements/REQ-1.json use {\"recordVersion\":1,\"id\":\"REQ-1\",\"title\":\"Title\",\"text\":\"Requirement text\",\"status\":\"draft\"}; exactly these five fields, id matching filename, status draft, accepted or complete. Title and text must be nonempty." }, decision: COMMON.decision, }, ["project", "path", "record"])); } @@ -429,7 +429,7 @@ export function callTool(path, tool, args = {}, { timeout = 60_000 } = {}) { if (!input.includes("\n")) return; try { const r = JSON.parse(input); - r.ok ? end(null, r.result) : end(new Error(tool === "intake_question" && r.error === "outcome-unknown" ? "outcome-unknown" : tool === "launch" && r.error === "instance-running" ? "refused: instance-running: already running; wait for the existing run" : `refused: ${r.error}`)); + r.ok ? end(null, r.result) : end(new Error(tool === "intake_question" && r.error === "outcome-unknown" ? "outcome-unknown" : tool === "launch" && r.error === "instance-running" ? "refused: instance-running: already running; wait for the existing run" : `refused: ${r.error}${tool === "write_record" && r.error === "project-record-invalid" && typeof r.reason === "string" ? `: ${r.reason.slice(0, 300)}` : ""}`)); } catch { end(unknown("tool reply isn't JSON")); } diff --git a/packages/harness/tests/write-record-object.test.mjs b/packages/harness/tests/write-record-object.test.mjs new file mode 100644 index 000000000..e12a6f79d --- /dev/null +++ b/packages/harness/tests/write-record-object.test.mjs @@ -0,0 +1,75 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fixture, git } from '../../bus/tests/spec-fixtures.mjs'; +import { serve } from '../../bus/src/server.mjs'; +import { Client } from '../../bus/src/client.mjs'; +import { toolServer } from '../src/runner.mjs'; +import { callTool, typedTools } from '../src/tools.mjs'; + +const requirement = { recordVersion: 1, id: 'REQ-1', title: 'Title', text: 'Text', status: 'draft' }; +const decision = { recordVersion: 1, id: 'DEC-1', question: 'Which?', + options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], + recommendation: 'yes', status: 'open', choice: null }; + +async function setup(t) { + const f = await fixture(t); + const busPath = join(f.root, 'bus.sock'); + const bus = await serve({ broker: f.broker, path: busPath }); + t.after(() => bus.close()); + const client = new Client({ path: busPath, cap: f.coder }); + const tools = typedTools({ resolved: { definition: 'coder', limits: { tools: [], authority: { + withinRole: ['record.write'], crossRole: [], + } } } }); + const path = join(f.root, 'tools.sock'); + const server = await toolServer({ path, tools, client, local: {} }); + t.after(() => server.close()); + return { ...f, write: (recordPath, record) => callTool(path, 'write_record', { project: 'stack', path: recordPath, record }) }; +} + +for (const [kind, record] of [['requirements', requirement], ['decisions', decision]]) { + test(`JSON string ${kind} records commit the same bytes as object records`, async t => { + const object = await setup(t), string = await setup(t); + const path = `${kind}/${record.id}.json`; + const written = await object.write(path, record); + const parsed = await string.write(path, JSON.stringify(record)); + assert.match(parsed.commit, /^[0-9a-f]{40}$/); + const expected = git(object.repo, 'show', `${written.commit}:${path}`) + '\n'; + assert.equal(readFileSync(join(string.repo, path), 'utf8'), expected); + assert.equal(git(string.repo, 'show', `${parsed.commit}:${path}`) + '\n', expected); + const doc = '# Markdown remains text\n'; + await string.write('docs/record.md', doc); + assert.equal(readFileSync(join(string.repo, 'docs/record.md'), 'utf8'), doc); + await assert.rejects(string.write('docs/object.md', { text: 'not Markdown' }), /project-record-invalid/); + }); +} + +for (const [name, value, detail] of [ + ['unparseable', 'secret-marker {', /unparseable string/], + ['array', '["secret-marker"]', /array/], + ['number', '42', /number/], + ['null', 'null', /null/], +]) { + test(`agent sees safe detail for ${name} JSON record`, async t => { + const f = await setup(t); + await assert.rejects(f.write('requirements/REQ-1.json', value), e => { + assert.match(e.message, /refused: project-record-invalid/); + assert.match(e.message, detail); + assert.doesNotMatch(e.message, /secret-marker/); + return true; + }); + }); +} + +test('agent sees validation detail while other refusal codes remain code-only', async t => { + const f = await setup(t); + await assert.rejects(f.write('requirements/REQ-1.json', { ...requirement, title: '' }), + /refused: project-record-invalid: requirement\.title: too short/); + await assert.rejects(f.write('spec/secret-marker.md', '# text'), { message: 'refused: project-spec-human-only' }); + const path = join(f.root, 'other-tools.sock'); + const server = await toolServer({ path, tools: [{ name: 'write_record', verb: 'record.write' }], local: {}, + client: { call() { throw Object.assign(new Error('hidden'), { code: 'project-busy', reason: 'secret-marker' }); } } }); + t.after(() => server.close()); + await assert.rejects(callTool(path, 'write_record', {}), { message: 'refused: project-busy' }); +}); diff --git a/packages/project/src/index.mjs b/packages/project/src/index.mjs index d66cad1d3..7a330477d 100644 --- a/packages/project/src/index.mjs +++ b/packages/project/src/index.mjs @@ -167,6 +167,14 @@ function recordBytes(path, record) { if (typeof record !== 'string' || !record.length) throw new ProjectError('project-record-invalid', 'document must be nonempty Markdown'); text = record; } else { + if (typeof record === 'string') { + try { record = JSON.parse(record); } + catch { throw new ProjectError('project-record-invalid', 'JSON record must be an object (got unparseable string)'); } + if (record === null || typeof record !== 'object' || Array.isArray(record)) { + const type = record === null ? 'null' : Array.isArray(record) ? 'array' : typeof record; + throw new ProjectError('project-record-invalid', `JSON record must be an object (got ${type})`); + } + } const kind = match[1] === 'requirements' ? 'requirement' : 'decision'; const problems = validateRecord(kind, record); if (problems.length || record.id !== match[2])