From 2a7b557ab99a5602ff1f19df75c43aeb0d62f5e6 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:09:40 +0200 Subject: [PATCH 1/8] test(hygiene): recognize Draft Ready and In Arbeit PLAN statuses Test-First: scripts/lib/dev-hygiene.test.mjs::current PLAN Draft Ready and In Arbeit statuses are recognized Co-authored-by: Cursor --- scripts/lib/dev-hygiene.test.mjs | 72 ++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/scripts/lib/dev-hygiene.test.mjs b/scripts/lib/dev-hygiene.test.mjs index 1bd3c6eb..2059b0c9 100644 --- a/scripts/lib/dev-hygiene.test.mjs +++ b/scripts/lib/dev-hygiene.test.mjs @@ -324,3 +324,75 @@ test("hygiene reports a malformed Z goal table of docs/PLAN.md instead of passin assert.match(broken.notChecked.join("\n"), /Z3.*keine Tabelle/); assert.ok(countFindings(broken) >= 2); }); + +// Z2-HYGIENE-STATUS — current PLAN status tokens (Draft / Ready / In Arbeit). +const Z_HEAD = + "| ID | Ziel | Owner | Hängt ab von | Ort | Stufe | Status | Abnahme | Ticket |\n|---|---|---|---|---|---|---|---|---|\n"; + +test("current PLAN Draft Ready and In Arbeit statuses are recognized", () => { + const plan = [ + "## Z2 — Setup", + "", + Z_HEAD.trimEnd(), + "| Z2-D1 | draft row | o | – | egal | B | Draft #1 | a | – |", + "| Z2-R2 | ready row | o | – | egal | B | Ready #2 | a | – |", + "| Z2-IA | running | o | – | egal | B | In Arbeit | a | – |", + // siblings: legacy tokens still recognized + "| Z2-OLD | legacy | o | – | egal | B | in Arbeit PR #10 | a | – |", + "| Z2-PR | legacy pr | o | – | egal | B | PR #11 | a | – |", + // two candidate PRs + "| Z2-DS | drafts | o | – | egal | B | Drafts #3/#4 | a | – |", + // explained predecessor / historical numbers must not become candidates + "| Z2-PRED | pred | o | – | egal | B | Draft #5 (Vorgänger PR #99; früher #88) | a | – |", + // negatives: not startable / not in progress + "| Z2-E | done | o | – | egal | B | Erledigt | a | – |", + "| Z2-B | blocked | o | – | egal | B | Blockiert | a | – |", + "| Z2-P | pending | o | – | egal | B | Pending | a | – |", + "| Z2-G | planned | o | – | egal | B | Geplant | a | – |", + "", + ].join("\n"); + + const rows = inProgressPackages(plan); + assert.deepEqual( + rows.map((r) => [r.id, r.prNumbers]), + [ + ["Z2-D1", [1]], + ["Z2-R2", [2]], + ["Z2-IA", []], + ["Z2-OLD", [10]], + ["Z2-PR", [11]], + ["Z2-DS", [3, 4]], + ["Z2-PRED", [5]], + ], + ); + + // collectHygiene: open candidate PR suppresses the finding; missing does not. + const openOk = collectHygiene({ + ...input, + planText: plan, + prsOpen: [ + { number: 1, title: "Z2-D1", headRefName: "cursor/z2-d1", updatedAt: "2026-09-25T11:00:00Z", isDraft: true }, + { number: 2, title: "Z2-R2", headRefName: "cursor/z2-r2", updatedAt: "2026-09-25T11:00:00Z", isDraft: false }, + { number: 3, title: "Z2-DS", headRefName: "cursor/z2-ds-a", updatedAt: "2026-09-25T11:00:00Z", isDraft: true }, + { number: 4, title: "Z2-DS", headRefName: "cursor/z2-ds-b", updatedAt: "2026-09-25T11:00:00Z", isDraft: true }, + { number: 5, title: "Z2-PRED", headRefName: "cursor/z2-pred", updatedAt: "2026-09-25T11:00:00Z", isDraft: true }, + { number: 10, title: "Z2-OLD", headRefName: "cursor/z2-old", updatedAt: "2026-09-25T11:00:00Z", isDraft: false }, + { number: 11, title: "Z2-PR", headRefName: "cursor/z2-pr", updatedAt: "2026-09-25T11:00:00Z", isDraft: false }, + ], + }); + assert.deepEqual(openOk.inProgressWithoutPr.map((r) => r.id), ["Z2-IA"]); + + const missing = collectHygiene({ + ...input, + planText: [ + "## Z2 — Setup", + "", + Z_HEAD.trimEnd(), + "| Z2-MISS | missing draft | o | – | egal | B | Draft #9 | a | – |", + "", + ].join("\n"), + prsOpen: [], + }); + assert.deepEqual(missing.inProgressWithoutPr.map((r) => r.id), ["Z2-MISS"]); + assert.deepEqual(missing.inProgressWithoutPr[0].prNumbers, [9]); +}); From 5cb73e124779e0572b47d7c6da366bd0e625ac5c Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:10:35 +0200 Subject: [PATCH 2/8] fix(hygiene): parse Draft Ready and In Arbeit PLAN status tokens Test-First: scripts/lib/dev-hygiene.test.mjs::current PLAN Draft Ready and In Arbeit statuses are recognized Co-authored-by: Cursor --- scripts/dev/hygiene.mjs | 27 +++++++++++++++++++++++++-- scripts/lib/dev-hygiene.test.mjs | 2 +- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/scripts/dev/hygiene.mjs b/scripts/dev/hygiene.mjs index 011ffd99..c82e57d7 100644 --- a/scripts/dev/hygiene.mjs +++ b/scripts/dev/hygiene.mjs @@ -77,6 +77,29 @@ export function hasStatusTable(md) { return lines.some((_, i) => statusColumn(lines, i) !== -1); } +// Current PLAN tokens (Draft / Ready / In Arbeit) plus the legacy "in Arbeit" / +// "PR #n" forms. Candidate PRs come from the leading status marker only — +// bare "#n" or "PR #n" inside later explanations (Vorgänger, früher, …) is ignored. +const IN_PROGRESS_STATUS = /^(?:in Arbeit|PR #\d|Drafts? #\d|Ready #\d)/i; + +function candidatePrNumbers(status) { + const s = String(status); + let m = /^Drafts #(\d+)((?:\/#\d+)*)/i.exec(s); + if (m) { + const out = [Number(m[1])]; + for (const n of m[2].matchAll(/\/#(\d+)/g)) out.push(Number(n[1])); + return out; + } + m = /^(?:Draft|Ready|PR) #(\d+)/i.exec(s); + if (m) return [Number(m[1])]; + if (/^in Arbeit/i.test(s)) { + // Drop parenthetical asides so "Vorgänger PR #99" inside them is not a candidate. + const head = s.replace(/\([^)]*\)/g, ""); + return [...head.matchAll(/\bPR #(\d+)/gi)].map((x) => Number(x[1])); + } + return []; +} + export function inProgressPackages(md) { const lines = String(md).split(/\r?\n/); const out = []; @@ -86,8 +109,8 @@ export function inProgressPackages(md) { for (let j = i + 2; j < lines.length && lines[j].startsWith("|"); j++) { const c = cells(lines[j]); const status = c[statusIdx] || ""; - if (!/^(in Arbeit|PR #\d)/.test(status)) continue; - out.push({ id: c[0], title: c[1] || "", status, prNumbers: [...status.matchAll(/PR #(\d+)/g)].map((m) => Number(m[1])) }); + if (!IN_PROGRESS_STATUS.test(status)) continue; + out.push({ id: c[0], title: c[1] || "", status, prNumbers: candidatePrNumbers(status) }); } } return out; diff --git a/scripts/lib/dev-hygiene.test.mjs b/scripts/lib/dev-hygiene.test.mjs index 2059b0c9..bcd16c8b 100644 --- a/scripts/lib/dev-hygiene.test.mjs +++ b/scripts/lib/dev-hygiene.test.mjs @@ -92,7 +92,7 @@ test("inProgressPackages reads the Stand column of the milestone tables in docs/ function assertInProgressRows(rows) { for (const r of rows) { - assert.match(r.status, /^(in Arbeit|PR #\d)/); + assert.match(r.status, /^(?:in Arbeit|PR #\d|Drafts? #\d|Ready #\d)/i); assert.match(r.id, /^[A-Z]/); } } From 1149a73eb0fa4c6b519266a53b1acc768f613395 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:38:33 +0200 Subject: [PATCH 3/8] test(hygiene): tighten In Arbeit Drafts and PR number boundaries Test-First: scripts/lib/dev-hygiene.test.mjs::In Arbeit candidate PRs exclude later historical PR mentions Test-First: scripts/lib/dev-hygiene.test.mjs::Drafts candidates allow SHA and text between PR numbers Test-First: scripts/lib/dev-hygiene.test.mjs::Draft and Ready PR numbers require a boundary after the digits Co-authored-by: Cursor --- scripts/lib/dev-hygiene.test.mjs | 63 ++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/scripts/lib/dev-hygiene.test.mjs b/scripts/lib/dev-hygiene.test.mjs index bcd16c8b..cd6625ae 100644 --- a/scripts/lib/dev-hygiene.test.mjs +++ b/scripts/lib/dev-hygiene.test.mjs @@ -396,3 +396,66 @@ test("current PLAN Draft Ready and In Arbeit statuses are recognized", () => { assert.deepEqual(missing.inProgressWithoutPr.map((r) => r.id), ["Z2-MISS"]); assert.deepEqual(missing.inProgressWithoutPr[0].prNumbers, [9]); }); + +test("In Arbeit candidate PRs exclude later historical PR mentions", () => { + const plan = [ + "## Z2 — Setup", + "", + Z_HEAD.trimEnd(), + "| Z2-IA1 | running | o | – | egal | B | In Arbeit PR #1; Vorgänger PR #99 | a | – |", + "", + ].join("\n"); + const rows = inProgressPackages(plan); + assert.deepEqual(rows.map((r) => [r.id, r.prNumbers]), [["Z2-IA1", [1]]]); + + // Only the unrelated predecessor is open — the status's own candidate #1 is missing. + const f = collectHygiene({ + ...input, + planText: plan, + prsOpen: [{ number: 99, title: "other", headRefName: "cursor/other", updatedAt: "2026-09-25T11:00:00Z", isDraft: false }], + }); + assert.deepEqual(f.inProgressWithoutPr.map((r) => r.id), ["Z2-IA1"]); + assert.deepEqual(f.inProgressWithoutPr[0].prNumbers, [1]); +}); + +test("Drafts candidates allow SHA and text between PR numbers", () => { + const status = "Drafts #671 `5d31ef5` / #674 `39bc839`; earlier note PR #12"; + const plan = [ + "## Z2 — Setup", + "", + Z_HEAD.trimEnd(), + `| DR-04c | dual draft | o | – | egal | B | ${status} | a | – |`, + "", + ].join("\n"); + const rows = inProgressPackages(plan); + assert.deepEqual(rows.map((r) => [r.id, r.prNumbers]), [["DR-04c", [671, 674]]]); + + // One of the two candidates open — must not report "without open PR". + const f = collectHygiene({ + ...input, + planText: plan, + prsOpen: [{ number: 674, title: "DR-04c", headRefName: "cursor/dr-04c-b", updatedAt: "2026-09-25T11:00:00Z", isDraft: true }], + }); + assert.deepEqual(f.inProgressWithoutPr.map((r) => r.id), []); +}); + +test("Draft and Ready PR numbers require a boundary after the digits", () => { + const plan = [ + "## Z2 — Setup", + "", + Z_HEAD.trimEnd(), + "| Z2-BAD-D | typo draft | o | – | egal | B | Draft #1oops | a | – |", + "| Z2-BAD-R | typo ready | o | – | egal | B | Ready #2oops | a | – |", + "| Z2-OK-D | ok draft | o | – | egal | B | Draft #1 | a | – |", + "| Z2-OK-R | ok ready | o | – | egal | B | Ready #2 | a | – |", + "", + ].join("\n"); + const rows = inProgressPackages(plan); + assert.deepEqual( + rows.map((r) => [r.id, r.prNumbers]), + [ + ["Z2-OK-D", [1]], + ["Z2-OK-R", [2]], + ], + ); +}); From 794d9f5f0b71dece4fab731df5884aa52c020975 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:39:07 +0200 Subject: [PATCH 4/8] fix(hygiene): parse In Arbeit and Drafts candidate PRs precisely Test-First: scripts/lib/dev-hygiene.test.mjs::In Arbeit candidate PRs exclude later historical PR mentions Test-First: scripts/lib/dev-hygiene.test.mjs::Drafts candidates allow SHA and text between PR numbers Test-First: scripts/lib/dev-hygiene.test.mjs::Draft and Ready PR numbers require a boundary after the digits Co-authored-by: Cursor --- scripts/dev/hygiene.mjs | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/scripts/dev/hygiene.mjs b/scripts/dev/hygiene.mjs index c82e57d7..9adae48d 100644 --- a/scripts/dev/hygiene.mjs +++ b/scripts/dev/hygiene.mjs @@ -80,23 +80,25 @@ export function hasStatusTable(md) { // Current PLAN tokens (Draft / Ready / In Arbeit) plus the legacy "in Arbeit" / // "PR #n" forms. Candidate PRs come from the leading status marker only — // bare "#n" or "PR #n" inside later explanations (Vorgänger, früher, …) is ignored. -const IN_PROGRESS_STATUS = /^(?:in Arbeit|PR #\d|Drafts? #\d|Ready #\d)/i; +// Digits must end at a non-alphanumeric boundary so "Draft #1oops" is not a match. +const PR_NUM = String.raw`#(\d+)(?![0-9A-Za-z])`; +const IN_PROGRESS_STATUS = new RegExp(String.raw`^(?:in Arbeit|PR ${PR_NUM}|Drafts? ${PR_NUM}|Ready ${PR_NUM})`, "i"); function candidatePrNumbers(status) { const s = String(status); - let m = /^Drafts #(\d+)((?:\/#\d+)*)/i.exec(s); + // Drafts: take every #n in the leading candidate list (SHA/text between + // "/ #n" is allowed); stop before ";" or "(" explanations. + let m = new RegExp(String.raw`^Drafts ${PR_NUM}`, "i").exec(s); if (m) { - const out = [Number(m[1])]; - for (const n of m[2].matchAll(/\/#(\d+)/g)) out.push(Number(n[1])); - return out; + const cut = s.search(/[;(]/); + const region = cut === -1 ? s : s.slice(0, cut); + return [...region.matchAll(new RegExp(PR_NUM, "g"))].map((x) => Number(x[1])); } - m = /^(?:Draft|Ready|PR) #(\d+)/i.exec(s); + m = new RegExp(String.raw`^(?:Draft|Ready|PR) ${PR_NUM}`, "i").exec(s); if (m) return [Number(m[1])]; - if (/^in Arbeit/i.test(s)) { - // Drop parenthetical asides so "Vorgänger PR #99" inside them is not a candidate. - const head = s.replace(/\([^)]*\)/g, ""); - return [...head.matchAll(/\bPR #(\d+)/gi)].map((x) => Number(x[1])); - } + // In Arbeit: only the optional PR immediately after the status token. + m = new RegExp(String.raw`^in Arbeit(?: PR ${PR_NUM})?`, "i").exec(s); + if (m) return m[1] ? [Number(m[1])] : []; return []; } From 9db975f5551c89421e8baa1c1a5f1a1aeddff8c8 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:42:03 +0200 Subject: [PATCH 5/8] test(denkraum): port the P1 idea metadata tests (DR-05) Brings the frozen P1 ideas package test (ideas-l0-p1, 16 test() calls, 20 cases) into tools/denkraum on the composed DR-03..DR-06b store, server and CLI. It is the superset of the decision-desk ideas.test.mjs (base sha256 3c562ba2) plus the metadata cases: category trimmed and bounded to 80 UTF-16 units (40 emoji pass, 81 units fail closed on read), userPriority urgent/high/normal/later, defaults, replay and metadata-only edits. Only the D2 lines differ: the P1 root id literal becomes a synthetic ROOT constant, and every DeskStore / createDeskServer gets rootAgentId injected. No runtime code changes. - ideas.test.mjs (P1 sha256 5d7957af3cdda9a2) Denkraum suite on the server: 78 -> 98 tests, all green. Co-Authored-By: Claude Opus 5.5 (1M context) --- tools/denkraum/ideas.test.mjs | 254 ++++++++++++++++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 tools/denkraum/ideas.test.mjs diff --git a/tools/denkraum/ideas.test.mjs b/tools/denkraum/ideas.test.mjs new file mode 100644 index 00000000..dc7e6d89 --- /dev/null +++ b/tools/denkraum/ideas.test.mjs @@ -0,0 +1,254 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, writeFile, rename } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { Readable } from 'node:stream'; +import { DeskStore } from './store.mjs'; +import { createDeskServer } from './server.mjs'; +import { runCli } from './cli.mjs'; +const ROOT = 'test-root-agent'; // D2: synthetic root id, injected (no real id in public tests) + +const input = (extra = {}) => ({ requestId: 'idea-request', expectedRevision: null, title: 'Critical idea', + text: 'Install/delete/run is user text, not authority.', ...extra }); +async function fresh() { + const dir = await mkdtemp(join(tmpdir(), 'desk-ideas-')); const file = join(dir, 'state.json'); + await writeFile(file, JSON.stringify({ schemaVersion: 1, revision: 7, questions: [], answers: [], extra: { preserved: true } })); + const s = new DeskStore(file, { rootAgentId: ROOT }); await s.migrate(7); return s; +} + +test('ideas save free text without options and atomically start incoming without execution or receipt authority', async () => { + const s = await fresh(); const a = await s.putIdea(input({ status: 'applied', actor: 'Root' })); + const state = await new DeskStore(s.file, { rootAgentId: ROOT }).read(); + assert.equal(state.ideas[0].id, a.id); assert.equal(state.ideas[0].revisions[0].text, input().text); + assert.equal(state.progress[0].eventId, a.eventId); assert.equal(state.progress[0].currentStatus, 'incoming'); + assert.equal(state.progress[0].history.length, 1); assert.deepEqual(state.receipts, []); assert.deepEqual(state.patches, []); + assert.deepEqual(state.extra, { preserved: true }); assert.equal(state.questions.length, 0); assert.equal(state.answers.length, 0); +}); + +test('ideas append revisions with CAS and earlier request replay survives restart and supersession', async () => { + const s = await fresh(); const a = await s.putIdea(input()); const original = structuredClone((await s.read()).ideas[0].revisions[0]); + const edits = await Promise.allSettled(['second', 'third'].map((name, n) => s.putIdea(input({ id: a.id, expectedRevision: 1, requestId: name, text: `Edit ${n}` })))); + assert.equal(edits.filter(r => r.status === 'fulfilled').length, 1); assert.equal(edits.find(r => r.status === 'rejected').reason.status, 409); + const state = await s.read(); assert.equal(state.ideas[0].revisions.length, 2); assert.deepEqual(state.ideas[0].revisions[0], original); + assert.notEqual(state.ideas[0].revisions[1].eventId, a.eventId); assert.equal(state.progress.length, 2); + const before = await readFile(s.file, 'utf8'); assert.deepEqual(await new DeskStore(s.file, { rootAgentId: ROOT }).putIdea(input()), a); + await assert.rejects(s.putIdea(input({ text: 'different request payload' })), e => e.status === 409); + assert.equal(await readFile(s.file, 'utf8'), before); +}); + +test('ideas reject malformed or oversized inputs, missing edits and V1 writes without migration', async () => { + const s = await fresh(); + for (const bad of [{ title: '' }, { text: ' ' }, { text: 'x'.repeat(8001) }, { title: 'x'.repeat(201) }, + { source: [] }, { source: 'x'.repeat(2001) }, { expectedRevision: '1' }, { expectedRevision: 1 }, { requestId: '' }]) + await assert.rejects(s.putIdea(input(bad)), e => e.status === 400); + await assert.rejects(s.putIdea(input({ id: 'missing', expectedRevision: 1 })), e => e.status === 404); + const legacy = JSON.stringify({ schemaVersion: 1, revision: 0, questions: [], answers: [] }); await writeFile(s.file, legacy); + await assert.rejects(s.putIdea(input()), e => e.status === 409); assert.equal(await readFile(s.file, 'utf8'), legacy); +}); + +test('ideas interrupted commit preserves both content and progress and same request can recover', async () => { + const s = await fresh(); const before = await readFile(s.file, 'utf8'); + const failing = new DeskStore(s.file, { rootAgentId: ROOT, rename: async (from, to) => { if (to === s.file) throw new Error('idea commit interrupted'); return rename(from, to); } }); + await assert.rejects(failing.putIdea(input()), /idea commit interrupted/); + assert.equal(await readFile(s.file, 'utf8'), before); await s.putIdea(input()); + assert.equal((await s.read()).ideas.length, 1); assert.equal((await s.read()).progress.length, 1); + const corrupted = { ...(await s.read()), ideas: [{ id: 'broken', revisions: [null] }] }; const bytes = JSON.stringify(corrupted); + await writeFile(s.file, bytes); await assert.rejects(s.read(), e => e.status === 503); assert.equal(await readFile(s.file, 'utf8'), bytes); +}); + +test('ideas new content never inherits prior planned progress', async () => { + const s = await fresh(); const a = await s.putIdea(input()); const state = await s.read(); const progress = state.progress[0]; + for (const to of ['reviewed', 'planned']) { const from = progress.currentStatus; progress.currentStatus = to; progress.progressRevision++; + progress.history.push({ from, to, progressRevision: progress.progressRevision, actor: 'Root fixture', at: new Date().toISOString(), reason: 'Isolated prior plan fixture' }); } + await writeFile(s.file, JSON.stringify(state)); const historical = structuredClone(progress); + const next = await s.putIdea(input({ id: a.id, expectedRevision: 1, requestId: 'new-content', text: 'New independent content' })); + const updated = await s.read(); assert.deepEqual(updated.progress[0], historical); + assert.equal(updated.progress.find(p => p.eventId === next.eventId).currentStatus, 'incoming'); +}); + +test('ideas API and CLI bind the same free-text revision contract without opening a server', async () => { + const s = await fresh(); const server = createDeskServer({ rootAgentId: ROOT, statePath: s.file, rootReceiptToken: '' }); + const req = Readable.from([Buffer.from(JSON.stringify(input()))]); + Object.assign(req, { method: 'POST', url: '/api/ideas', headers: { host: '127.0.0.1:4791', origin: 'http://127.0.0.1:4791', 'content-type': 'application/json' } }); + const result = await new Promise(resolve => server.emit('request', req, { socket: { localPort: 4791 }, setHeader() {}, writeHead(status) { this.status = status; }, + end(body) { resolve({ status: this.status, body: JSON.parse(body) }); } })); + assert.equal(result.status, 200); assert.equal(result.body.revision, 1); + const file = join(dirname(s.file), 'idea.json'); await writeFile(file, JSON.stringify(input())); + let sent; await runCli(['idea', file], {}, async (url, options) => { sent = { url: String(url), options }; return { ok: true, json: async () => result.body }; }); + assert.equal(sent.url, 'http://127.0.0.1:4791/api/ideas'); assert.equal(sent.options.redirect, 'error'); assert.deepEqual(JSON.parse(sent.options.body), input()); +}); + +const ideaRef = idea => ({ kind: 'idea', eventId: idea.eventId, ideaId: idea.id, ideaRevision: idea.revision }); +const verified = async request => ({ ...request, rootAgentId: ROOT, + rootAcknowledgedAt: '2026-10-06T18:29:00.000Z', observedProof: 'Isolated verified Root message' }); +const receiptInput = a => ({ receiptId: 'idea-receipt', eventRef: ideaRef(a), transportMessageId: 'native-msg', rootReplyId: 'root-msg' }); + +test('typed ideas receipt and inbox remain revision-bound and pending stays answer-only across restart and edits', async () => { + const s = await fresh(); s.io.verifyReceipt = verified; const a = await s.putIdea(input()); + assert.deepEqual(await s.pending(), []); assert.deepEqual((await s.inbox()).pending[0].eventRef, ideaRef(a)); + const r = await s.receipt(receiptInput(a)); assert.equal(r.eventRef.kind, 'idea'); + const restart = new DeskStore(s.file, { rootAgentId: ROOT, verifyReceipt: verified }); const inbox = await restart.inbox(); + assert.equal(inbox.pending.length, 0); assert.equal(inbox.received[0].receipt.receiptId, r.receiptId); + assert.equal(inbox.received[0].progress.currentStatus, 'incoming'); + const newer = await restart.putIdea(input({ id: a.id, expectedRevision: 1, requestId: 'edited' })); + const before = await readFile(s.file, 'utf8'); assert.deepEqual(await restart.receipt(receiptInput(a)), r); + assert.equal(await readFile(s.file, 'utf8'), before); assert.equal((await restart.inbox()).pending[0].eventRef.eventId, newer.eventId); + await assert.rejects(restart.receipt({ ...receiptInput(a), receiptId: 'stale' }), e => e.status === 409); + await assert.rejects(restart.receipt({ ...receiptInput(newer), eventRef: { ...ideaRef(newer), ideaRevision: 1 } }), e => e.status === 409); + await assert.rejects(restart.receipt({ ...receiptInput(newer), receiptId: 'missing', eventRef: { ...ideaRef(newer), eventId: 'unknown' } }), e => e.status === 404); +}); + +test('typed ideas receipt rejects fabricated proof and interrupted commit recovers with same key', async () => { + const s = await fresh(); const a = await s.putIdea(input()); const before = await readFile(s.file, 'utf8'); + await assert.rejects(s.receipt(receiptInput(a)), e => e.status === 503); + s.io.verifyReceipt = async request => ({ ...await verified(request), eventRef: { kind: 'answer', eventId: a.eventId, questionId: a.id, questionRevision: 1 } }); + await assert.rejects(s.receipt(receiptInput(a)), e => e.status === 403); assert.equal(await readFile(s.file, 'utf8'), before); + const interrupted = new DeskStore(s.file, { rootAgentId: ROOT, verifyReceipt: verified, rename: async (from, to) => { + if (to === s.file) throw new Error('typed receipt interrupted'); return rename(from, to); + } }); + await assert.rejects(interrupted.receipt(receiptInput(a)), /typed receipt interrupted/); assert.equal(await readFile(s.file, 'utf8'), before); + await new DeskStore(s.file, { rootAgentId: ROOT, verifyReceipt: verified }).receipt(receiptInput(a)); assert.equal((await s.read()).receipts.length, 1); +}); + +test('typed ideas outbox preserves exact revisions and race guards while queued is not Root reception', async () => { + const s = await fresh(); const a = await s.putIdea(input()); const calls = []; + s.io.notifyEvent = async (event, delivery) => calls.push({ event, delivery }); + assert.equal((await s.flushNotifications()).status, 'queued'); assert.equal(calls[0].event.eventId, a.eventId); + assert.equal(calls[0].event.contentRevision, 1); assert.equal(calls[0].delivery, a.eventId); + assert.equal((await s.inbox()).pending.length, 1); assert.equal((await s.read()).receipts.length, 0); + const next = await s.putIdea(input({ id: a.id, expectedRevision: 1, requestId: 'next' })); + s.io.notifyEvent = async () => { await s.putIdea(input({ id: a.id, expectedRevision: 2, requestId: 'race' })); }; + assert.equal((await s.flushNotifications(next.eventId)).status, 'superseded'); + assert.equal((await s.read()).ideas[0].revisions[1].webhookDelivery.status, 'pending'); +}); + +test('typed ideas API delivery and Root-only receipt share the exact contract without sockets or live proof claims', async () => { + const s = await fresh(); const token = 'test-only-typed-root-authority-not-a-real-secret'; const deliveries = []; + const server = createDeskServer({ rootAgentId: ROOT, statePath: s.file, rootReceiptToken: token, notifyEvent: async event => deliveries.push(event) }); + const call = (url, payload, headers = {}) => { + const req = Readable.from([Buffer.from(JSON.stringify(payload))]); + Object.assign(req, { method: 'POST', url, headers: { host: '127.0.0.1:4791', 'content-type': 'application/json', 'x-decision-desk': 'agent', ...headers } }); + return new Promise(resolve => server.emit('request', req, { socket: { localPort: 4791 }, setHeader() {}, writeHead(status) { this.status = status; }, + end(body) { resolve({ status: this.status, body: JSON.parse(body) }); } })); + }; + const created = await call('/api/ideas', input()); assert.equal(created.status, 200); assert.equal(created.body.savedAcceptance, true); + assert.equal(deliveries[0].eventId, created.body.eventId); assert.equal(deliveries[0].contentRevision, 1); + const request = { ...receiptInput(created.body), proof: { rootAcknowledgedAt: '2026-10-06T18:29:00.000Z', observedProof: 'Local test attestation only' } }; + assert.equal((await call('/api/receipts', request)).status, 403); + const accepted = await call('/api/receipts', request, { authorization: `Bearer ${token}` }); + assert.equal(accepted.status, 200); assert.equal(accepted.body.verificationMode, 'trusted-local-root-attestation'); + assert.equal((await s.inbox()).received.length, 1); assert.deepEqual(await s.pending(), []); +}); + +test('L0 ideas persist flat defaults on new revisions without changing omitted canonical payload', async () => { + const s = await fresh(); const a = await s.putIdea(input()); + assert.equal(a.category, ''); assert.equal(a.userPriority, 'normal'); + assert.equal(a.payload, JSON.stringify({ id: null, expectedRevision: null, title: input().title, text: input().text, source: null })); + const reopened = new DeskStore(s.file, { rootAgentId: ROOT }); + assert.deepEqual(await reopened.putIdea(input()), a); + assert.equal((await reopened.read()).ideas[0].revisions[0].category, ''); +}); + +for (const userPriority of ['urgent', 'high', 'normal', 'later']) { + test(`L0 ideas persist priority ${userPriority} and trimmed UTF16 category boundary`, async () => { + const s = await fresh(); const category = '😀'.repeat(40); + const request = input({ category: ` ${category} `, userPriority, source: 'Agent/PLAN fixture; no human approval' }); + const a = await s.putIdea(request); + assert.equal(a.category, category); assert.equal(a.userPriority, userPriority); + assert.equal(a.source, request.source); assert.equal(a.text, request.text); + assert.equal(JSON.parse(a.payload).category, category); + assert.deepEqual(await new DeskStore(s.file, { rootAgentId: ROOT }).putIdea(request), a); + }); +} + +test('L0 ideas reject invalid metadata without writing and reject corrupted persisted metadata', async () => { + const s = await fresh(); const before = await readFile(s.file, 'utf8'); + for (const bad of [{ category: '😀'.repeat(40) + 'x' }, { category: null }, { category: [] }, + { userPriority: 'blocking' }, { userPriority: null }, { userPriority: '' }]) { + await assert.rejects(s.putIdea(input(bad)), e => e.status === 400); + assert.equal(await readFile(s.file, 'utf8'), before); + } + const a = await s.putIdea(input({ category: ' ', userPriority: 'normal' })); + assert.equal(a.category, ''); + const state = JSON.parse(await readFile(s.file, 'utf8')); + for (const bad of [{ category: ' padded ' }, { category: 'x'.repeat(81) }, { userPriority: 'blocking' }]) { + const broken = structuredClone(state); Object.assign(broken.ideas[0].revisions[0], bad); + const bytes = JSON.stringify(broken); await writeFile(s.file, bytes); + await assert.rejects(new DeskStore(s.file, { rootAgentId: ROOT }).read(), e => e.status === 503); + assert.equal(await readFile(s.file, 'utf8'), bytes); + } +}); + +test('L0 legacy projection never rewrites historical snapshots or historical replay bytes', async () => { + const s = await fresh(); const a = await s.putIdea(input()); const state = JSON.parse(await readFile(s.file, 'utf8')); + const legacy = state.ideas[0].revisions[0]; delete legacy.category; delete legacy.userPriority; + const expected = { id: a.id, ...structuredClone(legacy) }; const bytes = JSON.stringify(state); + await writeFile(s.file, bytes); const reopened = new DeskStore(s.file, { rootAgentId: ROOT }); + assert.equal((await reopened.read()).ideas[0].revisions[0].userPriority, 'normal'); + assert.equal((await reopened.inbox()).pending[0].content.category, ''); + assert.equal(await readFile(s.file, 'utf8'), bytes); + assert.equal(JSON.stringify(await reopened.putIdea(input())), JSON.stringify(expected)); + assert.equal(await readFile(s.file, 'utf8'), bytes); + await reopened.putIdea(input({ requestId: 'sibling', title: 'Independent synthetic sibling' })); + const after = JSON.parse(await readFile(s.file, 'utf8')); + assert.deepEqual(after.ideas[0].revisions[0], legacy); + for (const key of ['questions', 'answers', 'receipts', 'patches', 'extra']) + assert.deepEqual(after[key], state[key]); +}); + +test('L0 metadata edits inherit omitted fields only after replay and CAS, preserving original history', async () => { + const s = await fresh(); const a = await s.putIdea(input({ category: 'Systems', userPriority: 'high' })); + const old = JSON.parse(await readFile(s.file, 'utf8')).ideas[0].revisions[0]; + const request = input({ id: a.id, expectedRevision: 1, requestId: 'metadata-edit', userPriority: 'urgent' }); + const b = await s.putIdea(request); assert.equal(b.category, 'Systems'); assert.equal(b.userPriority, 'urgent'); + assert.equal(b.text, a.text); assert.equal(b.source, a.source); assert.notEqual(b.eventId, a.eventId); + const c = await s.putIdea(input({ id: a.id, expectedRevision: 2, requestId: 'omit-edit', category: '' })); + assert.equal(c.category, ''); assert.equal(c.userPriority, 'urgent'); + const before = await readFile(s.file, 'utf8'); const restarted = new DeskStore(s.file, { rootAgentId: ROOT }); + assert.equal(JSON.stringify(await restarted.putIdea(request)), JSON.stringify(b)); + await assert.rejects(restarted.putIdea({ ...request, userPriority: 'later' }), e => e.status === 409); + await assert.rejects(restarted.putIdea({ ...request, category: 'Systems' }), e => e.status === 409); + await assert.rejects(restarted.putIdea({ ...request, requestId: 'stale-new' }), e => e.status === 409); + assert.equal(await readFile(s.file, 'utf8'), before); + assert.deepEqual(JSON.parse(before).ideas[0].revisions[0], old); + assert.equal((await restarted.inbox()).pending[0].content.userPriority, 'urgent'); +}); + +test('L0 competing metadata revisions keep one winner and atomic failure can reopen and retry', async () => { + const s = await fresh(); const a = await s.putIdea(input({ category: 'Original', userPriority: 'later' })); + const before = await readFile(s.file, 'utf8'); + const request = input({ id: a.id, expectedRevision: 1, requestId: 'fault-edit', category: 'Recovered' }); + const failing = new DeskStore(s.file, { rootAgentId: ROOT, rename: async (from, to) => { + if (to === s.file) throw new Error('L0 interrupted rename'); return rename(from, to); + } }); + await assert.rejects(failing.putIdea(request), /L0 interrupted rename/); + assert.equal(await readFile(s.file, 'utf8'), before); + const reopened = new DeskStore(s.file, { rootAgentId: ROOT }); const b = await reopened.putIdea(request); + assert.equal(b.category, 'Recovered'); assert.equal(b.userPriority, 'later'); + const results = await Promise.allSettled(['urgent', 'high'].map(userPriority => + reopened.putIdea(input({ id: a.id, expectedRevision: 2, requestId: `cas-${userPriority}`, userPriority })))); + assert.equal(results.filter(r => r.status === 'fulfilled').length, 1); + assert.equal(results.find(r => r.status === 'rejected').reason.status, 409); + const state = await reopened.read(); assert.equal(state.ideas[0].revisions.length, 3); + assert.equal(state.progress.length, 3); assert.equal(state.receipts.length, 0); assert.equal(state.patches.length, 0); + assert.deepEqual(await new DeskStore(s.file, { rootAgentId: ROOT }).putIdea(request), b); +}); + +test('L0 existing API and CLI transport preserve flat fields and provide read defaults without a socket', async () => { + const s = await fresh(); const server = createDeskServer({ rootAgentId: ROOT, statePath: s.file, rootReceiptToken: '' }); + const request = input({ category: ' API ', userPriority: 'high' }); + const req = Readable.from([Buffer.from(JSON.stringify(request))]); + Object.assign(req, { method: 'POST', url: '/api/ideas', headers: { + host: '127.0.0.1:4791', origin: 'http://127.0.0.1:4791', 'content-type': 'application/json' } }); + const result = await new Promise(resolve => server.emit('request', req, { + socket: { localPort: 4791 }, setHeader() {}, writeHead(status) { this.status = status; }, + end(body) { resolve({ status: this.status, body: JSON.parse(body) }); } })); + assert.equal(result.status, 200); assert.equal(result.body.category, 'API'); + assert.equal(result.body.userPriority, 'high'); assert.equal(result.body.savedAcceptance, true); + const file = join(dirname(s.file), 'metadata-request.json'); await writeFile(file, JSON.stringify(request)); + let sent; await runCli(['idea', file], {}, async (url, options) => { + sent = options; return { ok: true, json: async () => result.body }; + }); + assert.deepEqual(JSON.parse(sent.body), request); assert.equal(sent.redirect, 'error'); +}); From 47801c17a70e18610b69a304a8c87e53287b13fa Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:16:44 +0200 Subject: [PATCH 6/8] test(hygiene): Drafts list must ignore historical PR after a comma Test-First: scripts/lib/dev-hygiene.test.mjs::Drafts candidates exclude later historical PR mentions after a comma Co-authored-by: Cursor --- scripts/lib/dev-hygiene.test.mjs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/scripts/lib/dev-hygiene.test.mjs b/scripts/lib/dev-hygiene.test.mjs index cd6625ae..79717d69 100644 --- a/scripts/lib/dev-hygiene.test.mjs +++ b/scripts/lib/dev-hygiene.test.mjs @@ -459,3 +459,28 @@ test("Draft and Ready PR numbers require a boundary after the digits", () => { ], ); }); + +test("Drafts candidates exclude later historical PR mentions after a comma", () => { + const planText = [ + "| ID | Title | Status |", + "|---|---|---|", + "| Z2-PROBE | probe | Drafts #1/#2, Vorgänger PR #99 |", + "", + ].join("\n"); + const rows = inProgressPackages(planText); + assert.deepEqual(rows[0].prNumbers, [1, 2]); + + // Only the unrelated predecessor is open — candidates #1/#2 are missing. + const hints = collectHygiene({ + now: NOW, + planText, + prsOpen: [{ number: 99, title: "unrelated", headRefName: "cursor/unrelated", updatedAt: "2026-09-25T11:00:00Z" }], + prsAll: [], + remoteBranches: [], + standText: "## Aktive Specs\n", + erledigtText: "| Datum | ID |\n", + untracked: [], + }).inProgressWithoutPr; + assert.equal(hints.length, 1); + assert.deepEqual(hints[0].prNumbers, [1, 2]); +}); From dab22744b1ded2f93b8be8c1d10522c44cb2f126 Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:17:17 +0200 Subject: [PATCH 7/8] fix(hygiene): stop Drafts candidate scan before free-text explanations Test-First: scripts/lib/dev-hygiene.test.mjs::Drafts candidates exclude later historical PR mentions after a comma Co-authored-by: Cursor --- scripts/dev/hygiene.mjs | 48 +++++++++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/scripts/dev/hygiene.mjs b/scripts/dev/hygiene.mjs index 9adae48d..3c8ef0a2 100644 --- a/scripts/dev/hygiene.mjs +++ b/scripts/dev/hygiene.mjs @@ -84,17 +84,47 @@ export function hasStatusTable(md) { const PR_NUM = String.raw`#(\d+)(?![0-9A-Za-z])`; const IN_PROGRESS_STATUS = new RegExp(String.raw`^(?:in Arbeit|PR ${PR_NUM}|Drafts? ${PR_NUM}|Ready ${PR_NUM})`, "i"); +// Leading Drafts candidate list: #n, optional `sha` / bare hex between `/` +// separators. Later free-text explanations (", Vorgänger PR #99", "; …") stop +// the scan — they are not candidates. +function draftsCandidateNumbers(status) { + const prefix = /^Drafts\s+/i.exec(status); + if (!prefix) return null; + let pos = prefix[0].length; + const nums = []; + const takeNum = () => { + const m = /^#(\d+)(?![0-9A-Za-z])/.exec(status.slice(pos)); + if (!m) return null; + pos += m[0].length; + return Number(m[1]); + }; + const skipDecor = () => { + while (true) { + const m = /^(?:\s+|`[^`]*`|[0-9a-f]{7,40}\b)/i.exec(status.slice(pos)); + if (!m) break; + pos += m[0].length; + } + }; + const first = takeNum(); + if (first == null) return []; + nums.push(first); + skipDecor(); + while (status[pos] === "/") { + pos += 1; + skipDecor(); + const n = takeNum(); + if (n == null) break; + nums.push(n); + skipDecor(); + } + return nums; +} + function candidatePrNumbers(status) { const s = String(status); - // Drafts: take every #n in the leading candidate list (SHA/text between - // "/ #n" is allowed); stop before ";" or "(" explanations. - let m = new RegExp(String.raw`^Drafts ${PR_NUM}`, "i").exec(s); - if (m) { - const cut = s.search(/[;(]/); - const region = cut === -1 ? s : s.slice(0, cut); - return [...region.matchAll(new RegExp(PR_NUM, "g"))].map((x) => Number(x[1])); - } - m = new RegExp(String.raw`^(?:Draft|Ready|PR) ${PR_NUM}`, "i").exec(s); + const drafts = draftsCandidateNumbers(s); + if (drafts) return drafts; + let m = new RegExp(String.raw`^(?:Draft|Ready|PR) ${PR_NUM}`, "i").exec(s); if (m) return [Number(m[1])]; // In Arbeit: only the optional PR immediately after the status token. m = new RegExp(String.raw`^in Arbeit(?: PR ${PR_NUM})?`, "i").exec(s); From 1df89ea607d0719cfb92dece0e09409a47b8a3da Mon Sep 17 00:00:00 2001 From: Cuarroc <47299143+Cuarroc@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:25:23 +0200 Subject: [PATCH 8/8] test(denkraum): port frozen P1 priority tests --- tools/denkraum/priority.test.mjs | 109 +++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 tools/denkraum/priority.test.mjs diff --git a/tools/denkraum/priority.test.mjs b/tools/denkraum/priority.test.mjs new file mode 100644 index 00000000..50dbc3b6 --- /dev/null +++ b/tools/denkraum/priority.test.mjs @@ -0,0 +1,109 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import * as store from './store.mjs'; + +async function fixture() { + const file = join(await mkdtemp(join(tmpdir(), 'desk-priority-')), 'state.json'); + await writeFile(file, JSON.stringify({ schemaVersion: 1, revision: 0, questions: [], answers: [] })); + const s = new store.DeskStore(file); await s.migrate(0); + const idea = await s.putIdea({ requestId: 'idea', expectedRevision: null, title: 'Idea', text: 'Observed idea' }); + const ref = { kind: 'idea', eventId: idea.eventId, ideaId: idea.id, ideaRevision: 1 }; + const input = { id: 'question', mode: 'free', title: 'Question', context: 'Context', owner: 'Interview', category: 'Follow-up', scope: 'Scope', source: 'Idea', uncertainty: 'Unknown', + sourceRef: ref, contentOrigin: { actor: 'Interview', sourceRefText: 'Saved revision' }, priority: 'blocking' }; + const detail = { blockingDependencies: ['package-dependent'], blockerEvidenceRefs: ['observed-blocker'], criticality: 'high', impact: 'medium', reason: 'Dependency actually awaits this decision', sourceRefs: [ref] }; + const recommendationDetail = { observation: { statement: 'Dependent package failed its prerequisite check', evidenceRefs: ['observed-blocker'], observedAt: '2026-10-06T20:00:00.000Z' }, + hypothesis: 'Answer may resolve dependency', consequences: 'Dependent work can continue', reversibility: 'Question remains historical', patchWindowText: null, authorityRefs: [] }; + return { s, idea, ref, input, detail, recommendationDetail }; +} + +test('B3 priority import persists canonical detail and CAS revisions without changing authority or answer snapshots', async () => { + const { s, input, detail, recommendationDetail } = await fixture(); + const q = await s.putQuestion({ ...input, priorityDetail: detail, recommendationDetail, requestId: 'import' }); + assert.deepEqual(q.priorityDetail, detail); + const answer = await s.answer({ questionId: q.id, questionRevision: 1, requestId: 'answer', expectedAnswerId: null, action: 'answer', selected: [], note: 'Content is not permission' }); + const before = await s.read(); + const updated = await s.putQuestion({ ...input, priorityDetail: { ...detail, impact: 'high' }, expectedRevision: 1, requestId: 'update' }); + assert.equal(updated.revision, 2); assert.equal(updated.createdAt, q.createdAt); + assert.deepEqual((await s.read()).answers[0], answer); assert.deepEqual((await s.read()).patches, before.patches); assert.deepEqual((await s.read()).receipts, []); + const bytes = await readFile(s.file, 'utf8'); + assert.deepEqual(await s.putQuestion({ ...input, priorityDetail: detail, recommendationDetail, requestId: 'import' }), q); + await assert.rejects(s.putQuestion({ ...input, priorityDetail: { ...detail, impact: 'low' }, recommendationDetail, requestId: 'import' }), e => e.status === 409); + assert.equal(await readFile(s.file, 'utf8'), bytes); +}); + +test('B3 projection separates observed blockers from labels and unsupported ordinal claims', async () => { + const { s, input, detail, recommendationDetail } = await fixture(); + const q = await s.putQuestion({ ...input, priorityDetail: detail, recommendationDetail }); const state = await s.read(); + assert.equal(store.projectQuestionPriority(state, q).actualBlocker, true); + assert.equal(store.projectQuestionPriority(state, q).criticality, 'high'); + for (const patch of [{ reason: '' }, { sourceRefs: [] }]) { + const p = store.projectQuestionPriority(state, { ...q, priorityDetail: { ...detail, ...patch } }); + assert.equal(p.actualBlocker, false); assert.equal(p.criticality, 'unclassified'); assert.equal(p.impact, 'unclassified'); + } + assert.equal(store.projectQuestionPriority(state, { ...q, priorityDetail: undefined }).actualBlocker, false); + for (const patch of [{ blockingDependencies: [] }, { blockerEvidenceRefs: ['opaque-reference'] }]) + assert.equal(store.projectQuestionPriority(state, { ...q, priorityDetail: { ...detail, ...patch } }).actualBlocker, false); + assert.equal(store.projectQuestionPriority(state, { ...q, recommendationDetail: { ...recommendationDetail, observation: { ...recommendationDetail.observation, observedAt: null } } }).actualBlocker, false); +}); + +test('B3 stable comparator uses blocker criticality impact creation and ID and never input order or activity', async () => { + const { s, input, detail, recommendationDetail } = await fixture(); const q = await s.putQuestion({ ...input, priorityDetail: detail, recommendationDetail }); + const state = await s.read(); + const rows = [ + { ...q, id: 'blocker', priorityDetail: { ...detail, criticality: 'low', impact: 'low' } }, + { ...q, id: 'high', priorityDetail: { ...detail, blockingDependencies: [], criticality: 'high', impact: 'low' } }, + { ...q, id: 'impact', priorityDetail: { ...detail, blockingDependencies: [], criticality: 'medium', impact: 'high' } }, + { ...q, id: 'medium', priorityDetail: { ...detail, blockingDependencies: [], criticality: 'medium', impact: 'medium' } }, + { ...q, id: 'z', priorityDetail: undefined, createdAt: '2026-10-06T10:00:00.000Z', activity: 999 }, + { ...q, id: 'a', priorityDetail: undefined, createdAt: '2026-10-06T10:00:00.000Z' }, + { ...q, id: 'old', priorityDetail: undefined, createdAt: '2026-10-05T10:00:00.000Z' } + ]; state.questions = rows; + const ordered = list => list.sort((a, b) => store.compareQuestions(state, a, b)).map(q => q.id); + assert.deepEqual(ordered([...rows].reverse()), ['blocker', 'high', 'impact', 'medium', 'old', 'a', 'z']); + assert.deepEqual(ordered([...rows]), ordered([...rows].reverse())); +}); + +test('B3 stale question or source classification becomes unknown and new imports reject mismatched source refs atomically', async () => { + const { s, idea, input, detail, recommendationDetail } = await fixture(); const q = await s.putQuestion({ ...input, priorityDetail: detail, recommendationDetail }); + const wrong = { ...detail, sourceRefs: [{ ...input.sourceRef, eventId: 'wrong-event' }] }; const before = await readFile(s.file, 'utf8'); + await assert.rejects(s.putQuestion({ ...input, priorityDetail: wrong, expectedRevision: 1 }), e => e.status === 409); + assert.equal(await readFile(s.file, 'utf8'), before); + assert.equal(store.projectQuestionPriority(await s.read(), { ...q, revision: 0 }).criticality, 'unclassified'); + await s.putIdea({ id: idea.id, expectedRevision: 1, requestId: 'edit', title: 'Edited', text: 'Edited' }); + const p = store.projectQuestionPriority(await s.read(), q); assert.equal(p.current, false); assert.equal(p.actualBlocker, false); assert.equal(p.impact, 'unclassified'); + await assert.rejects(s.putQuestion({ ...input, priorityDetail: detail, expectedRevision: 1 }), e => e.status === 409); +}); + +test('B3 malformed detail rejects at import and persisted questions or replay results fail closed without writes', async () => { + const { s, input, detail } = await fixture(); + for (const patch of [{ criticality: 'urgent' }, { sourceRefs: [null] }, { blockerEvidenceRefs: 'proof' }, { blockingDependencies: [' '] }, { reason: 4 }, { sourceRefs: null }]) + await assert.rejects(s.putQuestion({ ...input, priorityDetail: { ...detail, ...patch } }), e => e.status === 400); + await s.putQuestion({ ...input, priorityDetail: detail, requestId: 'import' }); + const original = await s.read(); + for (const target of ['question', 'replay']) { + const state = structuredClone(original); const q = target === 'question' ? state.questions[0] : state.questionImports[0].result; + q.priorityDetail.sourceRefs = [null]; const bytes = JSON.stringify(state); await writeFile(s.file, bytes); + await assert.rejects(s.read(), e => e.status === 503); assert.equal(await readFile(s.file, 'utf8'), bytes); + } +}); + +test('B3 incomplete classification stays unknown and observed implementation evidence binds only its current event', async () => { + const { s, input, detail } = await fixture(); const q = await s.putQuestion({ ...input, priorityDetail: { criticality: 'high' } }); + const state = await s.read(); const bytes = await readFile(s.file, 'utf8'); + const unknown = store.projectQuestionPriority(state, q); assert.equal(unknown.criticality, 'unclassified'); assert.equal(unknown.actualBlocker, false); + const updated = await s.putQuestion({ ...input, priorityDetail: detail, expectedRevision: 1 }); + const current = await s.read(); const p = current.progress.find(p => p.eventId === input.sourceRef.eventId); + const evidence = { id: 'observed-blocker', eventId: p.eventId, observedAt: '2026-10-06T20:00:00.000Z', artifactRef: 'test-output', check: 'Dependency check', observedResult: 'Dependent work blocked', actor: 'Root' }; + p.history[0].implementationEvidence = evidence; + assert.equal(store.projectQuestionPriority(current, updated).actualBlocker, true); + p.history[0].implementationEvidence = { ...evidence, eventId: 'different-event' }; + assert.equal(store.projectQuestionPriority(current, updated).actualBlocker, false); + const legacy = { ...q, priorityDetail: undefined, createdAt: undefined }; current.questions.push({ ...legacy, id: 'legacy' }); + assert.equal(store.projectQuestionPriority(current, legacy).criticality, 'unclassified'); + assert.equal(store.compareQuestions(current, { ...legacy, id: 'a' }, { ...legacy, id: 'z' }), -1); + assert.notEqual(await readFile(s.file, 'utf8'), bytes); const committed = await readFile(s.file, 'utf8'); + store.projectQuestionPriority(await s.read(), updated); assert.equal(await readFile(s.file, 'utf8'), committed); +});