From 2f367900ea487c23792703e29b41459d26270883 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 12 Oct 2026 18:30:26 -0500 Subject: [PATCH] dewey: row 90 round 2 packet, 21 paths on b8539480, manifest 95611274 Round 1 rebased on row 63 (b8539480) plus one RUNNER_FILES line in packages/seat/src/sandbox.mjs for row 92 (decision 127). Co-Authored-By: Claude Opus 5.5 --- agents/dewey/work/queue-90/r2/build.md | 108 +++ agents/dewey/work/queue-90/r2/build.patch | 751 ++++++++++++++++++ .../queue-90/r2/candidate-manifest.sha256 | 21 + agents/dewey/work/queue-90/r2/gate-rerun.txt | 5 + .../dewey/work/queue-90/r2/gate-summary.txt | 23 + agents/dewey/work/queue-90/r2/gate.sh | 22 + agents/dewey/work/queue-90/r2/mutants.py | 88 ++ agents/dewey/work/queue-90/r2/mutants.txt | 44 + 8 files changed, 1062 insertions(+) create mode 100644 agents/dewey/work/queue-90/r2/build.md create mode 100644 agents/dewey/work/queue-90/r2/build.patch create mode 100644 agents/dewey/work/queue-90/r2/candidate-manifest.sha256 create mode 100644 agents/dewey/work/queue-90/r2/gate-rerun.txt create mode 100644 agents/dewey/work/queue-90/r2/gate-summary.txt create mode 100644 agents/dewey/work/queue-90/r2/gate.sh create mode 100644 agents/dewey/work/queue-90/r2/mutants.py create mode 100644 agents/dewey/work/queue-90/r2/mutants.txt diff --git a/agents/dewey/work/queue-90/r2/build.md b/agents/dewey/work/queue-90/r2/build.md new file mode 100644 index 000000000..e461d0f2c --- /dev/null +++ b/agents/dewey/work/queue-90/r2/build.md @@ -0,0 +1,108 @@ +# Row 90 (#1584): The approved requirement ids as data, round 2 + +Owner: Dewey. Reviewer: Filbert, then Sage's gate. Round 1 approved by +Filbert (comment 28021, rev 826) on candidate `26d1fbdf…`, base `23338c7b`. +Round 2 is the rebase that lead decision 125 asks for: row 63 landed first, +as `b8539480` (rev 838), and row 90 now sits on it. No behaviour of row 90 +changed from round 1. + +Base `b8539480`. `build.patch` is `git diff --cached --binary b8539480 --` +the 21 paths in `candidate-manifest.sha256`: round 1's twenty plus +`packages/seat/src/sandbox.mjs`. Apply it with `git apply --index` to a +detached worktree of `b8539480`; the manifest then checks 21/21 OK. No +commit from `b8539480` to `refactor` HEAD touches `packages/`, `scripts/`, +`roles/` or `contracts/`. + +## What changed from round 1 + +Three files. The other 18 have the same hunks as round 1 (compared per +file with hunk line numbers ignored), `verbs.mjs`, `tools.mjs` and +`tools.test.mjs` included: row 63 touched them too, but in other places. + +| File | Round 2 change | Why | +|---|---|---| +| `packages/tasks/README.md` | the `task.create` paragraph merges both sides (below); the other hunks are round 1's | row 63 changed the same paragraph | +| `packages/tasks/tests/verbs.test.mjs` | the import line, a context line in round 1, now reads `import { world, sleep, SCOPES } from './world.mjs';` as row 63 left it; row 90's added lines are unchanged | row 63 changed the same line | +| `packages/seat/src/sandbox.mjs` | `RUNNER_FILES` gains `"packages/tasks/src/requirements.mjs"`, one line | row 92 landed between the bases (below) | + +**The README merge.** At round 1's base the paragraph had one sentence on +`requirement`. Row 63 added three sentences after it on `project`. Round 1 +replaced the `requirement` sentence with the `requirements.json` text and +added two sentences: `unknown-requirement`, and that `request` is the +`request` field of a received message. Round 2 keeps both: round 1's +`requirement` and `request` sentences first, then row 63's `project` +sentences word for word, then the unchanged `task.created` sentence. The +paragraph is rewrapped to the file's width. No word of either side is +dropped or changed. + +**The sandbox line.** Row 92 (`57948a81`, #1586) landed after round 1's +base. A role session's runner now runs in a bwrap sandbox that binds only +`RUNNER_FILES`, the files on the runner's import walk, and +`packages/seat/tests/sandbox.test.mjs` fails unless the list equals that +walk exactly. The runner's `bus/src/client.mjs` imports `BusError` from +`broker.mjs`, and row 90's `broker.mjs` imports +`../../tasks/src/requirements.mjs`, so on `b8539480` with only the +twenty paths a sandboxed runner can't load the module and dies at start. +The rebased gate's first run showed it: seat 4 and cli 8 failing (the +`RUNNER_FILES` walk test, and the launcher's PM, coder, kill and +host-restart tests, whose runners ended `failed`). No line of either side +conflicted, and a path drift check on the twenty paths was clean; only +the gate found it. The sandbox line is the semantic fix (decision 127). +`requirements.json` stays out of the sandbox: the runner never constructs +a `Broker`, and only the `Broker` constructor reads the file. + +Row 91 (Darkwing, #1585, in build) doesn't touch `sandbox.mjs` or +`RUNNER_FILES` and adds no file to the runner's import walk (Darkwing's +reply to me, about 23:29Z). It does change `prompt()` in +`packages/harness/src/bundle.mjs`, which row 90 changes too, on other +lines; whichever row lands second reruns the rebase check. + +The rebase checks: row 63's six paths at `b8539480` are byte-identical to +the scratch stack I rebased on (row 63 round 2's candidate on `cca5f23e`), +and `git diff 23338c7b b8539480` on the twenty paths is exactly row 63's +own diff on them. + +## Gate (my run on the round 2 candidate) + +Two runs, sequential, in a detached worktree of `b8539480` with +`node_modules` symlinked. `gate.sh` and `gate-summary.txt` (run 1) and +`gate-rerun.txt` (run 2) are in the packet; the full logs are not. + +1. The twenty paths, 23:15:51-23:20:59Z, manifest 20/20. Green: tasks 89, + bus 111, harness 66, webui 101, business 60, control-board 124, and + every `scripts/test-*.sh`: auth 15, conductor 17, config 24, discord 66, + extension-package 18, foundation 44, queue 27, release 14, task 26 + (`DOCKER_HOST` at a dead socket). Red: cli 112/120 (8 failing) and seat + 29/33 (4 failing), the sandbox failures above. +2. With the sandbox line, the 21-path candidate, 23:26:31-23:26:50Z: seat + 33/33 and cli 120/120, rc=0. + +Not rerun on the 21-path candidate: the other suites. The sandbox line +changes only `RUNNER_FILES`, which `seat` and the cli launcher load; +`harness` names `sandbox.mjs` in a comment only. Per decision 127, +Filbert's review and Sage's landing gate run every suite on the 21-path +candidate, `harness` included. + +Counts against round 1's gate (base `23338c7b`): tasks 79 to 89 (row +63's tests), webui 86 to 101, cli 115 to 120 and seat 27 to 33 (rows 77 +and 92 landed between the bases). + +## Mutants + +Round 1's 44 (`mutants.py`, the same swaps, run in a scratch copy of the +twenty-path candidate against the `bus`, `harness` and `tasks` suites, each +run in its own process group with a 300 s limit), 23:20:59-23:25:39Z: +**44 of 44 killed**, none hung (`mutants.txt`). Afterwards the copy matches +the manifest's twenty paths again. The sandbox line has no mutant of its +own: the seat walk test fails when it is missing, as the first gate run +showed. + +## Round 1 follow-ups + +Filbert's three non-blocking test notes from round 1 stay follow-ups: the +title check accepts `\r`; `in` for `Object.hasOwn` survives; and an +allowlist holding only the first and last ids plus `REQ-TASK-1` survives. +None is in round 2, which changes only what the rebase forced. + +Round 1's `build.md` (`agents/dewey/work/queue-90/build.md`) still holds the +design, the decisions and the limits. Round 2 changes none of them. diff --git a/agents/dewey/work/queue-90/r2/build.patch b/agents/dewey/work/queue-90/r2/build.patch new file mode 100644 index 000000000..8a08176eb --- /dev/null +++ b/agents/dewey/work/queue-90/r2/build.patch @@ -0,0 +1,751 @@ +diff --git a/packages/bus/README.md b/packages/bus/README.md +index c5ca68ad..690eb8e3 100644 +--- a/packages/bus/README.md ++++ b/packages/bus/README.md +@@ -116,7 +116,7 @@ an operator inspect content and delivery evidence after an uncertain result. + | `role.claim` | No args; claims capability's role/run. One current holder per business/instance; a revoked run cannot reclaim that role, including after restart. | + | `role.release` | No args; only its current holder releases. | + | `role.revoke` | `{role,decision}`; needs human-approved gated decision for this exact target and holder incarnation. | +-| `decision.raise` | `{action,question,options:[{key,text}],recommendation,blocking,domain?,target?,project?,task_ref?,requirement_ref?,supersedes?,choice?,approvalChoice?}`. Returns decision with decoded options and authorization context. | ++| `decision.raise` | `{action,question,options:[{key,text}],recommendation,blocking,domain?,target?,project?,task_ref?,requirement_ref?,supersedes?,choice?,approvalChoice?}`. Returns decision with decoded options and authorization context. A blocking decision holds a task: `blocking` true with no `task_ref` refuses `task-required`. A non-blocking one needs no task, and a gated action such as `prd.approve` or `role.revoke` still routes to the human's inbox. | + | `decision.resolve` | `{id,choice,note?}`; option must exist, resolver must be routed role or outside-agent human CLI. | + | `decision.seen` | `{id,note?}`; acknowledgement by resolver, not a resolution. | + | `decision.withdraw` | `{id,note?}`; raiser or human. Never rewrites the decision. | +@@ -131,6 +131,7 @@ an operator inspect content and delivery evidence after an uncertain result. + | `trail` | `{subject}`: task ref, decision UUID or message UUID. Linked records in broker write order. | + | `decisions` | `{list}`: `roles` or `recent`; human and reader sessions only. Open decisions routed to a role, or the last 50 closed. | + | `credentials` | No args; human and reader sessions only. `[{service,account,role,dateKind,date,state}]` for the capability's business, never a token, file path or variable name. | ++| `requirements` | No args; role sessions only, as `task.create` is (row 90, #1584). The approved PRD's requirement ids, `{prd,version,requirements:[{id,title}]}`, from `packages/tasks/requirements.json`, loaded when the broker starts; a bad file refuses the start. A human session refuses `agent-required`, a reader `read-only`. | + | `replies` | `{id}`: a message UUID; human and reader sessions only. The messages whose `in_reply_to` is that id, oldest first, at most 50. Writes nothing. | + + ### Agent state (row 59, #1548) +diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs +index 4a3e52a4..5e211eef 100644 +--- a/packages/bus/src/broker.mjs ++++ b/packages/bus/src/broker.mjs +@@ -1,4 +1,5 @@ + import { randomUUID, randomBytes } from 'node:crypto'; ++import { loadRequirements } from '../../tasks/src/requirements.mjs'; + + // Kept closed while S1 lands; its vocabulary is the integration authority. + export const ACTIONS = Object.freeze([ +@@ -93,6 +94,7 @@ export class Broker { + #ended = new Set(); + #secretCheck; + #credentialStatus; ++ #requirements; + #clock = 0; + // credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use(). + constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [] }) { +@@ -101,6 +103,8 @@ export class Broker { + this.#store = store; + this.#secretCheck = secretCheck; + this.#credentialStatus = credentialStatus; ++ // Row 90 (#1584): the approved PRD's requirement ids. A bad requirements.json refuses here. ++ this.#requirements = loadRequirements(); + for (const table of [ + 'events', + 'role_claims', +@@ -894,6 +898,14 @@ export class Broker { + ), + }; + } ++ // Row 90 (#1584): the approved PRD's requirement ids, which task.create takes. A read for ++ // every session that can create tasks: task.create is an agent verb, so a human refuses ++ // agent-required here as there, and a reader read-only. ++ case 'requirements': { ++ keys(a, []); ++ this.#agent(s); ++ return this.#requirements; ++ } + case 'inbox': + case 'agents': + case 'tasks': +diff --git a/packages/bus/tests/requirements.test.mjs b/packages/bus/tests/requirements.test.mjs +new file mode 100644 +index 00000000..5aa39ced +--- /dev/null ++++ b/packages/bus/tests/requirements.test.mjs +@@ -0,0 +1,78 @@ ++// Row 90 (#1584): `requirements`, the approved PRD's ids that task.create takes, for the role ++// sessions that create tasks; and the PM's two no-task decisions, a request no id fits ++// (prd.approve) and a run it thinks should stop (role.revoke), which reach the human's Inbox. ++import test from 'node:test'; ++import assert from 'node:assert/strict'; ++import { mkdtempSync, rmSync } from 'node:fs'; ++import { tmpdir } from 'node:os'; ++import { join } from 'node:path'; ++import { Store } from '../src/store.mjs'; ++import { Broker } from '../src/broker.mjs'; ++import { loadRequirements } from '../../tasks/src/requirements.mjs'; ++ ++const options = [ ++ { key: 'yes', text: 'Approve' }, ++ { key: 'no', text: 'Decline' }, ++]; ++const roles = { ++ pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } }, ++ coder: { authority: { withinRole: ['message.send'], crossRole: [] } }, ++}; ++const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles } }; ++function setup(t) { ++ const root = mkdtempSync(join(tmpdir(), 'bus-requirements-')); ++ const store = new Store(root); ++ const b = new Broker({ store, businesses }); ++ t.after(() => { ++ store.close(); ++ rmSync(root, { recursive: true, force: true }); ++ }); ++ const agent = (role) => { ++ const cap = b.bindLaunch({ business: 'demo', role, run: `demo-${role}-run`, harness: 'pi', address: role }); ++ b.request(cap, { verb: 'role.claim' }); ++ return cap; ++ }; ++ const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); ++ const reader = b.bindReader({ business: 'demo' }); ++ return { b, agent, human, reader }; ++} ++const call = (b, cap, verb, args = {}) => b.request(cap, { verb, args }); ++const raise = (b, cap, action, extra = {}) => ++ call(b, cap, 'decision.raise', { action, question: `${action}?`, options, recommendation: 'no', blocking: false, ...extra }); ++ ++test('requirements returns the committed list to a holding role session, and no one else', (t) => { ++ const { b, agent, human, reader } = setup(t); ++ const pm = agent('pm'); ++ const list = call(b, pm, 'requirements'); ++ assert.deepEqual(list, loadRequirements()); ++ assert.equal(list.requirements.length, 24); ++ assert.deepEqual(call(b, agent('coder'), 'requirements'), list); ++ assert.throws(() => call(b, human, 'requirements'), /agent-required/); ++ assert.throws(() => call(b, reader, 'requirements'), /read-only/); ++ // A second launch of the role that never claimed it holds nothing. ++ const stale = b.bindLaunch({ business: 'demo', role: 'pm', run: 'demo-pm-other', harness: 'pi', address: 'pm2' }); ++ assert.throws(() => call(b, stale, 'requirements'), /not-holder/); ++ assert.throws(() => call(b, pm, 'requirements', { prd: 'docs/prd/mosaic-stack.md' }), /invalid-request/); ++}); ++ ++test("the PM's prd.approve with no task, when no id fits, reaches the human's Inbox", (t) => { ++ const { b, agent, human, reader } = setup(t); ++ const pm = agent('pm'); ++ const d = raise(b, pm, 'prd.approve', { target: 'mosaic-stack-prd' }); ++ assert.deepEqual([d.class, d.route_to, d.blocking, d.task_ref], ['gated', 'human', false, null]); ++ for (const cap of [human, reader]) assert.deepEqual(call(b, cap, 'inbox').map((r) => r.id), [d.id]); ++ assert.deepEqual(call(b, pm, 'inbox'), []); ++ // Blocking needs a task to hold. ++ assert.throws(() => raise(b, pm, 'prd.approve', { blocking: true }), /task-required/); ++}); ++ ++test("the PM's non-blocking role.revoke with no task reaches the human's Inbox", (t) => { ++ const { b, agent, human } = setup(t); ++ const pm = agent('pm'); ++ agent('coder'); ++ const d = raise(b, pm, 'role.revoke', { target: 'coder' }); ++ assert.deepEqual([d.class, d.route_to, d.blocking, d.task_ref], ['gated', 'human', false, null]); ++ assert.deepEqual(d.authorization, { action: 'role.revoke', target: 'coder', approvalChoice: 'yes' }); ++ assert.deepEqual(call(b, human, 'inbox').map((r) => r.id), [d.id]); ++ assert.throws(() => raise(b, pm, 'role.revoke', { target: 'coder', blocking: true }), /task-required/); ++}); +diff --git a/packages/harness/src/bundle.mjs b/packages/harness/src/bundle.mjs +index 9326be87..63aa4d04 100644 +--- a/packages/harness/src/bundle.mjs ++++ b/packages/harness/src/bundle.mjs +@@ -69,6 +69,8 @@ function prompt({ contract, resolved, session, tools, untyped }) { + `- Bus tools: ${tools.map((t) => t.name).join(", ")}. The broker decides every call; a refusal names its reason.`, + "- Each message to you arrives as one turn. Your final answer goes back to the sender as the reply, so end each turn with the answer itself.", + "- You never hold a token. You act on the tracker, the forge and other roles only through the bus tools.", ++ // Row 90 (#1584): PM fault 10 (#228 c91). ++ "- Only the human stops a run, with `mosaic stop`. If you think a run should stop, raise a role.revoke decision with blocking false and the role instance as target, naming the run and saying why.", + ]; + if (untyped.length) lines.push(`- Actions you hold that have no bus tool yet: ${untyped.join(", ")}. Say so instead of working around it.`); + return lines.join("\n") + "\n"; +diff --git a/packages/harness/src/runner.mjs b/packages/harness/src/runner.mjs +index ccf6ca43..c8943244 100644 +--- a/packages/harness/src/runner.mjs ++++ b/packages/harness/src/runner.mjs +@@ -63,6 +63,8 @@ export function turnRequest(m) { + const head = [`Message ${m.id} from ${m.from_role}`, `class ${m.class}`]; + if (m.in_reply_to) head.push(`in reply to ${m.in_reply_to}`); + if (m.decision) head.push(`citing decision ${m.decision}`); ++ // Row 90 (#1584): the human input id task_create cites as its request. ++ if (m.request) head.push(`request ${m.request}`); + return `${head.join(", ")}:\n\n${m.body}`; + } + +diff --git a/packages/harness/src/tools.mjs b/packages/harness/src/tools.mjs +index eed0818a..a568a808 100644 +--- a/packages/harness/src/tools.mjs ++++ b/packages/harness/src/tools.mjs +@@ -44,8 +44,8 @@ const TASK_TOOLS = { + priority: PRIORITY, + labels: LABEL_IDS, + relations: { type: "array", items: RELATION, maxItems: 50 }, +- request: str("Id of the message or human input this task answers"), +- requirement: str("Requirement id, such as REQ-CRED-2", { pattern: "^REQ-[A-Z]+-[1-9][0-9]*$" }), ++ request: str("The request id of the human message this task answers, shown as \"request \" in that message's header; not the message's own id"), ++ requirement: str("Requirement id from list_requirements, such as REQ-CRED-2", { pattern: "^REQ-[A-Z]+-[1-9][0-9]*$" }), + decision: COMMON.decision, + }, ["title", "request", "requirement"]], + "task.assign": ["task_assign", "Assign an unassigned task to a role.", { ...COMMON, role: str("Role instance to assign") }, ["task_ref", "role"]], +@@ -103,7 +103,7 @@ export function typedTools({ resolved, arbiter = false, tracker = false }) { + question: str("The question, with enough context to answer it"), + options: { type: "array", minItems: 2, maxItems: 9, items: obj({ key: str("Short key"), text: str("What this option means") }, ["key", "text"]) }, + recommendation: str("Your recommended option key, with a reason"), +- blocking: { type: "boolean", description: "True when you can't continue without the answer" }, ++ blocking: { type: "boolean", description: "True only for a decision that holds a task: it needs task_ref, and the task waits for the answer. A request with no task is false" }, + target: str("What the action applies to, such as a role instance or task reference"), + approvalChoice: str("The option key that approves the action"), + task_ref: TASK_REF, +@@ -129,6 +129,9 @@ export function typedTools({ resolved, arbiter = false, tracker = false }) { + after: str("The next value of the previous page"), + })); + add("get_task", "tasks", null, "Read one tracker task in full, description included.", obj({ task_ref: TASK_REF }, ["task_ref"])); ++ // Row 90 (#1584): the ids task_create takes, for the sessions that hold it. ++ if (held.has("task.create")) ++ add("list_requirements", "requirements", null, "List the approved PRD's requirement ids, each with a one-line title. task_create takes only these. When none fits a request, raise a prd.approve decision instead of creating the task.", obj({})); + } + return tools; + } +diff --git a/packages/harness/tests/bundle.test.mjs b/packages/harness/tests/bundle.test.mjs +index 98cee8ed..aae81c52 100644 +--- a/packages/harness/tests/bundle.test.mjs ++++ b/packages/harness/tests/bundle.test.mjs +@@ -43,6 +43,8 @@ test("a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory" + assert.match(prompt, /^# Contract\n\nDo the work\.\n\n## This session\n/); + assert.match(prompt, /Role instance: pm \(definition pm\)\. Run: run-1\./); + assert.match(prompt, /Bus tools: send_message, launch, /); ++ // Row 90 (#1584): PM fault 10 (#228 c91). ++ assert.match(prompt, /^- Only the human stops a run, with `mosaic stop`\. If you think a run should stop, raise a role\.revoke decision with blocking false/m); + assert.deepEqual(JSON.parse(readFileSync(b.files.policy, "utf8")), { + harness: "pi", + workspace: b.workspace, +diff --git a/packages/harness/tests/runner.test.mjs b/packages/harness/tests/runner.test.mjs +index 25b4d8a9..777d1614 100644 +--- a/packages/harness/tests/runner.test.mjs ++++ b/packages/harness/tests/runner.test.mjs +@@ -48,6 +48,12 @@ test("turnRequest names the sender, class, reply and decision", () => { + turnRequest({ id: "m2", from_role: "cto", class: "RESULT", in_reply_to: "m1", decision: "d1", body: "b" }), + "Message m2 from cto, class RESULT, in reply to m1, citing decision d1:\n\nb", + ); ++ // Row 90 (#1584): a human message's request id, which task_create cites; none for a role's. ++ assert.equal( ++ turnRequest({ id: "m3", from_role: "jason", class: "REQUEST", request: "e7", body: "b" }), ++ "Message m3 from jason, class REQUEST, request e7:\n\nb", ++ ); ++ assert.equal(turnRequest({ id: "m4", from_role: "cto", class: "RESULT", request: null, body: "b" }), "Message m4 from cto, class RESULT:\n\nb"); + }); + + async function setup(t, { session = {}, policy = {}, tools } = {}) { +diff --git a/packages/harness/tests/tools.test.mjs b/packages/harness/tests/tools.test.mjs +index f6fca279..c8fdfe46 100644 +--- a/packages/harness/tests/tools.test.mjs ++++ b/packages/harness/tests/tools.test.mjs +@@ -13,7 +13,7 @@ test("the PM gets launch, its task verbs and the reads", (t) => { + "send_message", "launch", + "task_create", "task_assign", "task_reassign", "task_schedule", "task_priority_change", "task_scope_change", + "task_update_assigned", "task_close", +- "raise_decision", "resolve_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", ++ "raise_decision", "resolve_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "list_requirements", + ]); + const launch = tools.find((x) => x.name === "launch"); + assert.equal(launch.verb, "launch"); +@@ -31,6 +31,27 @@ test("the PM gets launch, its task verbs and the reads", (t) => { + } + }); + ++// Row 90 (#1584): list_requirements for the sessions that hold task.create, with a tracker. ++test("list_requirements reads the requirement ids, for a task.create holder with a tracker", (t) => { ++ const pm = resolvedFor(t, "pm"); ++ const tools = typedTools({ resolved: pm, arbiter: true, tracker: true }); ++ const list = tools.find((x) => x.name === "list_requirements"); ++ assert.deepEqual([list.verb, list.action], ["requirements", null]); ++ assert.deepEqual(list.parameters.properties, {}); ++ assert.match(list.description, /task_create takes only these/); ++ assert.match(list.description, /raise a prd\.approve decision instead of creating the task/); ++ assert.ok(!names(typedTools({ resolved: pm, arbiter: true })).includes("list_requirements")); ++ const noCreate = resolvedFor(t, "pm", (d) => (d.roles.pm.vars["limits.authority"] = ["message.send", "task.assign"])); ++ const without = names(typedTools({ resolved: noCreate, tracker: true })); ++ assert.ok(without.includes("task_assign") && !without.includes("task_create") && !without.includes("list_requirements")); ++ const create = tools.find((x) => x.name === "task_create").parameters.properties; ++ assert.match(create.requirement.description, /from list_requirements/); ++ assert.match(create.request.description, /shown as "request " in that message's header; not the message's own id/); ++ const raise = tools.find((x) => x.name === "raise_decision").parameters.properties; ++ assert.match(raise.blocking.description, /needs task_ref/); ++ assert.match(raise.blocking.description, /A request with no task is false/); ++}); ++ + test("a coder gets no launch, no resolve_decision, and no task tools without a tracker", (t) => { + const coder = resolvedFor(t, "coder"); + const tools = typedTools({ resolved: coder }); +@@ -117,6 +138,6 @@ test("replyText: compact JSON up to the cap, an error naming the size and the ca + return true; + }); + assert.throws(() => replyText("\u{1F600}".repeat(REPLY_CAP / 2)), /reply too large: 40002 characters/); +- // 40000 units fit, though they are 80002 bytes: the cap isn't a byte count. ++ // 40000 units fit, though they are 79998 bytes: the cap isn't a byte count. + assert.equal(replyText("\u{1F600}".repeat(REPLY_CAP / 2 - 1)).length, REPLY_CAP); + }); +diff --git a/packages/seat/src/sandbox.mjs b/packages/seat/src/sandbox.mjs +index 21c938ce..88db8db1 100644 +--- a/packages/seat/src/sandbox.mjs ++++ b/packages/seat/src/sandbox.mjs +@@ -52,6 +52,7 @@ export const RUNNER_FILES = Object.freeze([ + "packages/bus/src/client.mjs", + "packages/bus/src/broker.mjs", + "packages/business/src/vocabulary.mjs", ++ "packages/tasks/src/requirements.mjs", + ].map((p) => join(REPO, p))); + + const SYSTEM_LINKS = ["/bin", "/lib", "/lib64", "/sbin"]; +diff --git a/packages/tasks/README.md b/packages/tasks/README.md +index 5b658bc2..f262ba33 100644 +--- a/packages/tasks/README.md ++++ b/packages/tasks/README.md +@@ -133,6 +133,17 @@ decision isn't in it. That comes from the role's authority in + `tests/fields.test.mjs` walks every verb against the fake and checks + both. + ++`requirements.json` holds the approved PRD's requirement ids as data ++(row 90, #1584): the PRD's path and version, and each id with a one-line ++title, in the PRD's order. The PRD (`docs/prd/mosaic-stack.md`) stays ++the reviewed source. `tests/requirements.test.mjs` reads its ++`**REQ-...**` bullets and its status line, and fails unless the list ++holds the same ids in the same order at the same version. A later PRD ++version is a new list in a reviewed commit. `loadRequirements()` ++(`src/requirements.mjs`) checks the file's shape, refuses any other and ++returns it frozen; the adapter loads it when it starts, and the bus ++loads it for its `requirements` read. Neither starts on a bad file. ++ + A write that answers 5xx or doesn't answer is never retried. The adapter + reads the task again. If the write took effect, it continues. If not, + or the re-read fails too, the verb refuses with `write-uncertain`. A +@@ -186,12 +197,15 @@ own write back as a person's edit. + Details that aren't obvious from the table: + + - `task.create` needs `request`, the id of a `human.input` event of this +- business (`request-not-found` otherwise), and a `requirement` like +- `REQ-TASK-3`. `project` is the tracker project id to create the task +- in, the root when left out. Any other project than an installed one of +- the tree refuses with `project-not-installed`, and a value that isn't a +- positive integer with `invalid-request`. The decision a create may +- need is bound to no target, so it doesn't name the project. ++ business (`request-not-found` otherwise), and a `requirement` from ++ `requirements.json`, like `REQ-TASK-3`. An id in the right form that ++ the list doesn't hold refuses `unknown-requirement`. The `request` is ++ the `request` field `message.receive` gives a human's message, not the ++ message's own id. `project` is the tracker project id to create the ++ task in, the root when left out. Any other project than an installed ++ one of the tree refuses with `project-not-installed`, and a value that ++ isn't a positive integer with `invalid-request`. The decision a create ++ may need is bound to no target, so it doesn't name the project. + `task.created` is recorded even when a label or relation write after + the create fails. The task exists, and the verb then refuses with that + write's code. +@@ -376,7 +390,7 @@ on a live read with the sync key, and a switch is a follow-up row. + | `task-moved`, `task-placement`, `task-project` | the task is in another project, or on no bucket | + | `project-not-installed` | the project is watch-only, or not an installed project of the tree | + | `task-conflict`, `task-done`, `task-assigned`, `task-unassigned`, `not-assigned` | the task's state doesn't allow the verb | +-| `field-writer`, `label-not-allowed`, `unknown-role`, `invalid-state`, `request-not-found`, `invalid-request` | the call itself is wrong | ++| `field-writer`, `label-not-allowed`, `unknown-role`, `invalid-state`, `request-not-found`, `unknown-requirement`, `invalid-request` | the call itself is wrong | + + Startup adds `credential-unavailable`, `credential-expired`, + `tracker-version`, `tracker-project`, `tracker-install`, +diff --git a/packages/tasks/requirements.json b/packages/tasks/requirements.json +new file mode 100644 +index 00000000..622d3a68 +--- /dev/null ++++ b/packages/tasks/requirements.json +@@ -0,0 +1,30 @@ ++{ ++ "prd": "docs/prd/mosaic-stack.md", ++ "version": "1.0", ++ "requirements": [ ++ { "id": "REQ-ROLE-1", "title": "Each role is a reviewed roles/.json: contract, authority map, credentials" }, ++ { "id": "REQ-ROLE-2", "title": "One holder per role instance; a claim locks and logs; revoking a holder is gated" }, ++ { "id": "REQ-ROLE-3", "title": "A user-written business file declares instances, arbiters and credential references" }, ++ { "id": "REQ-ROLE-4", "title": "The first business is Mosaic Stack: PM, CTO, coder and reviewer" }, ++ { "id": "REQ-VAR-1", "title": "Variables in four layers, declared in code; limits intersect; unknown keys refused" }, ++ { "id": "REQ-VAR-2", "title": "One system config, never auto-written; secrets only as references the broker reads" }, ++ { "id": "REQ-CRED-1", "title": "Each role instance has its own Gitea and Vikunja token, held by the broker" }, ++ { "id": "REQ-CRED-2", "title": "A session that finds only founder credentials stops" }, ++ { "id": "REQ-DEC-1", "title": "Every decision is an append-only record: raiser, class, options, resolver, times" }, ++ { "id": "REQ-DEC-2", "title": "Decisions route by class: logged, to the domain arbiter, or to the human" }, ++ { "id": "REQ-DEC-3", "title": "Human resolutions come only from a mosaic CLI session outside any agent run" }, ++ { "id": "REQ-DEC-4", "title": "A blocking gated decision reaches Jason at once; the rest go in a daily digest" }, ++ { "id": "REQ-MSG-1", "title": "Messages are addressed to a role, append-only, delivered to the current holder" }, ++ { "id": "REQ-TASK-1", "title": "Tasks live in Vikunja, each citing a requirement id; the verbs enforce field writers" }, ++ { "id": "REQ-TASK-2", "title": "Polling is the source of truth: a 30-second board poll and an hourly reconcile" }, ++ { "id": "REQ-TASK-3", "title": "The installer points at an existing Vikunja or deploys the unmodified upstream image" }, ++ { "id": "REQ-TASK-4", "title": "The queue keeps the agents' lock, review rounds and audit log; no two-way sync" }, ++ { "id": "REQ-CLI-1", "title": "mosaic is the front door: talk to the PM, the inbox, tasks, agents and trails" }, ++ { "id": "REQ-CLI-2", "title": "The PM session mosaic talks to is launched and owned by the stack, not T3" }, ++ { "id": "REQ-WEB-1", "title": "The WebUI shows the same data as the CLI" }, ++ { "id": "REQ-LAUNCH-1", "title": "The PM launches role sessions within business-file limits, logged and revocable" }, ++ { "id": "REQ-HARN-1", "title": "The meta-harness generates each session's launch bundle from role and variables" }, ++ { "id": "REQ-HARN-2", "title": "Credential scope, verbs, the tool limit and a container enforce the rules" }, ++ { "id": "REQ-EVT-1", "title": "Every slice 1 step emits an event; measures come from events" } ++ ] ++} +diff --git a/packages/tasks/src/requirements.mjs b/packages/tasks/src/requirements.mjs +new file mode 100644 +index 00000000..62007000 +--- /dev/null ++++ b/packages/tasks/src/requirements.mjs +@@ -0,0 +1,53 @@ ++import { readFileSync } from 'node:fs'; ++// Row 90 (#1584): the approved PRD's requirement ids as data, in ../requirements.json. The PRD ++// (docs/prd/mosaic-stack.md) stays the reviewed source; tests/requirements.test.mjs fails unless ++// the list holds its **REQ-...** ids in its order. A later PRD version is a new list in a reviewed ++// commit. task.create refuses an id the list doesn't hold, and the broker's `requirements` verb ++// returns the list to the sessions that create tasks. This module imports nothing from the bus, ++// so the broker can load it. This function is the file's schema: it refuses any other shape. ++export const REQUIREMENTS_FILE = new URL('../requirements.json', import.meta.url); ++export const REQUIREMENT_ID = /^REQ-[A-Z]+-[1-9][0-9]*$/; ++export class RequirementsError extends Error {} ++const plain = (x) => x !== null && typeof x === 'object' && !Array.isArray(x); ++const str = (x, max) => typeof x === 'string' && x.trim() !== '' && x.length <= max; ++function exact(x, where, allowed) { ++ if (!plain(x)) throw new RequirementsError(`${where}: not an object`); ++ for (const k of Object.keys(x)) if (!allowed.includes(k)) throw new RequirementsError(`${where}: unknown key ${k}`); ++ for (const k of allowed) if (!Object.hasOwn(x, k)) throw new RequirementsError(`${where}: missing ${k}`); ++} ++export function checkRequirements(list) { ++ exact(list, 'list', ['prd', 'version', 'requirements']); ++ if (!str(list.prd, 256) || !/^docs\/prd\/[a-z0-9-]+\.md$/.test(list.prd)) ++ throw new RequirementsError('prd: not a docs/prd/*.md path'); ++ if (typeof list.version !== 'string' || !/^[1-9][0-9]*\.[0-9]+$/.test(list.version)) ++ throw new RequirementsError('version: not a version such as 1.0'); ++ if (!Array.isArray(list.requirements) || !list.requirements.length) ++ throw new RequirementsError('requirements: not a nonempty list'); ++ const seen = new Set(); ++ for (const [i, r] of list.requirements.entries()) { ++ const at = `requirements[${i}]`; ++ exact(r, at, ['id', 'title']); ++ if (typeof r.id !== 'string' || !REQUIREMENT_ID.test(r.id)) throw new RequirementsError(`${at}.id: not a requirement id`); ++ if (seen.has(r.id)) throw new RequirementsError(`${at}.id: ${r.id} twice`); ++ seen.add(r.id); ++ if (!str(r.title, 120) || r.title.includes('\n')) throw new RequirementsError(`${at}.title: not one line of 120 characters or fewer`); ++ } ++ return list; ++} ++function freeze(x) { ++ if (x && typeof x === 'object') { ++ for (const v of Object.values(x)) freeze(v); ++ Object.freeze(x); ++ } ++ return x; ++} ++// The committed list, checked and frozen. A file that fails the check refuses to load. ++export function loadRequirements(file = REQUIREMENTS_FILE) { ++ let list; ++ try { ++ list = JSON.parse(readFileSync(file, 'utf8')); ++ } catch (e) { ++ throw new RequirementsError(e instanceof SyntaxError ? 'requirements.json: not JSON' : `requirements.json: ${e.code ?? 'unreadable'}`); ++ } ++ return freeze(checkRequirements(list)); ++} +diff --git a/packages/tasks/src/verbs.mjs b/packages/tasks/src/verbs.mjs +index 50efa0e7..cef5ae44 100644 +--- a/packages/tasks/src/verbs.mjs ++++ b/packages/tasks/src/verbs.mjs +@@ -2,6 +2,7 @@ import { BusError } from '../../bus/src/broker.mjs'; + import { outcome, refusal } from './vikunja.mjs'; + import { ref, parseRef, checkTask, taskFields, digest } from './digest.mjs'; + import { board, normal } from './sync.mjs'; ++import { loadRequirements } from './requirements.mjs'; + // The eight task verbs (addendum B section 4). Each one: field-table writer check, compare-read with + // the sync token, the caller's expected digest, broker authority, the writes with the acting role's + // own token, a final read, then one self snapshot and its event. A write that answers 5xx or not at +@@ -64,6 +65,8 @@ const sorted = (a) => [...a].sort((x, y) => x - y); + const decision = (x) => (x === undefined ? null : text(x, 128, 1)); + export function verbs(ctx) { + const fail = (code) => refuse(code); ++ // Row 90 (#1584): the approved PRD's ids. A bad requirements.json refuses here, at adapter start. ++ const requirements = new Set(loadRequirements().requirements.map((r) => r.id)); + const label = (id) => { + // Second guard after the bot-owner rule (probes.md, L): only label ids in the business file. + if (!ctx.labels.has(id)) fail('label-not-allowed'); +@@ -215,6 +218,7 @@ export function verbs(ctx) { + plan.request = text(args.request, 64, 1); + plan.requirement = text(args.requirement, 64); + if (!/^REQ-[A-Z]+-[1-9][0-9]*$/.test(plan.requirement)) fail('invalid-request'); ++ if (!requirements.has(plan.requirement)) fail('unknown-requirement'); + return plan; + } + case 'task.assign': +diff --git a/packages/tasks/tests/adapter.test.mjs b/packages/tasks/tests/adapter.test.mjs +index 3a4903e6..e394cd70 100644 +--- a/packages/tasks/tests/adapter.test.mjs ++++ b/packages/tasks/tests/adapter.test.mjs +@@ -181,9 +181,9 @@ test('close waits for a running verb and refuses one that has not started', asyn + const pm = w.agent('pm'); + let release; + hold = new Promise((ok) => (release = ok)); +- const running = w.call(pm, 'task.create', { title: 'x', request: w.instruction(), requirement: 'REQ-S-1' }); ++ const running = w.call(pm, 'task.create', { title: 'x', request: w.instruction(), requirement: 'REQ-TASK-1' }); + await reached; +- const queued = w.call(pm, 'task.create', { title: 'y', request: w.instruction(), requirement: 'REQ-S-1' }); ++ const queued = w.call(pm, 'task.create', { title: 'y', request: w.instruction(), requirement: 'REQ-TASK-1' }); + let done = false; + const closing = w.adapter.close().then(() => (done = true)); + await sleep(20); +@@ -193,7 +193,7 @@ test('close waits for a running verb and refuses one that has not started', asyn + assert.ok((await running).task_ref); + await assert.rejects(queued, (e) => e.code === 'tracker-closed'); + await assert.rejects( +- w.call(pm, 'task.create', { title: 'z', request: w.instruction(), requirement: 'REQ-S-1' }), ++ w.call(pm, 'task.create', { title: 'z', request: w.instruction(), requirement: 'REQ-TASK-1' }), + (e) => e.code === 'tracker-closed', + ); + assert.equal(w.events('task.created').length, 1); +diff --git a/packages/tasks/tests/fields.test.mjs b/packages/tasks/tests/fields.test.mjs +index 597048bd..59332d0f 100644 +--- a/packages/tasks/tests/fields.test.mjs ++++ b/packages/tasks/tests/fields.test.mjs +@@ -187,10 +187,10 @@ test('each verb refuses, asks for a decision and writes as the table and the rol + } + const others = ROLE_NAMES.filter((r) => r !== 'pm'); + const create = { title: 'b', description: 'what', due_date: '2026-11-01T00:00:00.000Z', priority: 2, labels: [w.label] }; +- for (const r of others) await attempt(r, 'task.create', { ...create, request: w.instruction(), requirement: 'REQ-S-1' }); +- const a = await attempt('pm', 'task.create', { title: 'a', request: w.instruction(), requirement: 'REQ-S-1' }); ++ for (const r of others) await attempt(r, 'task.create', { ...create, request: w.instruction(), requirement: 'REQ-TASK-1' }); ++ const a = await attempt('pm', 'task.create', { title: 'a', request: w.instruction(), requirement: 'REQ-TASK-1' }); + const relation = { kind: 'related', task_ref: a.task_ref }; +- const { task_ref } = await attempt('pm', 'task.create', { ...create, relations: [relation], request: w.instruction(), requirement: 'REQ-S-1' }); ++ const { task_ref } = await attempt('pm', 'task.create', { ...create, relations: [relation], request: w.instruction(), requirement: 'REQ-TASK-1' }); + for (const r of others) { + await attempt(r, 'task.assign', { task_ref, role: 'coder' }); + await attempt(r, 'task.reassign', { task_ref, role: 'coder' }); +diff --git a/packages/tasks/tests/requirements.test.mjs b/packages/tasks/tests/requirements.test.mjs +new file mode 100644 +index 00000000..5e8f5453 +--- /dev/null ++++ b/packages/tasks/tests/requirements.test.mjs +@@ -0,0 +1,81 @@ ++import test from 'node:test'; ++import assert from 'node:assert/strict'; ++import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; ++import { tmpdir } from 'node:os'; ++import { join } from 'node:path'; ++import { fileURLToPath } from 'node:url'; ++import { loadRequirements, checkRequirements, RequirementsError, REQUIREMENTS_FILE } from '../src/requirements.mjs'; ++// Row 90 (#1584): requirements.json against the approved PRD it names. The PRD's **REQ-...** ++// bullets are the source; the list must hold the same ids in the same order, and the PRD's ++// status line must give the list's version. ++const list = loadRequirements(); ++const raw = () => JSON.parse(readFileSync(REQUIREMENTS_FILE, 'utf8')); ++const REPO = fileURLToPath(new URL('../../../', import.meta.url)); ++const listError = (message) => (e) => e instanceof RequirementsError && (message === undefined || e.message === message); ++ ++test("the list holds the PRD's requirement ids, in its order, at its version", () => { ++ const prd = readFileSync(join(REPO, list.prd), 'utf8'); ++ const ids = [...prd.matchAll(/^- \*\*(REQ-[A-Z]+-[0-9]+)\.\*\*/gm)].map((m) => m[1]); ++ // Any other mention of a bold id would be a bullet this pattern missed. ++ assert.equal(ids.length, [...prd.matchAll(/\*\*REQ-/g)].length); ++ assert.equal(ids.length, 24); ++ assert.deepEqual(list.requirements.map((r) => r.id), ids); ++ assert.match(prd, new RegExp(`^- Status: approved, version ${list.version.replace('.', '\\.')}\\.`, 'm')); ++ assert.deepEqual([list.prd, list.version], ['docs/prd/mosaic-stack.md', '1.0']); ++}); ++ ++test('the committed list loads frozen', () => { ++ assert.ok(Object.isFrozen(list) && Object.isFrozen(list.requirements) && Object.isFrozen(list.requirements[0])); ++ assert.deepEqual(checkRequirements(raw()), raw()); ++ assert.deepEqual(loadRequirements(), list); ++}); ++ ++test('the schema refuses any other shape', () => { ++ const bad = { ++ 'an unknown top key': (l) => (l.source = 'x'), ++ 'no prd': (l) => delete l.prd, ++ 'a prd outside docs/prd': (l) => (l.prd = 'docs/plans/x.md'), ++ 'a prd that is not markdown': (l) => (l.prd = 'docs/prd/mosaic-stack.txt'), ++ 'a prd with a parent step': (l) => (l.prd = 'docs/prd/../x.md'), ++ 'no version': (l) => delete l.version, ++ 'a numeric version': (l) => (l.version = 1.5), ++ 'a version with no minor': (l) => (l.version = '1'), ++ 'a version 0': (l) => (l.version = '0.4'), ++ 'no requirements': (l) => delete l.requirements, ++ 'an empty list': (l) => (l.requirements = []), ++ 'requirements not a list': (l) => (l.requirements = {}), ++ 'an entry not an object': (l) => (l.requirements[0] = 'REQ-ROLE-1'), ++ 'an unknown entry key': (l) => (l.requirements[0].note = 'x'), ++ 'no id': (l) => delete l.requirements[0].id, ++ 'no title': (l) => delete l.requirements[0].title, ++ 'an id in lower case': (l) => (l.requirements[0].id = 'REQ-role-1'), ++ 'an id numbered 0': (l) => (l.requirements[0].id = 'REQ-ROLE-0'), ++ 'an id with a suffix': (l) => (l.requirements[0].id = 'REQ-ROLE-1a'), ++ 'an id twice': (l) => (l.requirements[1].id = l.requirements[0].id), ++ 'a blank title': (l) => (l.requirements[0].title = ' '), ++ 'a title over 120 characters': (l) => (l.requirements[0].title = 'x'.repeat(121)), ++ 'a title of two lines': (l) => (l.requirements[0].title = 'a\nb'), ++ 'a title that is not a string': (l) => (l.requirements[0].title = 1), ++ }; ++ for (const x of [[], null, 'list']) assert.throws(() => checkRequirements(x), listError('list: not an object')); ++ for (const [what, edit] of Object.entries(bad)) { ++ const l = raw(); ++ edit(l); ++ assert.throws(() => checkRequirements(l), listError(), what); ++ } ++ const ok = raw(); ++ ok.requirements[0].title = 'x'.repeat(120); ++ assert.equal(checkRequirements(ok), ok); ++ const dir = mkdtempSync(join(tmpdir(), 'requirements-')); ++ try { ++ writeFileSync(join(dir, 'cut.json'), '{"prd":'); ++ assert.throws(() => loadRequirements(join(dir, 'cut.json')), listError('requirements.json: not JSON')); ++ assert.throws(() => loadRequirements(join(dir, 'absent.json')), listError('requirements.json: ENOENT')); ++ const twice = raw(); ++ twice.requirements.push(structuredClone(twice.requirements[0])); ++ writeFileSync(join(dir, 'twice.json'), JSON.stringify(twice)); ++ assert.throws(() => loadRequirements(join(dir, 'twice.json')), listError('requirements[24].id: REQ-ROLE-1 twice')); ++ } finally { ++ rmSync(dir, { recursive: true, force: true }); ++ } ++}); +diff --git a/packages/tasks/tests/shapes.test.mjs b/packages/tasks/tests/shapes.test.mjs +index c16f1f44..0a3ce8e3 100644 +--- a/packages/tasks/tests/shapes.test.mjs ++++ b/packages/tasks/tests/shapes.test.mjs +@@ -113,11 +113,11 @@ test('the fake answers each route with the statuses and shapes Vikunja v2.7.0 se + await w.adapter.start('demo'); + const pm = w.agent('pm'), + coder = w.agent('coder'); +- const a = await w.call(pm, 'task.create', { title: 'a', request: w.instruction(), requirement: 'REQ-S-1' }); ++ const a = await w.call(pm, 'task.create', { title: 'a', request: w.instruction(), requirement: 'REQ-TASK-1' }); + const b = await w.call(pm, 'task.create', { + title: 'b', + request: w.instruction(), +- requirement: 'REQ-S-1', ++ requirement: 'REQ-TASK-1', + labels: [w.label], + relations: [{ kind: 'blocked', task_ref: a.task_ref }], + }); +@@ -201,7 +201,7 @@ test('the client works against the fake over real HTTP with the platform fetch', + const w = await world(t, { fake, fetch: globalThis.fetch, tracker: { baseUrl: server.url } }); + await w.adapter.start('demo'); + assert.equal(w.adapter.status()[0].state, 'ready', w.adapter.status()[0].refused); +- const r = await w.call(w.agent('pm'), 'task.create', { title: 'over http', request: w.instruction(), requirement: 'REQ-S-1' }); ++ const r = await w.call(w.agent('pm'), 'task.create', { title: 'over http', request: w.instruction(), requirement: 'REQ-TASK-1' }); + assert.equal(r.task_ref, `vikunja:${w.project}/1`); + await w.ui('PATCH', '/tasks/1', { title: 'edited' }); + await sleep(20); +diff --git a/packages/tasks/tests/sync.test.mjs b/packages/tasks/tests/sync.test.mjs +index 5b7c9ffd..71a96b18 100644 +--- a/packages/tasks/tests/sync.test.mjs ++++ b/packages/tasks/tests/sync.test.mjs +@@ -8,7 +8,7 @@ const ready = async (t, edit) => { + return w; + }; + const create = (w, title = 'a task') => +- w.call(w.caps.pm ?? w.agent('pm'), 'task.create', { title, request: w.instruction(), requirement: 'REQ-S-1' }); ++ w.call(w.caps.pm ?? w.agent('pm'), 'task.create', { title, request: w.instruction(), requirement: 'REQ-TASK-1' }); + const external = (w, subject) => w.events('task.changed.external').filter((e) => subject === undefined || e.subject === subject); + // A fake whose clock runs an hour behind, so every task sits outside the cursor window and only the + // board or a task read can see it. +diff --git a/packages/tasks/tests/verbs.test.mjs b/packages/tasks/tests/verbs.test.mjs +index 9824a7fa..251317d3 100644 +--- a/packages/tasks/tests/verbs.test.mjs ++++ b/packages/tasks/tests/verbs.test.mjs +@@ -2,6 +2,7 @@ import test from 'node:test'; + import assert from 'node:assert/strict'; + import { world, sleep, SCOPES } from './world.mjs'; + import { FakeVikunja } from '../src/fake.mjs'; ++import { loadRequirements } from '../src/requirements.mjs'; + const ready = async (t, edit) => { + const w = await world(t, edit); + await w.adapter.start('demo'); +@@ -13,12 +14,12 @@ const refusedWith = (code) => (e) => e.code === code; + // A pm-created task, returned with its ref and digest. + const task = async (w, args = {}) => { + const pm = w.caps.pm ?? w.agent('pm'); +- return w.call(pm, 'task.create', { title: 'a task', request: w.instruction(), requirement: 'REQ-S-1', ...args }); ++ return w.call(pm, 'task.create', { title: 'a task', request: w.instruction(), requirement: 'REQ-TASK-1', ...args }); + }; + test('task.create needs a recorded human request and a requirement id', async (t) => { + const w = await ready(t); + const pm = w.agent('pm'); +- const args = { title: 'x', request: w.instruction(), requirement: 'REQ-S-1' }; ++ const args = { title: 'x', request: w.instruction(), requirement: 'REQ-TASK-1' }; + await assert.rejects(w.call(pm, 'task.create', { ...args, request: 'not-an-event' }), refusedWith('request-not-found')); + await assert.rejects(w.call(pm, 'task.create', { ...args, requirement: 'S-1' }), refusedWith('invalid-request')); + await assert.rejects(w.call(pm, 'task.create', { title: 'x', request: args.request }), refusedWith('invalid-request')); +@@ -28,7 +29,7 @@ test('task.create needs a recorded human request and a requirement id', async (t + assert.equal(r.task_ref, `vikunja:${w.project}/1`); + const created = w.events('task.created'); + assert.equal(created.length, 1); +- assert.deepEqual(created[0].body, { request: args.request, requirement: 'REQ-S-1' }); ++ assert.deepEqual(created[0].body, { request: args.request, requirement: 'REQ-TASK-1' }); + assert.equal(created[0].actor_role, 'pm'); + const [s] = w.snapshots(r.task_ref); + assert.equal(s.source, 'self'); +@@ -50,6 +51,35 @@ test('task.create needs a recorded human request and a requirement id', async (t + ); + assert.equal(allowed(w, 'task.create').length, 1); + }); ++// Row 90 (#1584): the ids are the approved PRD's, from requirements.json. ++test('task.create takes only an approved requirement id', async (t) => { ++ const w = await ready(t); ++ const pm = w.agent('pm'); ++ const { requirements } = loadRequirements(); ++ const args = { title: 'x', request: w.instruction() }; ++ // REQ-QA-1 is the id P19 #229 and #230 were created under: the right form, not in the PRD. ++ for (const requirement of ['REQ-QA-1', 'REQ-TASK-5', 'REQ-S-1']) ++ await assert.rejects(w.call(pm, 'task.create', { ...args, requirement }), refusedWith('unknown-requirement'), requirement); ++ // The form is checked first, as before. ++ await assert.rejects(w.call(pm, 'task.create', { ...args, requirement: 'REQ-task-1' }), refusedWith('invalid-request')); ++ assert.equal(allowed(w, 'task.create').length, 0); ++ assert.equal(w.events('task.created').length, 0); ++ for (const { id } of [requirements[0], requirements.at(-1)]) await w.call(pm, 'task.create', { ...args, requirement: id }); ++ assert.deepEqual(w.events('task.created').map((e) => e.body.requirement), [requirements[0].id, requirements.at(-1).id]); ++}); ++test("task.create cites the request a received message carries, not the message's id", async (t) => { ++ const w = await ready(t); ++ const pm = w.agent('pm'); ++ const sent = w.broker.request(w.human, { verb: 'message.send', args: { to: 'pm', body: 'build the thing' } }); ++ const [m] = w.broker.request(pm, { verb: 'message.receive' }); ++ assert.equal(m.id, sent.id); ++ assert.equal(m.request, sent.request); ++ assert.notEqual(m.request, m.id); ++ const args = { title: 'the thing', requirement: 'REQ-TASK-1' }; ++ await assert.rejects(w.call(pm, 'task.create', { ...args, request: m.id }), refusedWith('request-not-found')); ++ const r = await w.call(pm, 'task.create', { ...args, request: m.request }); ++ assert.deepEqual(w.events('task.created').map((e) => [e.subject, e.body]), [[r.task_ref, { request: m.request, requirement: 'REQ-TASK-1' }]]); ++}); + test('only labels named in the business file can be written', async (t) => { + const w = await ready(t); + // The pm can see this label in Vikunja (its owner created it), but the business file doesn't list it. +diff --git a/roles/pm.md b/roles/pm.md +index 4cbd3d8e..53422082 100644 +--- a/roles/pm.md ++++ b/roles/pm.md +@@ -10,6 +10,14 @@ order. You don't write the product's code and you don't review it. + you create cites the requirement id it serves, such as `REQ-TASK-1`, + and the human request that asked for it. A task without both is + refused. ++- Before creating a task, pick its requirement id from ++ `list_requirements`, the approved PRD's list. `task_create` refuses ++ any other id. The request is the `request ` in the header of the ++ human message you're answering, not the message's own id. ++- When no id fits the request, don't create the task, and don't ask in ++ your answer. Raise a decision with action `prd.approve`, `blocking` ++ false and no `task_ref`, naming the request and the nearest ids. It ++ goes to the human's Inbox. + - Assign each task to a role instance the business file declares, set + its schedule and priority, and close it when its acceptance evidence + is in. +@@ -26,6 +34,14 @@ the resolved list for this business and project, which may be narrower. + Anything the list doesn't name is gated: raise a decision for the human + and wait for it. Never route around a refusal. + ++A blocking decision holds a task: it needs `task_ref`, and the task ++waits for the answer. A decision with no task is non-blocking, and a ++gated one still reaches the human's Inbox. ++ ++Only the human stops a run, with `mosaic stop`. If you think a coder or ++reviewer run should stop, raise a `role.revoke` decision with `blocking` ++false and the role instance as target, naming the run and saying why. ++ + Changing a task's priority or scope against the CTO's technical call is + cross-role. Raise it as a decision for the technical arbiter. + diff --git a/agents/dewey/work/queue-90/r2/candidate-manifest.sha256 b/agents/dewey/work/queue-90/r2/candidate-manifest.sha256 new file mode 100644 index 000000000..e4912534b --- /dev/null +++ b/agents/dewey/work/queue-90/r2/candidate-manifest.sha256 @@ -0,0 +1,21 @@ +07fa600a67a59d2bb3d4c8ba8de43e002a08ca6e58c0aaef57118f0816936bdc packages/bus/README.md +d87bfeae25bb8e2d70a8b40dc565d812fbee25e5d74e34bc8999da580b62f3f9 packages/bus/src/broker.mjs +a00fb48fd82d3df5429561e487c7ec836976814e400ed27b6226dee813171d48 packages/bus/tests/requirements.test.mjs +031e6b5a787150de847a290843634268edb3c50f62b625cc719367a5b6c8cf42 packages/harness/src/bundle.mjs +91069964d1455b84b8b96f5772bb348d1250f4c0a064fe4eaa720b1ee56d90f8 packages/harness/src/runner.mjs +8394fb1a9fa95fa562bc7c538432b4cad798586c33dfbbb680f295cf950cdfce packages/harness/src/tools.mjs +bf4e7fad255338000e703a83f12a4568e1bae0211cba187e43af0ddb1d2d99cb packages/harness/tests/bundle.test.mjs +0ff62327f91b7dfddc4a9e8fa4c5af42d0d40e7c67ff0aabf508de2a2aa19c4a packages/harness/tests/runner.test.mjs +b136925ffa30cf8b23e8c198ad67d3f7f122659fa35b9ac6fda063870911ec30 packages/harness/tests/tools.test.mjs +13f938085b6ac3ecc6364dd2fb4de749509e7d22291cc970cd2121a5924fceca packages/seat/src/sandbox.mjs +2b234b173ccb1e6f37dc4d99dbde35b5db274b86a583a24e6e18c6eab84a0430 packages/tasks/README.md +2cd2616bd5755472355f580e42ebc0e2545dffe9c1062f617a61e22c117e86b2 packages/tasks/requirements.json +38e7b85650247759f06c09fce205b2970b8368bc81491f51dc6b3c90ba2a846d packages/tasks/src/requirements.mjs +cf0ce9d31c2efd1fd79c15f649801d3b6e8135310630a86901731dc364ba95eb packages/tasks/src/verbs.mjs +f5b1b8c977e75d8129eb9e85b7c633210a83683f152632f28be0e5cb9152042a packages/tasks/tests/adapter.test.mjs +793cb8f8acbadf2e2d741c439d01a61d7bbe13d639f133bc19d214f67bdb0fc9 packages/tasks/tests/fields.test.mjs +5233a68d0f6d25857fd13c321b42fb3d6a1848265240a4c49c342e27161704bc packages/tasks/tests/requirements.test.mjs +1c73baca1404f92700b341c4ad0bf6c8f92a274315e77cd044c08edeb10ccdb7 packages/tasks/tests/shapes.test.mjs +4aad97b3914572d647f767a6f10bac4da83c3de803f2c9d2359b72e540fc8b79 packages/tasks/tests/sync.test.mjs +ece745dc9fb59d6f0a14ee862e712e51af011219e04a8406d44f463906ff5847 packages/tasks/tests/verbs.test.mjs +73ce966340f9d4369b1c6d7854829d4cf60fdb1cb673d38287b914d107ccf053 roles/pm.md diff --git a/agents/dewey/work/queue-90/r2/gate-rerun.txt b/agents/dewey/work/queue-90/r2/gate-rerun.txt new file mode 100644 index 000000000..8803a7d04 --- /dev/null +++ b/agents/dewey/work/queue-90/r2/gate-rerun.txt @@ -0,0 +1,5 @@ +run 2, 21-path candidate (twenty paths plus packages/seat/src/sandbox.mjs), base b8539480, 23:26:31-23:26:50Z +manifest 21/21 OK +node seat rc=0 ℹ tests 33 ℹ pass 33 ℹ fail 0 +node cli rc=0 ℹ tests 120 ℹ pass 120 ℹ fail 0 +not rerun on the 21-path candidate: the other suites (run 1, gate-summary.txt); Filbert's review and Sage's landing gate run all (decision 127) diff --git a/agents/dewey/work/queue-90/r2/gate-summary.txt b/agents/dewey/work/queue-90/r2/gate-summary.txt new file mode 100644 index 000000000..5a9242a1e --- /dev/null +++ b/agents/dewey/work/queue-90/r2/gate-summary.txt @@ -0,0 +1,23 @@ +start 2026-10-12T23:15:51Z base b8539480aa6a37ecf9c5ea4c2eb3d6182af01934 +manifest 20/20 OK +node tasks rc=0 ℹ tests 89 ℹ pass 89 ℹ fail 0 +node bus rc=0 ℹ tests 111 ℹ pass 111 ℹ fail 0 +node harness rc=0 ℹ tests 66 ℹ pass 66 ℹ fail 0 +node webui rc=0 ℹ tests 101 ℹ pass 101 ℹ fail 0 +node cli rc=1 ℹ tests 120 ℹ pass 112 ℹ fail 8 +node business rc=0 ℹ tests 60 ℹ pass 60 ℹ fail 0 +node seat rc=1 ℹ tests 33 ℹ pass 29 ℹ fail 4 +node control-board rc=0 ℹ tests 124 ℹ pass 124 ℹ fail 0 +test-auth rc=0 selftest: 15 passed, 0 failed +test-conductor rc=0 selftest: 17 passed, 0 failed +test-config rc=0 selftest: 24 passed, 0 failed +test-discord rc=0 discord suite: 66 passed, 0 failed +test-extension-package rc=0 extension package selftest: 18 passed, 0 failed +test-foundation rc=0 selftest: 44 passed, 0 failed +test-queue rc=0 queue suite: 27 passed, 0 failed +test-release rc=0 selftest: 14 passed, 0 failed +test-task rc=0 selftest: 26 passed, 0 failed +end 2026-10-12T23:20:59Z +mutants start 23:20:59Z +mutants rc=0 end 23:25:39Z +mut copy restored, manifest OK diff --git a/agents/dewey/work/queue-90/r2/gate.sh b/agents/dewey/work/queue-90/r2/gate.sh new file mode 100644 index 000000000..a6347e6cc --- /dev/null +++ b/agents/dewey/work/queue-90/r2/gate.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Row 90 round 2 gate: build.patch applied to a fresh detached worktree of b8539480 (row 63 landed), the manifest +# checked, then each package's node suite from its own directory and every scripts/test-*.sh, +# sequential, output kept per suite. +cd $HOME/dewey-scratch/r90c || exit 9 +export TMPDIR=$HOME/dewey-scratch/tmp +OUT=$HOME/dewey-scratch/r90r2-out/gate +mkdir -p $OUT +S=$OUT/../gate-summary.txt +echo "start $(date -u +%Y-%m-%dT%H:%M:%SZ) base $(git rev-parse HEAD)" > $S +echo "manifest $(sha256sum -c $HOME/dewey-scratch/r90r2-out/candidate-manifest.sha256 2>&1 | grep -c ': OK$')/$(wc -l < $HOME/dewey-scratch/r90r2-out/candidate-manifest.sha256) OK" >> $S +for p in tasks bus harness webui cli business seat control-board; do + case $p in seat|webui) a=(tests/);; *) a=(tests/*.test.mjs);; esac + (cd packages/$p && node --test "${a[@]}") > $OUT/node-$p.log 2>&1; rc=$? + echo "node $p rc=$rc $(grep -E '^ℹ (tests|pass|fail)' $OUT/node-$p.log | tr '\n' ' ')" >> $S +done +for f in scripts/test-*.sh; do + n=$(basename $f .sh) + if [ "$n" = test-task ]; then DOCKER_HOST=unix:///nonexistent bash $f > $OUT/$n.log 2>&1; else bash $f > $OUT/$n.log 2>&1; fi; rc=$? + echo "$n rc=$rc $(tail -1 $OUT/$n.log | cut -c1-120)" >> $S +done +echo "end $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $S diff --git a/agents/dewey/work/queue-90/r2/mutants.py b/agents/dewey/work/queue-90/r2/mutants.py new file mode 100644 index 000000000..fa842f2ba --- /dev/null +++ b/agents/dewey/work/queue-90/r2/mutants.py @@ -0,0 +1,88 @@ +# Row 90 round 2 mutants (Dewey): round 1's 44, the same swaps, rerun on the rebased candidate. Each swaps one exact string in the scratch copy, runs +# the tasks, bus or harness node tests, restores the file, and reports killed (tests fail) or SURVIVED. +import subprocess, sys, os, signal +M = os.path.expanduser('~/dewey-scratch/r90mut2') +env = dict(os.environ, TMPDIR=os.path.expanduser('~/dewey-scratch/tmp')) +R = 'packages/tasks/src/requirements.mjs' +J = 'packages/tasks/requirements.json' +V = 'packages/tasks/src/verbs.mjs' +B = 'packages/bus/src/broker.mjs' +T = 'packages/harness/src/tools.mjs' +RU = 'packages/harness/src/runner.mjs' +BU = 'packages/harness/src/bundle.mjs' +TASKS = ['packages/tasks/tests/*.test.mjs'] +BUS = ['packages/bus/tests/*.test.mjs'] +HAR = ['packages/harness/tests/*.test.mjs'] +muts = [ + # The list against the PRD. + ('list order swapped', J, '"id": "REQ-VAR-1", "title": "Variables', '"id": "REQ-VAR-9", "title": "Variables', TASKS), + ('list two ids swapped', J, '{ "id": "REQ-VAR-1", "title": "Variables in four layers, declared in code; limits intersect; unknown keys refused" },\n { "id": "REQ-VAR-2", "title": "One system config, never auto-written; secrets only as references the broker reads" },', + '{ "id": "REQ-VAR-2", "title": "One system config, never auto-written; secrets only as references the broker reads" },\n { "id": "REQ-VAR-1", "title": "Variables in four layers, declared in code; limits intersect; unknown keys refused" },', TASKS), + ('list drops the last id', J, ',\n { "id": "REQ-EVT-1", "title": "Every slice 1 step emits an event; measures come from events" }', '', TASKS), + ('list adds REQ-QA-1', J, ' { "id": "REQ-EVT-1",', ' { "id": "REQ-QA-1", "title": "QA" },\n { "id": "REQ-EVT-1",', TASKS), + ('list version 1.1', J, '"version": "1.0"', '"version": "1.1"', TASKS), + # The schema. + ('top keys open', R, "exact(list, 'list', ['prd', 'version', 'requirements']);", "if (!plain(list)) throw new RequirementsError('list: not an object');", TASKS), + ('prd pattern dropped', R, " || !/^docs\\/prd\\/[a-z0-9-]+\\.md$/.test(list.prd)", "", TASKS), + ('prd takes a parent step', R, "/^docs\\/prd\\/[a-z0-9-]+\\.md$/", "/^docs\\/prd\\/[a-z0-9.\\/-]+\\.md$/", TASKS), + ('version any type', R, "typeof list.version !== 'string' || ", "", TASKS), + ('version 0 major', R, "/^[1-9][0-9]*\\.[0-9]+$/", "/^[0-9]+\\.[0-9]+$/", TASKS), + ('version minor optional', R, "/^[1-9][0-9]*\\.[0-9]+$/", "/^[1-9][0-9]*(\\.[0-9]+)?$/", TASKS), + ('empty list allowed', R, " || !list.requirements.length", "", TASKS), + ('entry keys open', R, "exact(r, at, ['id', 'title']);", "if (!plain(r)) throw new RequirementsError(`${at}: not an object`);", TASKS), + ('id numbered 0', R, "/^REQ-[A-Z]+-[1-9][0-9]*$/;", "/^REQ-[A-Z]+-[0-9]+$/;", TASKS), + ('id suffix allowed', R, "/^REQ-[A-Z]+-[1-9][0-9]*$/;", "/^REQ-[A-Z]+-[1-9][0-9]*/;", TASKS), + ('id twice allowed', R, " if (seen.has(r.id)) throw new RequirementsError(`${at}.id: ${r.id} twice`);\n", "", TASKS), + ('title 121', R, "!str(r.title, 120)", "!str(r.title, 121)", TASKS), + ('title 119', R, "!str(r.title, 120)", "!str(r.title, 119)", TASKS), + ('title newline allowed', R, " || r.title.includes('\\n')", "", TASKS), + ('blank title allowed', R, "typeof x === 'string' && x.trim() !== '' &&", "typeof x === 'string' &&", TASKS), + ('not frozen', R, " Object.freeze(x);\n", "", TASKS), + ('frozen shallow', R, " for (const v of Object.values(x)) freeze(v);\n", "", TASKS), + ('unchecked load', R, "return freeze(checkRequirements(list));", "return freeze(list);", TASKS), + ('not JSON reported as unreadable', R, "e instanceof SyntaxError ? 'requirements.json: not JSON' :", "e instanceof SyntaxError ? 'requirements.json: unreadable' :", TASKS), + ('ENOENT reported as unreadable', R, "`requirements.json: ${e.code ?? 'unreadable'}`", "'requirements.json: unreadable'", TASKS), + # task.create. + ('unknown-requirement dropped', V, " if (!requirements.has(plan.requirement)) fail('unknown-requirement');\n", "", TASKS), + ('unknown-requirement as invalid-request', V, "fail('unknown-requirement')", "fail('invalid-request')", TASKS), + ('only the first id', V, "loadRequirements().requirements.map((r) => r.id)", "loadRequirements().requirements.slice(0, 1).map((r) => r.id)", TASKS), + ('all but the last id', V, "loadRequirements().requirements.map((r) => r.id)", "loadRequirements().requirements.slice(0, -1).map((r) => r.id)", TASKS), + # The bus read. + ('requirements agent guard dropped', B, " keys(a, []);\n this.#agent(s);\n return this.#requirements;", " keys(a, []);\n return this.#requirements;", BUS), + ('requirements keys dropped', B, " keys(a, []);\n this.#agent(s);\n return this.#requirements;", " this.#agent(s);\n return this.#requirements;", BUS), + ('requirements returns the ids only', B, "return this.#requirements;", "return this.#requirements.requirements;", BUS), + ('constructor loads nothing', B, "this.#requirements = loadRequirements();", "this.#requirements = null;", BUS), + # The harness. + ('list_requirements ungated', T, " if (held.has(\"task.create\"))\n add(\"list_requirements\"", " add(\"list_requirements\"", HAR), + ('list_requirements gated on task.assign', T, "if (held.has(\"task.create\"))", "if (held.has(\"task.assign\"))", HAR), + ('list_requirements without tracker', T, " // Row 90 (#1584): the ids task_create takes, for the sessions that hold it.\n if (held.has", " }\n {\n if (held.has", HAR), + ('list_requirements wrong verb', T, "add(\"list_requirements\", \"requirements\",", "add(\"list_requirements\", \"tasks\",", HAR), + ('list_requirements no prd.approve', T, " When none fits a request, raise a prd.approve decision instead of creating the task.", "", HAR), + ('request description old', T, "str(\"The request id of the human message this task answers, shown as \\\"request \\\" in that message's header; not the message's own id\")", "str(\"Id of the message or human input this task answers\")", HAR), + ('requirement description old', T, "Requirement id from list_requirements, such as REQ-CRED-2", "Requirement id, such as REQ-CRED-2", HAR), + ('blocking description old', T, "\"True only for a decision that holds a task: it needs task_ref, and the task waits for the answer. A request with no task is false\"", "\"True when you can't continue without the answer\"", HAR), + ('turn request always', RU, "if (m.request) head.push(`request ${m.request}`);", "head.push(`request ${m.request}`);", HAR), + ('turn request dropped', RU, " if (m.request) head.push(`request ${m.request}`);\n", "", HAR), + ('stop line dropped', BU, " \"- Only the human stops a run, with `mosaic stop`. If you think a run should stop, raise a role.revoke decision with blocking false and the role instance as target, naming the run and saying why.\",\n", "", HAR), +] +only = sys.argv[1:] +for name, f, old, new, tests in muts: + if only and not any(o in name for o in only): continue + p = os.path.join(M, f); src = open(p).read() + n = src.count(old) + if n != 1: print(f'BAD-ANCHOR ({n}) {name}', flush=True); continue + open(p, 'w').write(src.replace(old, new)) + # Its own process group, so a hung test's children die with it (a mutant that loops forever + # never fails on its own; a hang is a kill). + proc = subprocess.Popen(['node', '--test', *tests], cwd=M, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, start_new_session=True) + try: + out, _ = proc.communicate(timeout=300) + except subprocess.TimeoutExpired: + os.killpg(proc.pid, signal.SIGKILL); proc.communicate() + out = None + finally: + open(p, 'w').write(src) + if out is None: + print(f'killed {name} (hung: killed after 300 s)', flush=True); continue + fails = [l for l in out.splitlines() if l.startswith('not ok')] + print(('killed ' if proc.returncode else 'SURVIVED') + f' {name}' + (f' ({len(fails)} failing)' if fails else ''), flush=True) diff --git a/agents/dewey/work/queue-90/r2/mutants.txt b/agents/dewey/work/queue-90/r2/mutants.txt new file mode 100644 index 000000000..8d0ccb30f --- /dev/null +++ b/agents/dewey/work/queue-90/r2/mutants.txt @@ -0,0 +1,44 @@ +killed list order swapped +killed list two ids swapped +killed list drops the last id +killed list adds REQ-QA-1 +killed list version 1.1 +killed top keys open +killed prd pattern dropped +killed prd takes a parent step +killed version any type +killed version 0 major +killed version minor optional +killed empty list allowed +killed entry keys open +killed id numbered 0 +killed id suffix allowed +killed id twice allowed +killed title 121 +killed title 119 +killed title newline allowed +killed blank title allowed +killed not frozen +killed frozen shallow +killed unchecked load +killed not JSON reported as unreadable +killed ENOENT reported as unreadable +killed unknown-requirement dropped +killed unknown-requirement as invalid-request +killed only the first id +killed all but the last id +killed requirements agent guard dropped +killed requirements keys dropped +killed requirements returns the ids only +killed constructor loads nothing +killed list_requirements ungated +killed list_requirements gated on task.assign +killed list_requirements without tracker +killed list_requirements wrong verb +killed list_requirements no prd.approve +killed request description old +killed requirement description old +killed blocking description old +killed turn request always +killed turn request dropped +killed stop line dropped