dewey: row 90 round 1 packet, base 23338c7b, manifest 26d1fbdf (#1584)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-12 17:47:32 -05:00
co-authored by Claude Opus 5.5
parent 1e4cfa960a
commit 950e84030b
8 changed files with 1135 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
# Row 90 (#1584): The approved requirement ids as data, round 1
Owner: Dewey. Reviewer: Filbert, then Sage's gate. Brief:
`docs/plans/2026-10-12_pm-task-flow.md`, section "The approved requirement
ids as data" (blob `e8a1150e`). Also PM fault 10 (#228 c91), covered by
the stop line.
Base `23338c7b` (queue rev 798). `build.patch` is
`git diff --cached --binary 23338c7b --` the twenty paths in
`candidate-manifest.sha256`. Apply it with `git apply --index` to a detached
worktree of the base; the manifest then checks 20/20 OK. No commit from the
base to `refactor` `1e4cfa96` touches the twenty paths (checked at
packaging).
| File | What changed |
|---|---|
| `packages/tasks/requirements.json` | new: the PRD's path and version, and its 24 ids, each with a one-line title |
| `packages/tasks/src/requirements.mjs` | new: `checkRequirements` (the file's schema), `loadRequirements` (checked, frozen) |
| `packages/tasks/src/verbs.mjs` | `task.create` refuses `unknown-requirement` for an id the list doesn't hold; the format check stays |
| `packages/bus/src/broker.mjs` | the constructor loads the list; a `requirements` read for role sessions |
| `packages/harness/src/tools.mjs` | `list_requirements` for task.create holders with a tracker; `task_create`'s `request` and `requirement`, `raise_decision`'s `blocking` |
| `packages/harness/src/runner.mjs` | the turn header names a message's `request` id (outside the file list; see Decisions) |
| `packages/harness/src/bundle.mjs` | one session-text line: only the human stops a run |
| `roles/pm.md` | two Duties bullets (the id from `list_requirements`, the request id; no id fits: a non-blocking `prd.approve`) and two Authority paragraphs (blocking holds a task; stopping a run) |
| `packages/tasks/README.md` | `requirements.json`, the `task.create` detail, `unknown-requirement` in Refusals |
| `packages/bus/README.md` | the `requirements` row; `decision.raise`'s `task-required` rule |
| `packages/tasks/tests/requirements.test.mjs` | new: the list against the PRD, frozen, each schema refusal, the load errors |
| `packages/tasks/tests/verbs.test.mjs` | two tests: only a listed id creates; a received message's `request` creates and its id refuses |
| `packages/tasks/tests/{adapter,fields,shapes,sync}.test.mjs` | `REQ-S-1`, not in the PRD, becomes `REQ-TASK-1` |
| `packages/bus/tests/requirements.test.mjs` | new: who can read `requirements`; the PM's non-blocking `prd.approve` and `role.revoke` with no task reach the human's Inbox; blocking still refuses `task-required` |
| `packages/harness/tests/tools.test.mjs` | the PM's tool list; `list_requirements`'s gating, verb and text; the three descriptions; the row 89 byte count (below) |
| `packages/harness/tests/runner.test.mjs` | the turn header with and without a `request` |
| `packages/harness/tests/bundle.test.mjs` | the stop line |
## What the PM sees now
- **Which ids exist.** `list_requirements` returns
`{prd, version, requirements:[{id,title}]}`, 24 ids for PRD 1.0. Its
text says `task_create` takes only these, and that a request no id fits
gets a `prd.approve` decision instead of a task. `task_create` refuses
any other id with `unknown-requirement`, so `REQ-QA-1` (P19 #229, #230)
now refuses.
- **The request id.** The turn header of a human message reads
`Message <id> from jason, class REQUEST, request <event id>:`.
`task_create`'s `request` says to take that `request <id>`, not the
message's own id. Before this change the header left `request` out, so
the PM had only the message id, which refuses `request-not-found`.
- **The Inbox path without a task.** `raise_decision`'s `blocking` says a
blocking decision holds a task and needs `task_ref`, and a request with
no task is false. `pm.md` says the same, and that a gated decision with
no task still reaches the human's Inbox. The bus test raises both
`prd.approve` and `role.revoke` from the PM that way and reads them in
the human's (and reader's) inbox.
- **Stopping a run.** The session text and `pm.md` say only the human stops
a run, with `mosaic stop`; a session that thinks a run should stop raises
`role.revoke`, `blocking` false, the role instance as target, naming the
run and saying why. The bundle line goes to every role session, not only
the PM, since every session holds `raise_decision`.
## Decisions
1. **`runner.mjs` is outside the brief's file list.** The brief says each
message `message.receive` returns carries `request`, and the tool should
say to use it. The broker does attach it (`broker.mjs` message.receive),
but `turnRequest` built the session's turn text from `id`, `from_role`,
`class`, `in_reply_to` and `decision` only. Without one line there, the
new `request` description would point the PM at a field it never sees.
I told Sage before packaging; Sage's ruling: keep it, name it here and in
the review request. Row 93 bases its `runner.mjs` on this one if row 90
lands first (Sage's note on row 93, rev 800).
2. **The broker loads the list in its constructor**, so a bad
`requirements.json` refuses at bus start, not at the first read. The
tasks adapter loads it again in `verbs(ctx)`, at adapter start. Both load
the same committed file.
3. **`requirements` is an agent read (`#agent`)**, the same guard as
`task.create`: a human session refuses `agent-required`, a reader
`read-only`, a role session that doesn't hold its role `not-holder`. The
bus doesn't also check that the role holds `task.create`. The list is the
approved PRD's ids, nothing secret, and the harness gives the tool only to
task.create holders. The reader allowlist in `request()` is unchanged.
4. **`requirements.mjs` imports nothing from the bus**, so the broker can
import it statically without a cycle (tasks already imports the broker).
Its errors are `RequirementsError`, as `fields.mjs`'s are
`FieldTableError`.
5. **Tests use `REQ-TASK-1`, a real id, in place of `REQ-S-1`**, with no
seam to inject a test list. Sage agreed.
6. **The titles are mine**, one line of 120 characters or fewer, written
from each PRD bullet. The test checks ids, order and version against the
PRD, not the titles. A reviewer may want different wording; changing a
title changes no behaviour.
## Limits
- A bad file at bus start shows as `startup-refused` over IPC.
`process.mjs` maps any error that isn't a `BusError` to that code, so the
`RequirementsError` message (`requirements[3].id: not a requirement id`)
doesn't reach the host. The start still refuses. Making it a `BusError`
would change the boot protocol, outside this row.
- `packages/harness/README.md` isn't in the file list, so it doesn't yet
name `list_requirements` or the `request` in the turn header. A one-line
follow-up if the reviewer wants it.
- `packages/tasks/src/index.mjs` exports `loadFields` but not
`loadRequirements`. Nothing imports the list through the index; adding the
export is outside the file list.
- Nothing here reads the PRD at run time. A later PRD version needs a new
`requirements.json` in a reviewed commit, and the test fails until it
matches.
- #229 and #230 keep their `REQ-QA-1`; correcting them is the PM's and
Jason's (out of scope in the brief).
- Live at the next bus restart and PM launch (decision 109): Mos is notified
before landing.
## Correction to row 89
Row 89's `packages/harness/tests/tools.test.mjs:115` comment and its
`build.md` (line 183) say 19999 emoji in a JSON string are "80002 bytes".
They are 79998: 19999 × 4 + 2 quotes (checked with `Buffer.byteLength`).
This row fixes the test comment. Row 89's packet stays as written; this is
the correction entry.
## Gate (my run on the candidate)
`gate.sh` on a fresh detached worktree of `23338c7b` with `build.patch`
applied by `git apply --index`; manifest 20/20 OK; suites sequential, each
node suite from its package directory. 2026-10-12T22:43:29Z to 22:46:50Z.
Summary in `gate-summary.txt`.
| Suite | Result |
|---|---|
| tasks node | 79 pass, 0 fail |
| bus node | 111 pass, 0 fail |
| harness node | 66 pass, 0 fail |
| webui node | 86 pass, 0 fail |
| cli node | 115 pass, 0 fail |
| business node | 60 pass, 0 fail |
| seat node | 27 pass, 0 fail |
| control-board node | 124 pass, 0 fail |
| test-auth | 15 passed |
| test-conductor | 17 passed |
| test-config | 24 passed |
| test-discord | 66 passed |
| test-extension-package | 18 passed |
| test-foundation | 44 passed |
| test-queue | 27 passed |
| test-release | 14 passed |
| test-task | 26 passed; run with `DOCKER_HOST=unix:///nonexistent`, so it skips the adapter seam, workspace/capability, live task and live recall cases (docker unavailable) |
Every rc is 0. No commit from the base to `refactor` `1e4cfa96` touches the
twenty paths.
## Mutants
`mutants.py` swaps one exact string in a scratch copy of the candidate (a
plain directory, its 20 files byte-identical to the candidate when run 2
ended), runs the tasks, bus or harness node tests in their own process
group (killed after 300 s, which counts as a kill), and restores the file.
44 mutants.
- **Run 1** (`mutants-run1.txt`): 43 killed, 1 survived, `empty list
allowed`. The cause was the test, not the code: the schema table's loop
used an edit's return value when it was an array, so
`(l) => (l.requirements = [])` handed `checkRequirements` the `[]` itself
and tested "not an object" instead of the empty list. The fix: a separate
not-an-object loop for `[]`, `null` and a string, and the table now edits
in place and ignores the return. I also changed "a numeric version" from
`1` to `1.5`, so it can't pass the version regex if the type check goes.
- **Run 2** (`mutants-run2.txt`): after the fix, `list version 1.1` and
the 20 mutants on `requirements.mjs`, which the changed schema and load
tests cover. 21 killed. The other 23 were killed in run 1 by tests the
fix didn't touch.
| Mutant | Site | What it undoes |
|---|---|---|
| list order swapped, two ids swapped, drops the last id, adds REQ-QA-1, version 1.1 | `requirements.json` | the list equals the PRD's ids, order and version |
| top keys open, entry keys open | `requirements.mjs` | unknown keys refuse |
| prd pattern dropped, prd takes a parent step | `requirements.mjs` | `prd` is a `docs/prd/*.md` path with no `..` |
| version any type, version 0 major, version minor optional | `requirements.mjs` | `version` is a `N.M` string, N from 1 |
| empty list allowed | `requirements.mjs` | at least one id |
| id numbered 0, id suffix allowed, id twice allowed | `requirements.mjs` | the id format, anchored; no id twice |
| title 121, title 119, title newline allowed, blank title allowed | `requirements.mjs` | a title is 1 to 120 characters, one line |
| not frozen, frozen shallow, unchecked load | `requirements.mjs` | the loaded list is checked and deep-frozen |
| not JSON / ENOENT reported as unreadable | `requirements.mjs` | the load error names the cause |
| unknown-requirement dropped, as invalid-request | `verbs.mjs` | an unlisted id refuses `unknown-requirement` |
| only the first id, all but the last id | `verbs.mjs` | every listed id creates |
| requirements agent guard dropped, keys dropped, returns the ids only, constructor loads nothing | `broker.mjs` | the read's guard, its no-argument rule, its shape, the load at start |
| list_requirements ungated, gated on task.assign, without tracker, wrong verb, no prd.approve | `tools.mjs` | who gets the tool, its verb, its text |
| request, requirement, blocking description old | `tools.mjs` | the three new descriptions |
| turn request always, turn request dropped | `runner.mjs` | the header names `request` only when there is one |
| stop line dropped | `bundle.mjs` | the session text's stop line |
+731
View File
@@ -0,0 +1,731 @@
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 f0011d30..071f89b3 100644
--- a/packages/harness/src/tools.mjs
+++ b/packages/harness/src/tools.mjs
@@ -43,8 +43,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 <id>\" 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"]],
@@ -102,7 +102,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,
@@ -128,6 +128,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 045f11c1..3ec93aa8 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");
@@ -26,6 +26,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 <id>" 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 });
@@ -112,6 +133,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/tasks/README.md b/packages/tasks/README.md
index ff675343..33e588aa 100644
--- a/packages/tasks/README.md
+++ b/packages/tasks/README.md
@@ -129,6 +129,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
@@ -178,8 +189,11 @@ 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`. `task.created` is recorded even when a label or relation
+ 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. `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.
- `task.assign` refuses a task a bot already holds (`task-assigned`).
@@ -358,7 +372,7 @@ on a live read with the sync key, and a switch is a follow-up row.
| `tracker-shape`, `tracker-paging`, `tracker-unexpected` | Vikunja sent something the probes didn't show |
| `task-moved`, `task-placement`, `task-project` | the task is in another project, or on no bucket |
| `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/<name>.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 9bf38511..43189abf 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
@@ -62,6 +63,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');
@@ -185,6 +188,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 c9ea98a6..3c5c03c3 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 } 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 <id>` 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.
@@ -0,0 +1,20 @@
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
bf2af42715acad35972ccca877fa9da81758a09cd2d2d876d3b70b8de98c23a3 packages/harness/src/tools.mjs
bf4e7fad255338000e703a83f12a4568e1bae0211cba187e43af0ddb1d2d99cb packages/harness/tests/bundle.test.mjs
0ff62327f91b7dfddc4a9e8fa4c5af42d0d40e7c67ff0aabf508de2a2aa19c4a packages/harness/tests/runner.test.mjs
98c97f5ee45c61a0d2e664d58723a705147c8292957727e06051c5ae8a7889b3 packages/harness/tests/tools.test.mjs
31a31ecf5564c1d5f2000448c5fb2bb7a622420ed659c378e3ecd48902b400e5 packages/tasks/README.md
2cd2616bd5755472355f580e42ebc0e2545dffe9c1062f617a61e22c117e86b2 packages/tasks/requirements.json
38e7b85650247759f06c09fce205b2970b8368bc81491f51dc6b3c90ba2a846d packages/tasks/src/requirements.mjs
a95222a830f8f7d201b070ca8e8779ef17d77a8d337068ec700926fe55aa46b4 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
62bc3f212bf29d2182e7daa247bf01bc8eb14ac6fefc3d886e3156ded9a2f114 packages/tasks/tests/verbs.test.mjs
73ce966340f9d4369b1c6d7854829d4cf60fdb1cb673d38287b914d107ccf053 roles/pm.md
@@ -0,0 +1,20 @@
start 2026-10-12T22:43:29Z base 23338c7b0e412f02dddb55d1ae2c178c5981f232
manifest 20/20 OK
node tasks rc=0 ℹ tests 79 ℹ pass 79 ℹ 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 86 ℹ pass 86 ℹ fail 0
node cli rc=0 ℹ tests 115 ℹ pass 115 ℹ fail 0
node business rc=0 ℹ tests 60 ℹ pass 60 ℹ fail 0
node seat rc=0 ℹ tests 27 ℹ pass 27 ℹ fail 0
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-12T22:46:50Z
+22
View File
@@ -0,0 +1,22 @@
#!/bin/bash
# Row 90 gate: build.patch applied to a fresh detached worktree of the base, 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/r90gate || exit 9
export TMPDIR=$HOME/dewey-scratch/tmp
OUT=$HOME/dewey-scratch/r90-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/r90-out/candidate-manifest.sha256 2>&1 | grep -c ': OK$')/$(wc -l < $HOME/dewey-scratch/r90-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
@@ -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
SURVIVED 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
@@ -0,0 +1,21 @@
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
+88
View File
@@ -0,0 +1,88 @@
# Row 90 mutants (Dewey): each swaps one exact string in the scratch copy of the candidate, 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/r90mut')
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 <id>\\\" 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)