Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -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 |
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user