From 6e5cf26e7a079a50d26fe9a671690d50dc006037 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 5 Nov 2026 21:18:26 -0600 Subject: [PATCH] fix(bus): quote approved requirement texts into coder/reviewer continuations (#797) D3: coder/reviewer sandboxes cannot read the product records repo and list_requirements stays PM/CTO-only (row 90, #1584). The bus now quotes the task-cited approved requirement texts (exact text, id, title, citing task refs, snapshot id, product key, records commit) into the implementation and review continuations, resolved host-side through the same catalog resolver behind the scoped requirements verb, never from model input. Quotes are bounded at 16384 bytes and framed as quoted data; an unresolvable or revoked snapshot degrades to an ask-PM line without blocking the task. No role, tool or schema widened; one duty note each in roles/coder.md and roles/reviewer.md. --- BUILD-LOG.md | 6 + packages/bus/src/broker.mjs | 58 ++++++++ packages/bus/src/request-continuation.mjs | 30 +++- packages/bus/src/request-lifecycle.mjs | 20 ++- .../bus/tests/request-continuation.test.mjs | 133 ++++++++++++++++++ roles/coder.md | 3 + roles/reviewer.md | 4 + 7 files changed, 244 insertions(+), 10 deletions(-) diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 6a74a7337..af59d43b9 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -6201,3 +6201,9 @@ Before: pinned 05fdfef9ea1fdddf58c725c04f229ca8559fc054; live b1869a13 has five ## 2026-11-05 — MVP 3 planning coverage implementation ready for review (#797) After: preserve the scalar task requirement schema and fail-closed coverage gate; incomplete advance now sends a deduplicated PM bus notice naming every uncovered id and explaining correction, task-limit reconciliation and why a push cannot clear coverage. PM contract, task_create description and planning continuation state the same rule. Five-requirement regression proves prose/push do not cover missing ids, retries do not flood, changed coverage updates the notice, and complete assigned coverage advances without pushes for the other tasks. Focused whole files: 52 pass/0 fail/0 skip; scoped/lifecycle/import/role regressions: 115 pass/0 fail/0 skip; dependency/path/closure files: 61 pass/0 fail/0 skip (228 total). Initial fixture run: 51 pass/1 fail (new test used an invalid PRD author; corrected to pm.pm). Initial path checks: 28 pass/23 fail from absent worktree node_modules; canonical dependency versions matched package.json, linked locally, then all 61 passed. Read-only .53 evidence identifies proxy-stall watchdog as coder stop cause; D2–D5 remain report-only follow-ups. Detailed commands, receipts, limitations and exact commit: /mnt/storage/scratch/mosaic-lead/mvp3/planning-gate-fix-r1-report.md. No push, merge, runtime write, role authority expansion or product-acceptance claim. + +## 2026-11-05 — MVP 3 D3 requirement texts in continuations implemented (#797) + +Before: branch fix/mvp3-req-texts-797 off 62bece4a9da803d0734b435d157578ea5c8d2001 (planning-gate fix) in the T3 worktree; coder/reviewer could not read the product's approved requirement records (D3), and list_requirements remains PM/CTO-only by row 90 (#1584), asserted unchanged by packages/harness/tests/tools.test.mjs. Scope chosen by the lead: no role/tool widening; the bus itself quotes the task-cited approved texts into the implementation/review continuations it already sends. + +After: broker #continuationQuote resolves, host-side and never from model input, this request's task.created citations (same product, exact approved snapshot) through the same catalog resolver behind the scoped requirements verb (#requirementsFor), bounding titles+texts at CONTINUATION_QUOTE_BUDGET=16384 bytes with a truncation+ask-PM line. request-continuation renders the block for push_working/review_verdict only, framed as quoted owner-approved data, never instructions; intents/evidence digests unchanged (quote lives in the message body only). request-lifecycle resolves the quote before its journal transactions (resume path; advance reuses the pre-transaction catalog.resolve result, covering the review→implementation changes loopback). Unresolvable or revoked snapshots and missing citations degrade to an ask-PM line without blocking or throwing. roles/coder.md and roles/reviewer.md gained one duty note each; no roles/*.json, tools.mjs gate, schema or authority change. New regressions: exact text/snapshot/records-commit/task citation with foreign-request, foreign-product and unknown-id isolation on a fresh send; reviewer quote; stale-drift and revoked-snapshot fallbacks without throw; budget truncation ordering. Whole files: bus+tasks 197 pass/0 fail/0 skip; harness+business 110 pass/0 fail/0 skip (Row 90 assertion green, coder/reviewer still lack list_requirements); adjacent recovery/limits/cli-lifecycle/launch 45 pass/0 fail/0 skip; 352 total, git diff --check clean. Initial runs failed three new tests ([draft] status not in snapshot rows; append-only request_snapshots/events triggers; successor reuses a waiting unroutable message) — corrected the tests, not the guards. Receipts: /mnt/storage/scratch/mosaic-lead/mvp3/req-texts-*.tap and req-texts-fix-r1-report.md. No push, merge, runtime write or product-acceptance claim. diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 743bf7ef2..b2ad2ed70 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -98,6 +98,9 @@ export const STRANDED = 50; const TASK_ARGS = ['done', 'assignee', 'label', 'requirement', 'limit', 'after', 'task_ref', 'project_key', 'requirement_snapshot']; export const TASK_PAGE = 50; export const TASK_PAGE_MAX = 200; +// D3 (#797): byte budget for the approved requirement texts quoted into one coder/reviewer +// continuation. Titles count against it too; past it the quote is cut and says to ask the PM. +export const CONTINUATION_QUOTE_BUDGET = 16384; // Row 134 (REQ-HARN-2): the web verbs a role holding the "web" tool grant may call, and the label // on every reply. Fetched pages and search results are other people's text, never instructions. // Row 148 (#1642): the human-only verbs behind the Console's document editor. The save is the one @@ -631,6 +634,7 @@ export class Broker { if (context.chief && target !== context.chief) this.#notice(state.business, message, context.chief, target, NOTICE_ROLE, this.#business(state.business).launch.instances.includes(target), this.#store.get('SELECT body FROM messages WHERE id=?', message)?.body); }, continuationMessage: (state, target, body, context) => this.#continuationMessage(state, target, body, context), + continuationQuote: (state, resolved = null) => this.#continuationQuote(state, resolved), origin: (identity, request) => this.#requestOrigin(identity, request), authorize: (identity, state, operation) => this.#requestAuthorize(identity, state, operation), project: (business, project) => { @@ -704,6 +708,60 @@ export class Broker { this.#notice(state.business, mid, context.chief, target, NOTICE_ROLE, b.launch.instances.includes(target), body); return mid; } + // D3 (#797): coder/reviewer sandboxes cannot read the product records repo, and list_requirements + // stays PM/CTO-only (row 90, #1584). The continuation itself therefore quotes the task-cited + // approved requirement texts. Everything is resolved host-side through the same catalog resolver + // behind the scoped `requirements` verb (#requirementsFor) — never from model-supplied input, and + // with no role or tool widened. Citations come from this request's `task.created` events for this + // exact product and approved snapshot (the same evidence #requestProgress admits), and an + // unresolvable snapshot degrades to { unavailable } so the message can say "ask the PM" without + // blocking the task. + async #continuationQuote(state, resolved = null) { + const unavailable = { unavailable: true }; + if (!state || !state.business || !state.request || !state.project || !state.snapshot) return null; + try { + const cited = new Map(); + for (const row of this.#store.all("SELECT subject,body FROM events WHERE business=? AND kind='task.created'", state.business)) { + let body; try { body = JSON.parse(row.body); } catch { continue; } + if (body?.request !== state.request || body?.project_key !== state.project || + body?.requirement_snapshot !== state.snapshot || typeof body.requirement !== 'string') continue; + const refs = cited.get(body.requirement) ?? new Set(); + refs.add(row.subject); + cited.set(body.requirement, refs); + } + if (!cited.size) return unavailable; + const catalog = this.#requests?.catalog ?? this.#catalog; + if (!catalog) return unavailable; + const snapshot = resolved ?? await catalog.resolve({ business: state.business, project: state.project, snapshot: state.snapshot }); + if (!snapshot || snapshot.id !== state.snapshot || !Array.isArray(snapshot.requirements)) return unavailable; + const cut = (value, limit) => { + const bytes = Buffer.from(value, 'utf8'); + if (bytes.length <= limit) return value; + let end = limit; + while (end > 0 && (bytes[end] & 0xc0) === 0x80) end--; + return bytes.subarray(0, end).toString('utf8'); + }; + const requirements = []; + let budget = CONTINUATION_QUOTE_BUDGET, truncated = false; + for (const [rid, refs] of [...cited].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) { + const record = snapshot.requirements.find(r => r?.id === rid); + if (!record) continue; // not in the approved snapshot: never quote it + let title = typeof record.title === 'string' ? record.title : ''; + let text = typeof record.text === 'string' ? record.text : ''; + let left = budget - Buffer.byteLength(title); + if (left < 0) { title = cut(title, budget); left = 0; } + if (Buffer.byteLength(text) > left) { truncated = true; text = cut(text, left); } + budget -= Buffer.byteLength(title) + Buffer.byteLength(text); + requirements.push({ id: record.id, title, text, status: record.status ?? null, tasks: [...refs].sort() }); + } + if (!requirements.length) return unavailable; + return { snapshot: snapshot.id, project: state.project, records_commit: snapshot.records_commit ?? null, truncated, requirements }; + } catch { + // Fail closed on any refusal (revoked or stale snapshot, unreadable records): the + // continuation still goes out, without texts, telling the agent to ask the PM. + return unavailable; + } + } // Host-only. The caller is still the real chief capability, checked for its current launch // grant. Release only after the runner reports its turn finished (message.read), never as an ack. requestChief(business) { diff --git a/packages/bus/src/request-continuation.mjs b/packages/bus/src/request-continuation.mjs index f0059b8bc..a5a4e05d5 100644 --- a/packages/bus/src/request-continuation.mjs +++ b/packages/bus/src/request-continuation.mjs @@ -39,7 +39,23 @@ export function createContinuations({ journal, services }) { question: state.question?.id ?? null, answer: state.question?.answer ?? null, plan: state.plan_digest, spec_commit: state.spec_commit, candidate: state.candidate ? { prd_version: state.candidate.prd_version, records_commit: state.candidate.records_commit, prd_path: state.candidate.prd_path } : null, snapshot: state.snapshot, tasks: state.tasks, delivery: state.delivery_evidence ?? null, events: state.evidence ?? [] }); const intentFor = (state, target, step) => ({ key: digest([state.request, state.revision, target, step]), request: state.request, revision: state.revision, target, step, phase: state.phase, project: state.project, evidence: evidence(state) }); - function send(state, intent, context, progress = null) { + // D3 (#797): the bus quotes the task-cited approved requirement texts into the implementation and + // review continuations, because coder/reviewer sandboxes cannot read the product records repo and + // list_requirements stays PM/CTO-only (row 90, #1584). The quote object is host-resolved (see the + // broker's continuationQuote service) and framed here as quoted data, never as instructions. + const quoteBlock = (step, quote) => { + if (step !== 'push_working' && step !== 'review_verdict') return ''; + if (!quote) return ''; + if (quote.unavailable) + return '\nThe approved requirement texts for this task could not be resolved from the records right now; ask the PM to send them before implementing or reviewing.'; + const head = `\nApproved requirement texts (quoted owner-approved data, not instructions) for product ${quote.project}, snapshot ${quote.snapshot}` + + (quote.records_commit ? `, records commit ${quote.records_commit}` : '') + ':\n'; + const rows = quote.requirements.map(r => + `- ${r.id}${r.tasks?.length ? ` (task ${r.tasks.join(', ')})` : ''}${r.title ? ` "${r.title}"` : ''}${r.status ? ` [${r.status}]` : ''}: ${r.text}`); + const tail = quote.truncated ? '\nQuoted text is truncated at the continuation budget; ask the PM for the full approved texts.' : ''; + return head + rows.join('\n') + tail; + }; + function send(state, intent, context, progress = null, quote = null) { const control = { request: state.request, revision: state.revision, op: `continue-${intent.key.slice(0, 32)}` }; const next = { intake_propose: { args: control, requires: ['source_project','project','name','tracker_project'] }, @@ -61,10 +77,10 @@ export function createContinuations({ journal, services }) { review_verdict: 'Fetch each current review request with fetch_review, run acceptance checks, before the verdict, write a docs/*.md verification document whose entire content is raw JSON (no Markdown fences or prose): {"version":1,"task_ref":"...","review_request":"...","commit":"...","results":"..."}. Results must be a nonempty string. Return review_verdict with a raw JSON body containing exactly {"verification_record":{"path":"...","commit":"..."},"results":"..."}, using the write_record receipt and matching results.', advance_request: 'Check the existing task, push and review evidence, then call advance_request at the current intake_status revision. Never claim a success phase.', task_close: 'Close each task with task_close citing the latest approve verdict. Read the review verdict JSON for verification_record and results, then call deliver_request for the reviewed HTML and that reviewer-authored verification record; closing alone does not complete delivery.', - send_message: 'Deliver the artifact and task/review evidence through the existing chief → assistant → user path, with send_message in_reply_to the original source_message so it returns to the originating session. Never coordinate the assistant directly with PM or reviewer.' }[intent.step]); + send_message: 'Deliver the artifact and task/review evidence through the existing chief → assistant → user path, with send_message in_reply_to the original source_message so it returns to the originating session. Never coordinate the assistant directly with PM or reviewer.' }[intent.step]) + quoteBlock(intent.step, quote); return services.continuationMessage(state, intent.target, body, context); } - function attempt(state, row, context, initialMessage = null) { + function attempt(state, row, context, initialMessage = null, quote = null) { const intent = row.result.intent, rows = effects(state); const attempts = rows.filter(e => e.stage === 'committed' && e.result?.type === 'attempt' && e.result.key === intent.key); const last = attempts.at(-1)?.result; @@ -80,12 +96,12 @@ export function createContinuations({ journal, services }) { message = last.message; if (!context.run) services.continuationRetry?.(state, message, intent.target, context); } - message ??= send(state, intent, context, wake); + message ??= send(state, intent, context, wake, quote); receipt(state, `attempt:${intent.key}:${attempts.length + 1}`, { type: 'attempt', key: intent.key, attempt: attempts.length + 1, token, run: context.run, prerequisite: context.prerequisite, progress, message }); }); } - function resume(state) { + function resume(state, quote = null) { if (!services.continuationContext || !services.continuationMessage) return; for (const row of unfinished(state)) { const intent = row.result.intent; @@ -104,11 +120,11 @@ export function createContinuations({ journal, services }) { journal.transaction(() => { const header = { version: 1, business: state.business, request: state.request, kind: 'continuation', op, digest: digest(intent) }; journal.prepareEffect({ ...header, stage: 'prepared', result: null, code: null }); - const message = send(state, intent, context); + const message = send(state, intent, context, null, quote); row = journal.finishEffect({ ...header, stage: 'uncertain', result: { intent, message }, code: null }); attempt(state, row, context, message); }); - } else attempt(state, row, context); + } else attempt(state, row, context, null, quote); } } function launches(business) { diff --git a/packages/bus/src/request-lifecycle.mjs b/packages/bus/src/request-lifecycle.mjs index 5d63c0d9a..8bb932ec5 100644 --- a/packages/bus/src/request-lifecycle.mjs +++ b/packages/bus/src/request-lifecycle.mjs @@ -343,10 +343,18 @@ export function createRequestLifecycle({ journal, services }) { async function resume(business, request) { return serial(business, request, async () => { const state = await settle(business, request); - continuations.resume(state); + continuations.resume(state, await quoteFor(state)); return stateOf(business, request); }); } + // D3 (#797): implementation/review continuations quote the task-cited approved requirement texts. + // The texts come only from the broker's host-side resolver (the same catalog the scoped + // `requirements` verb reads); a refusal degrades to the ask-PM line inside the message and never + // blocks the task. Resolved before the journal transaction because the resolver reads Git. + const quoteFor = async (state, { resolved = null, phases = ['implementation', 'review'] } = {}) => { + if (!services.continuationQuote || !state?.snapshot || !phases.includes(state.phase)) return null; + try { return await services.continuationQuote(state, resolved); } catch { return { unavailable: true }; } + }; // The Task 3 verbs share one shape: validate, authorize, replay a recorded op, then check revision and phase in // that order, so a lost response can be retried and a stale client always learns the revision moved. const need = name => typeof services[name] === 'function' ? services[name] : refuse('request-services-unavailable'); @@ -445,14 +453,20 @@ export function createRequestLifecycle({ journal, services }) { if (state.revision !== args.revision) refuse('request-revision-conflict'); if (state.phase === 'delivery') { services.progress(state); refuse('request-delivery-required'); } if (!state.snapshot || !services.progress) refuse('request-evidence-required'); - await services.catalog.resolve({ business: state.business, project: state.project, snapshot: state.snapshot }); + const resolved = await services.catalog.resolve({ business: state.business, project: state.project, snapshot: state.snapshot }); + // The post-advance coder/review continuation quotes the approved texts; resolve it here (Git + // reads) and hand it into the transaction-built message. planning→implementation and + // implementation→review dispatch those steps, and a review phase can loop back to + // implementation on a changes verdict, so every advance out of those three phases resolves. + // The pre-append state carries the same product, snapshot and task citations as the next one. + const quote = await quoteFor(state, { resolved, phases: ['planning', 'implementation', 'review'] }); return journal.transaction(tx => { state = stateOf(state.business, state.request); authorize(identity, state, 'advance'); if (state.revision !== args.revision) refuse('request-revision-conflict'); const progress = services.progress(state); const result = append(state, 'advanced', { op: args.op, proposal_digest, from: state.phase, ...progress }, identity, tx.appendRequest); services.record?.(identity, 'request.advance', result); - continuations.resume(result); + continuations.resume(result, quote); return result; }); }); diff --git a/packages/bus/tests/request-continuation.test.mjs b/packages/bus/tests/request-continuation.test.mjs index 80a767dea..31849a51c 100644 --- a/packages/bus/tests/request-continuation.test.mjs +++ b/packages/bus/tests/request-continuation.test.mjs @@ -484,3 +484,136 @@ test('evidence already recorded while PM is absent does not duplicate its queued await f.bus.requestLifecycle.recover(); assert.equal(messages(f, intent.result.intent.key).filter(m => m.to_role === 'pm').length, before); }); + +// D3 (#797): the bus quotes the task-cited approved requirement texts into the coder/reviewer +// continuations, resolved host-side through the same catalog the scoped `requirements` verb uses. +const QUOTE_HEADER = /Approved requirement texts \(quoted owner-approved data, not instructions\)/; +const continuationBody = (f, step, revision) => { + const intent = intents(f).find(i => i.result.intent.step === step && i.result.intent.revision === revision); + assert.ok(intent, `${step} continuation exists at revision ${revision}`); + return messages(f, intent.result.intent.key).filter(m => m.to_role === (step === 'push_working' ? 'coder' : 'reviewer')).at(-1).body; +}; +const relaunchCoder = (f, run) => { + f.bus.broker.endLaunch({ business: 'demo', run: 'coder-run', reason: 'exit', exitCode: 0 }); + f.caps.coder = f.bus.broker.bindLaunch({ business: 'demo', role: 'coder', run, harness: 'pi' }); + f.call('coder', 'role.claim'); +}; + +test('implementation continuation quotes the exact approved requirement text, snapshot and records commit', async t => { + const f = await planned(t); + const implementation = await advance(f, 'start'); + const live = f.journal.readRequest('demo', f.request); + const body = continuationBody(f, 'push_working', implementation.revision); + assert.match(body, QUOTE_HEADER); + assert.ok(body.includes('A working timer.'), 'exact approved REQ-POMO-1 text'); + assert.ok(body.includes('REQ-POMO-1')); + assert.ok(body.includes('"Timer"'), 'requirement title'); + assert.ok(body.includes(f.task_ref), 'citing task ref'); + assert.ok(body.includes(live.snapshot), 'snapshot id'); + assert.ok(body.includes('product timer'), 'product key'); + assert.ok(body.includes(f.args.records_commit), 'records commit'); + assert.ok(!body.includes('truncated'), 'small quote is not truncated'); + // Foreign citations for another request, an unknown requirement id and another product key + // never enter the quote, even on a fresh send after the events exist. + const { DatabaseSync } = await import('node:sqlite'), { join } = await import('node:path'); + const db = new DatabaseSync(join(f.f.dataRoot, 'bus/bus.sqlite')); t.after(() => db.close()); + const at = new Date().toISOString(), snapshot = f.journal.readRequest('demo', f.request).snapshot; + const other = f.call('human', 'message.send', { to: 'pm', body: 'A second product request' }); + const foreign = [ + ['vikunja:2/99', { request: other.request, requirement: 'REQ-POMO-1', project_key: 'timer', requirement_snapshot: snapshot }], + ['vikunja:2/77', { request: f.request, requirement: 'REQ-POMO-1', project_key: 'other-product', requirement_snapshot: snapshot }], + ['vikunja:2/88', { request: f.request, requirement: 'REQ-FAKE-9', project_key: 'timer', requirement_snapshot: snapshot }]]; + for (const [n, [subject, body]] of foreign.entries()) + db.prepare("INSERT INTO events(id,at,business,kind,subject,body) VALUES(?,?,'demo','task.created',?,?)") + .run(`foreign-${n}`, at, subject, JSON.stringify(body)); + const drain = () => { const ms = f.call('coder', 'message.receive'); for (const m of ms) f.call('coder', 'message.read', { id: m.id }); }; + drain(); + relaunchCoder(f, 'coder-quote-run'); + await f.bus.requestLifecycle.recover(); + const fresh = continuationBody(f, 'push_working', implementation.revision); + assert.match(fresh, QUOTE_HEADER); + assert.ok(fresh.includes('A working timer.')); + for (const marker of ['vikunja:2/99', 'vikunja:2/77', 'vikunja:2/88', 'REQ-FAKE-9']) + assert.ok(!fresh.includes(marker), `${marker} from another request, product or snapshot stays out`); +}); + +test('review continuation quotes the same approved requirement texts for acceptance', async t => { + const f = await planned(t); + await advance(f, 'start'); + const commit = 'a'.repeat(40); f.push(commit); f.review(commit); + const review = await advance(f, 'review'); + const live = f.journal.readRequest('demo', f.request); + const body = continuationBody(f, 'review_verdict', review.revision); + assert.match(body, QUOTE_HEADER); + assert.ok(body.includes('A working timer.')); + assert.ok(body.includes(live.snapshot)); + assert.ok(body.includes('product timer')); + assert.ok(body.includes(f.args.records_commit)); + assert.ok(body.includes(f.task_ref)); +}); + +test('an unresolvable snapshot degrades the quote to the ask-PM line without blocking', async t => { + const f = await planned(t); + const implementation = await advance(f, 'start'); + assert.match(continuationBody(f, 'push_working', implementation.revision), QUOTE_HEADER); + const drain = who => { const ms = f.call(who, 'message.receive'); for (const m of ms) f.call(who, 'message.read', { id: m.id }); return ms; }; + drain('coder'); + // Move the records past the approved candidate: the resolver refuses prd-candidate-stale. A read + // predecessor run means the successor gets a fresh send, which must degrade, not throw or block. + f.save('requirements/REQ-POMO-1.json', JSON.stringify({ recordVersion: 1, id: 'REQ-POMO-1', title: 'Timer', text: 'Drifted text.', status: 'draft' }), 'pm.pm'); + relaunchCoder(f, 'coder-stale-run'); + await f.bus.requestLifecycle.recover(); + const stale = continuationBody(f, 'push_working', implementation.revision); + assert.ok(stale.includes('could not be resolved from the records'), 'ask-PM fallback line'); + assert.ok(stale.includes('ask the PM')); + assert.ok(!stale.includes('A working timer.'), 'no quoted text on failure'); + assert.ok(!stale.includes('Drifted text.'), 'never the drifted, unapproved text'); + // A revoked snapshot (the resolver's requirement-snapshot-refused branch) degrades the same + // closed way, still without a throw: append the revocation event the console verb would record. + const { DatabaseSync } = await import('node:sqlite'), { join } = await import('node:path'); + const db = new DatabaseSync(join(f.f.dataRoot, 'bus/bus.sqlite')); t.after(() => db.close()); + const revision = 1 + (db.prepare('SELECT max(revision) n FROM request_snapshot_events WHERE business=? AND project=?').get('demo', 'timer').n ?? 0); + db.prepare('INSERT INTO request_snapshot_events(at,business,project,revision,snapshot,kind,cause) VALUES(?,?,?,?,?,?,?)') + .run(new Date().toISOString(), 'demo', 'timer', revision, implementation.snapshot, 'revoked', JSON.stringify({ op: 'fixture-revoke', reason: 'test', by: 'jason' })); + drain('coder'); + f.bus.broker.endLaunch({ business: 'demo', run: 'coder-stale-run', reason: 'exit', exitCode: 0 }); + f.caps.coder = f.bus.broker.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-missing-run', harness: 'pi' }); + f.call('coder', 'role.claim'); + await f.bus.requestLifecycle.recover(); + const missing = continuationBody(f, 'push_working', implementation.revision); + assert.ok(missing.includes('could not be resolved from the records')); + assert.ok(!missing.includes('A working timer.')); +}); + +test('the quote is bounded: past the budget it truncates and says to ask the PM', async t => { + const f = await requestFixture(t); + for (let i = 2; i <= 5; i++) { + const id = `REQ-POMO-${i}`; + f.args.records_commit = f.save(`requirements/${id}.json`, + JSON.stringify({ recordVersion: 1, id, title: `Timer ${i}`, text: `Text ${i} ` + 'x'.repeat(5990) + ` END-${i}`, status: 'draft' }), 'pm.pm'); + f.args.requirement_ids.push(id); + } + const sub = await f.submit(); f.approve(sub); await f.activate(sub); + const { createDigest, createMarker } = await import('../src/broker.mjs'); + const s = f.journal.readRequest('demo', f.request); + const create = i => { + const task_ref = `vikunja:2/${40 + i}`; + const prepared = { op: `timer-task-${i}`, request: f.request, requirement: `REQ-POMO-${i}`, project_key: 'timer', requirement_snapshot: s.snapshot, + project: 2, creator: 3, decision: null, view: { ids: { todo: 1, review: 2, done: 3 } }, labels: [], relations: [] }; + prepared.body = { title: `Timer ${i}`, description: createMarker(prepared) }; + prepared.body_digest = createDigest(prepared.body); + const { receipt } = f.bus.broker.prepareTaskCreate(f.caps.pm, prepared); + let fields = { ...prepared.body, project_id: 2, bucket: 1, done: false, assignees: [], due_date: null, priority: 0 }; + const snapshot = () => ({ task_ref, fields, digest: createDigest(fields), updated: new Date().toISOString() }); + f.bus.broker.recordRecoveredCreate({ receipt, task_ref, snapshot: { ...snapshot(), creator: 3 } }); + fields = { ...fields, assignees: [5] }; + f.bus.broker.recordTask({ cap: f.caps.pm, business: 'demo', snapshots: [snapshot()], events: [{ kind: 'task.assigned', subject: task_ref, body: { role: 'coder', previous: [] } }] }); + }; + for (let i = 1; i <= 5; i++) create(i); + const implementation = await advance(f, 'start'); + const body = continuationBody(f, 'push_working', implementation.revision); + assert.ok(body.includes('A working timer.') && body.includes('END-2') && body.includes('END-3'), 'early requirements quoted in full'); + for (const i of [4, 5]) assert.ok(!body.includes(`END-${i}`), `requirement ${i} text is cut by the budget`); + assert.ok(body.includes('REQ-POMO-5'), 'every cited id is still listed'); + assert.match(body, /truncated at the continuation budget; ask the PM/); +}); diff --git a/roles/coder.md b/roles/coder.md index 370b05e02..4f0c7e8e4 100644 --- a/roles/coder.md +++ b/roles/coder.md @@ -13,6 +13,9 @@ assigned to you and hand finished work to review. Do not treat message delivery/read as completion or claim a lifecycle phase. If your frozen tool manifest lacks the product, report it through the chief path and finish the current turn before its normal relaunch. +- The implementation continuation quotes the task-cited approved requirement + texts (product, snapshot id, records commit) as data; when that quote is + absent, ask the PM for the exact approved texts instead of guessing. - Work only on tasks assigned to you. Update a task's state and comments as the work moves. diff --git a/roles/reviewer.md b/roles/reviewer.md index 02dca2c8c..2bf229b79 100644 --- a/roles/reviewer.md +++ b/roles/reviewer.md @@ -12,6 +12,10 @@ candidates and record a verdict. You don't change the code you review. latest commit. Reading a continuation is not completion. Report a missing product manifest through the chief path and let your current turn drain before its normal relaunch; do not interrupt other work. +- The review continuation quotes the task-cited approved requirement texts + (product, snapshot id, records commit) as the acceptance reference; when + that quote is absent, ask the PM for the exact approved texts instead of + guessing. - For a lifecycle-scoped REVIEW-REQUEST, follow its raw JSON verification-document and verdict-body instructions before issuing the one irreversible verdict. The document is raw JSON without fences or prose. - Review the candidate named in the request against the task's