Merge fix/write-record-object (a7417a1f2) into integrate/mvp3-ux-1106

This commit is contained in:
jason.woltje committed 2026-11-06 08:24:15 -06:00
commit 4cc3d05b3a
9 files changed
+96 -7

No files matched your search

+1
View File
@@ -6309,3 +6309,4 @@ R5 quote fallback room (#797 D3, 2026-11-06, MVP3 lead): r4 review P2 found that
2026-11-06 UTC | #797 MVP3 project archive r8 — WebUI implementation | Before: r7 head 5bf07f35f temporarily withdrew live thread controls and showed archive-unavailable notices on every same-thread refresh without a failed read. After: a non-navigation redraw retains the resolved archive state only for the same thread with its project in retained metadata; missing retained metadata on failed reads remains unknown. Two delayed refresh ticks retain live request, session, share, cancel and revert controls; a thread switch still withdraws them and stays unknown after failure. Node 26: exact-r7 negative reproduces disabled session control, switch control passes; focused archive browser files 11/11; full WebUI 660 pass / 0 fail / 0 cancelled / 0 skipped, versus r7 658/0. The 1440px remote fixture passed. Bus/harness/CLI not run; project-files excluded. Queue refused linked worktree; no queue move, push, merge, deployment or server guard change. Local candidate for independent review. Evidence/report: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r8-evidence/ and /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r8-report.md.
2026-11-06 UTC | #797 MVP3 project archive r9 — WebUI fix for the r8 review P1 | Before: on r8 head 4c85d3784, a same-thread redraw kept the last resolved live controls. If the fresh lookup then found the project archived (listed or resolved) or unknown, the mounted request panel still offered mode, cancel, revoke, resend and answer, and the session, share, unshare, cancel and revert controls stayed enabled until the slower session and history reads finished. After: right after the fresh project lookup, a same-thread refresh that resolves archived or unknown withdraws those controls and remounts the request panel fenced, before the session read. The navigation and initial-load paths are unchanged. New browser test with the sessions read held open covers listed archived, resolved archived and unknown, and asserts no write. Node 26: negative control on exact r8 fails for behavior ("listed archived: [data-request-mode] offered while the sessions read is pending"); the r8 reviewer probe goes from 3/1 on r8 to 4/0; focused archive browser files 12/12; whole WebUI 661 pass / 0 fail / 0 cancelled / 0 skipped, versus r8 660/0. Bus/harness/CLI not run; project-files excluded. No push, merge, deployment or server guard change. Local candidate for independent review. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r9-evidence/.
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.
+1
View File
@@ -2101,3 +2101,4 @@ are never rewritten or removed; corrections are new entries.
2026-11-06 | Codex implementation sub-agent | #797 project archive r7 WebUI correction on 51892cf95 | Retained archived state fences settings, thread and room writes across failed refresh; final Node 26 WebUI 658/0/0/0, four exact-baseline behavioral negatives, 1440px diagnostic timeout and candidate/base isolated reruns documented. Room Send still requires a successful membership read. Evidence and handoff: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r7-report.md. Local candidate for independent review; queue refused linked worktree; no push, merge or deployment.
2026-11-06 | Codex implementation sub-agent | #797 project archive r8 WebUI correction on 5bf07f35f | Same-thread refresh retains resolved live archive state; switch and failed-lookup withdrawal retained. Exact-r7 behavioral negative reproduced; Node 26 archive browser files 11/11 and full WebUI 660/0/0/0. Evidence and handoff: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r8-report.md. Local candidate for independent review; queue refused linked worktree; no push, merge or deployment.
2026-11-06 | mvp3-lead-797 (Claude, #797 lane) | project archive r9 WebUI fix on 4c85d3784 for the r8 review P1 | A same-thread refresh that resolves archived or unknown withdraws request-panel and unguarded thread writes before the session read. r8 negative fails for behavior; reviewer probe 4/0; focused 12/12; whole WebUI 661/0/0/0. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r9-evidence/. Local candidate for independent review; no push, merge or deployment.
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.
+4 -2
View File
@@ -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
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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 };
+3 -1
View File
@@ -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) } : {}) });
}
});
});
+2 -2
View File
@@ -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"));
}
@@ -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' });
});
+8
View File
@@ -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])