@arjunkhera/atlas 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
package/README.md CHANGED
@@ -18,7 +18,7 @@ Atlas sets how a repository is worked on. It is one Claude Code plugin with:
18
18
  Run this once, in a terminal:
19
19
 
20
20
  ```bash
21
- npx @arjunkhera/atlas@0.2.0 install
21
+ npx @arjunkhera/atlas install
22
22
  ```
23
23
 
24
24
  It shows what it will change, and asks first. Then open Claude Code, run
@@ -6,7 +6,7 @@ description: >
6
6
  Claude-website family, written for a smart newcomer ("assume like a fresher"), diagram-rich,
7
7
  with every stable id backlinked to its definition. Invoked by the sdlc-task design loop and
8
8
  any session presenting a design/task/review to the owner — rendering is mechanical work and
9
- runs on Sonnet by program principle 9. Input: a repo markdown doc path (+ optional emphasis
9
+ runs on Sonnet (sdlc-task skill, "Model tiering"). Input: a repo markdown doc path (+ optional emphasis
10
10
  notes). Output: a single self-contained HTML file written to the path the caller names.
11
11
  It renders; it never publishes (the calling session owns the Artifact call and the
12
12
  registered URL) and never edits the source doc.
@@ -6,7 +6,8 @@ description: >
6
6
  reading files inline. Searches and reads inside the worktree it is briefed with ($WT) and
7
7
  returns a tight findings digest with file:line references; raw file contents never enter the
8
8
  caller's context. Callable directly for a single "where is X" lookup, or as one arm of a
9
- design-loop research crew (lifecycle spec §10, context-firewall rule). Read-only — it never
9
+ design-loop research crew (the design loop in the sdlc-task skill: digests only, raw
10
+ exploration never enters the session). Read-only — it never
10
11
  edits.
11
12
  model: sonnet
12
13
  tools: Read, Grep, Glob, Bash
@@ -2,10 +2,10 @@
2
2
  name: reviewer-architect
3
3
  description: >
4
4
  Adversarial design-review persona: the Architect. One lens of the design-loop review panel
5
- (lifecycle spec §10; roster in the sdlc-task skill). Reviews a design document or diff for
5
+ (roster in the sdlc-task skill, "Adversarial review before lock"). Reviews a design document or diff for
6
6
  structural soundness — boundaries, data model, failure modes, operational cost on THIS
7
7
  system's real constraints. Invoked with a doc/diff path; returns verdicts + findings only.
8
- Review is judgment work (program principle 9), so this persona inherits the session's
8
+ Review is judgment work (sdlc-task skill, "Model tiering"), so this persona inherits the session's
9
9
  frontier model.
10
10
  model: inherit
11
11
  tools: Read, Grep, Glob, Bash
@@ -2,10 +2,10 @@
2
2
  name: reviewer-pm
3
3
  description: >
4
4
  Adversarial design-review persona: the PM. One lens of the design-loop review panel
5
- (lifecycle spec §10; roster in the sdlc-task skill). Reviews a design document for
5
+ (roster in the sdlc-task skill, "Adversarial review before lock"). Reviews a design document for
6
6
  problem-fit, scope honesty, and acceptance-criteria quality — is this the right thing to
7
7
  build, sliced right, with testable AC? Invoked with a doc path; returns verdicts +
8
- findings only. Review is judgment work (program principle 9), so this persona inherits
8
+ findings only. Review is judgment work (sdlc-task skill, "Model tiering"), so this persona inherits
9
9
  the session's frontier model.
10
10
  model: inherit
11
11
  tools: Read, Grep, Glob, Bash
@@ -29,7 +29,7 @@ Ground rules:
29
29
  where it exists (the product documents, roadmap or feature list its entry map names) — a
30
30
  feature that duplicates or contradicts ratified canon is a finding.
31
31
  - The owner is a solo technical founder dogfooding their own product; owner-minutes are the
32
- scarcest resource in the whole system (program principle 8). Weigh every scope decision
32
+ scarcest resource in the whole system. Weigh every scope decision
33
33
  against that.
34
34
  - Attack, in order: problem-fit (does the Context section describe a real, current pain —
35
35
  or a hypothetical?) · scope honesty (what's smuggled in beyond the stated goal? what
@@ -2,12 +2,13 @@
2
2
  name: reviewer-security
3
3
  description: >
4
4
  Adversarial design-review persona: Security. One lens of the design-loop review panel
5
- (lifecycle spec §10; roster in the sdlc-task skill). Reviews a design document or diff
5
+ (roster in the sdlc-task skill, "Adversarial review before lock"). Reviews a design document or diff
6
6
  for tenant-isolation breaks, principal-boundary bypasses, secret handling, and abuse
7
7
  paths — the repo's own invariants first, generic OWASP second. An S1 here blocks the lock via the
8
- open-questions gate (spec §4 `open_questions_empty`); post-lock, security is a tripwire
9
- (LC-6). Review is judgment work (program principle 9), so this persona inherits the
10
- session's frontier model.
8
+ open-questions gate (`open_questions_empty` in the sdlc-task `lifecycle.yaml`
9
+ lock preconditions); post-lock, security is a tripwire (`lifecycle.yaml` `tripwires`).
10
+ Review is judgment work, so this persona inherits the session's frontier model (sdlc-task
11
+ skill, "Model tiering").
11
12
  model: inherit
12
13
  tools: Read, Grep, Glob, Bash
13
14
  ---
@@ -19,7 +20,8 @@ an attacker — or a confused agent — takes through the design in front of you
19
20
  you never fix, never edit. Your S1 findings must be recorded as **unchecked open-questions
20
21
  items on the design doc** — that is what mechanically blocks the lock (the
21
22
  `open_questions_empty` precondition) until they are resolved or explicitly owner-accepted.
22
- Post-lock, security remains a tripwire category (lifecycle spec §5).
23
+ Post-lock, security remains a tripwire category (`tripwires` in the sdlc-task skill's
24
+ `lifecycle.yaml`).
23
25
 
24
26
  First, learn what this repo is. Read its `CLAUDE.md`, its `atlas.yaml` (the
25
27
  `kind` says whether it is a service, a library or a schema repo), and the
@@ -40,8 +42,9 @@ Ground rules:
40
42
  trust boundary · injection through payloads, webhooks or files an agent reads · resource
41
43
  exhaustion (unbounded buffers, missing timeouts, fanout without caps) · data exposure in
42
44
  logs, snapshots, or artifacts (artifacts publish OUTSIDE the repo — anything rendered into
43
- one is effectively public to whoever holds the URL). A library or schema repo has no
44
- server: ask what its callers trust it to do.
45
+ one is effectively public to whoever holds the URL). A library repo has no server: ask
46
+ what its callers trust it to do. A schema repo changes a running database
47
+ after the merge: ask what the migration can reach.
45
48
  - Every finding needs the attack path spelled out (actor → entry → step → impact). No
46
49
  path, no finding — downgrade to a question.
47
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, as a Claude Code plugin for any repository.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -30,6 +30,8 @@ checks and the transition log; each verb's own description says what it needs.
30
30
  | Record new work, with the owner's words | `item_propose`, then `item_stage` as it moves |
31
31
  | Freeze the design | `item_lock` |
32
32
  | Park it, or pick it up again | `item_park`, `item_resume` |
33
+ | Drop it, in the owner's words (`item_resume` undoes a drop) | `item_drop` |
34
+ | Keep its next step, its wait or its title true | `item_edit` |
33
35
  | Split it, or link it to other work | `item_split`, `item_link` |
34
36
  | Ask the owner something, or record the answer | `question_ask`, `question_answer` |
35
37
  | Record a decision in the owner's words | `decision_record` |
@@ -142,6 +144,9 @@ is confirmed. Then:
142
144
  resume anchor from [`templates/resume-anchor.md`](templates/resume-anchor.md)
143
145
  (hotfix). The bar: a fresh session needs nothing else.
144
146
  2. Call `item_park` with the reason. A parked item keeps its stage.
147
+ 3. Before the session ends, keep each item you touched true: call
148
+ `item_edit` with its next step and what it waits on. If the owner retires
149
+ a piece of work, call `item_drop` with his words.
145
150
 
146
151
  ## resume
147
152
 
@@ -160,8 +165,9 @@ next action. One short paragraph.
160
165
  ## done
161
166
 
162
167
  1. Prove delivery the way the repo's `kind` requires, then call `item_done`. A
163
- `library` or `schema` repo proves it by merged pull requests and a green
164
- gate. A `service` repo also needs its live checks.
168
+ `library` repo proves it by merged pull requests and a green gate. A
169
+ `service` or `schema` repo also needs its live checks, because a schema
170
+ migration reaches the running node after the merge.
165
171
  2. If anything was a struggle, encode the fix where it stops a repeat — a lint,
166
172
  then a tool, then a skill, then a doc — and say which.
167
173
  3. Design doc status: `SHIPPED <date>`.
@@ -2,7 +2,9 @@
2
2
  #
3
3
  # Atlas is the package that sets how a repo is worked on, and this is its
4
4
  # lifecycle. It travels with the package, beside the skill that reads it, not
5
- # with any one repo. A verb reads these lists; it never hard-codes them.
5
+ # with any one repo. The skill reads these lists. The work verbs still keep
6
+ # their own copies (STAGES and LIVE_LIFECYCLES in work/lib), and the stage
7
+ # lists differ on ship; the register records that gap.
6
8
  #
7
9
  # The values came from the old ops pack, unchanged. That pack and every script
8
10
  # that read it are deleted.
@@ -1,4 +1,4 @@
1
- <!-- Living design document — template (lifecycle spec §7, LC-5).
1
+ <!-- Living design document — template (sdlc-task skill, "The design loop").
2
2
  Copy to docs/design-docs/<slug>.md at `sdlc-task start` (standard/initiative
3
3
  tiers). Sections may be trimmed at standard tier; never at initiative.
4
4
  The artifact projection republishes on change (D14: git is truth).
@@ -91,7 +91,7 @@ Standard tier: may collapse to "approach + rejected alternative, one line".>
91
91
 
92
92
  ## Acceptance criteria
93
93
 
94
- <!-- These freeze at lock (LC-6). Write them testable — the red spec derives
94
+ <!-- These freeze at lock (`lifecycle.yaml` `lock.freezes`). Write them testable — the red spec derives
95
95
  from this list. -->
96
96
 
97
97
  1. …
@@ -1,16 +1,18 @@
1
- <!-- Resume anchor — template (lifecycle spec §8).
1
+ <!-- Resume anchor — template (sdlc-task skill, "pause").
2
2
  For work BELOW the design-doc threshold: hotfix tier, bug investigations,
3
- any interrupted queue work. Posted as a comment on the issue (queue lane)
4
- or the container thread (conversation lane) at every pause AND at stage
5
- transitions of long-running work.
6
- The bar (and the eval): a fresh session given only this anchor proceeds
7
- without asking the owner anything the anchor should have contained. -->
3
+ any interrupted work. Write it at every pause AND at stage transitions of
4
+ long-running work. Keep it where the work item can point at it: a file in
5
+ the repo or a comment on the issue, attached with `item_link`. Put its
6
+ next action in the item's next step with `item_edit`.
7
+ The bar: a fresh session given only this anchor proceeds without asking
8
+ the owner anything the anchor should have contained. No eval checks this
9
+ bar yet; the reviewer judges it. -->
8
10
 
9
11
  ## ⏸ Resume anchor — <date>
10
12
 
11
13
  **Goal:** <one line, owner-verbatim where possible>
12
14
 
13
- **State:** lifecycle `<active|paused>` · stage `<capture|spec|build|verify|ship|learn>`
15
+ **State:** lifecycle `<active|paused>` · stage `<capture|spec|build|verify|learn>`
14
16
 
15
17
  **Findings so far:** <POINTER to where they live — issue comment link, doc
16
18
  path, graph entity id. Never duplicate content inline.>
@@ -15,7 +15,7 @@
15
15
  // scrubs by pattern. The rendered answer is scrubbed once more before it
16
16
  // leaves the door (R12).
17
17
  import { refuse, text, isoDate, stringList } from './verb-fields.mjs';
18
- import { oneLine, scrubSecrets, ROW_LINE_MAX } from './questions.mjs';
18
+ import { oneLine, scrubSecrets, ROW_LINE_MAX, priorityOrderOf, byPriority, orderNote, waitsOnLine } from './questions.mjs';
19
19
  import { listLine, legacySealOf, marksByItem, movementOf, pullsOfItem, readPullsByRepo, correctionsOf, daysBetween } from './delivery.mjs';
20
20
  import { acceptCodeRead, codeReadBrief } from './code-read.mjs';
21
21
 
@@ -259,7 +259,7 @@ export function createCapabilityVerbs(core) {
259
259
  const born = fields.created_at ?? item.created_at ?? null;
260
260
  proposed.push({ ...base, age_days: born ? daysBetween(born, readAt) : null, proposed_at: born, driver: clean(fields.driver ?? '', 64) });
261
261
  } else if (lifecycle !== 'done' && !deliveries.length) {
262
- omitted.push({ item: item.id, title: clean(item.title, 120), reason: `the item is ${clean(lifecycle, 32)}; paused and archived items are backlog (D3 rule 1)` });
262
+ omitted.push({ item: item.id, title: clean(item.title, 120), reason: lifecycle === 'dropped' ? 'the owner dropped this item' : `the item is ${clean(lifecycle, 32)}; paused and archived items are backlog (D3 rule 1)` });
263
263
  }
264
264
  }
265
265
  inProgress.sort((a, b) => (a.days_since_last_change ?? Number.MAX_SAFE_INTEGER) - (b.days_since_last_change ?? Number.MAX_SAFE_INTEGER) || a.item.localeCompare(b.item));
@@ -382,8 +382,10 @@ export function createCapabilityVerbs(core) {
382
382
  // does not carry: an item with none reads "unregistered", as the six-part
383
383
  // answer already renders it.
384
384
  const items = only ? rows.filter((row) => row.fields?.product === only) : rows;
385
- const unregisteredTotal = rows.filter((row) => !registered(row)).length;
386
- const unregisteredShown = items.filter((row) => !registered(row)).length;
385
+ // A dropped item is counted once, under "Dropped by you", never as unsorted.
386
+ const unsorted = (row) => !registered(row) && row.fields?.lifecycle !== 'dropped';
387
+ const unregisteredTotal = rows.filter(unsorted).length;
388
+ const unregisteredShown = items.filter(unsorted).length;
387
389
  const marks = await marksFor(new Set(items.map((row) => row.id)));
388
390
  const readAt = now();
389
391
  let redactions = 0;
@@ -415,7 +417,7 @@ export function createCapabilityVerbs(core) {
415
417
  // own, and it must not pass through the one-line flattener. The count is
416
418
  // read after the walk, so the walk's own finds are counted too (L1).
417
419
  const scrubbed = ({ text, ...rest }) => { const rows = cleanDeep(rest); return { ...rows, redactions: redactions + rendered.redactions, text }; };
418
- const built = [], building = [], pending = [], possible = [];
420
+ const built = [], building = [], pending = [], possible = [], dropped = [];
419
421
  for (const item of items) {
420
422
  const fields = item.fields ?? {};
421
423
  const { rows: deliveries, marks: own } = deliveriesOf(item, marks);
@@ -431,24 +433,24 @@ export function createCapabilityVerbs(core) {
431
433
  }
432
434
  const doneAt = fields.delivered_at ?? item.updated_at ?? item.created_at ?? null;
433
435
  if (fields.lifecycle === 'done' && !deliveries.length && !(since && String(doneAt ?? '') < since)) built.push({ ...base, mark_id: null, verdict: 'done, no delivery mark', delivered_at: doneAt, prs: [], source: 'no mark yet', note: 'the item record says done; the done verb writes the mark' });
434
- if (fields.lifecycle === 'active') building.push({ ...base, stage: clean(fields.stage ?? '', 64), driver: clean(fields.driver ?? '', 64), next_step: clean(fields.next_step ?? '', 200), days_since_last_change: days, last_change_source: clean(last?.source ?? 'nothing recorded', 120), delivery_state: fields.delivery_state ? clean(fields.delivery_state, 64) : null });
435
- else if (fields.lifecycle === 'paused') pending.push({ ...base, reason: clean(fields.pause_reason ?? 'no reason on record', 300), days_since_last_change: days, parked_at: fields.parked_at ?? null });
436
+ const waits = fields.waits_on && typeof fields.waits_on === 'object' ? { waits_on: fields.waits_on } : {};
437
+ if (fields.lifecycle === 'active') building.push({ ...base, ...waits, stage: clean(fields.stage ?? '', 64), driver: clean(fields.driver ?? '', 64), next_step: clean(fields.next_step ?? '', 200), days_since_last_change: days, last_change_source: clean(last?.source ?? 'nothing recorded', 120), delivery_state: fields.delivery_state ? clean(fields.delivery_state, 64) : null });
438
+ else if (fields.lifecycle === 'paused') pending.push({ ...base, ...waits, reason: clean(fields.pause_reason ?? 'no reason on record', 300), days_since_last_change: days, parked_at: fields.parked_at ?? null });
436
439
  else if (fields.lifecycle === 'proposed') {
437
440
  const born = fields.created_at ?? item.created_at ?? null;
438
- possible.push({ ...base, age_days: born ? daysBetween(born, readAt) : null, proposed_at: born, driver: clean(fields.driver ?? '', 64) });
439
- }
441
+ possible.push({ ...base, ...waits, age_days: born ? daysBetween(born, readAt) : null, proposed_at: born, driver: clean(fields.driver ?? '', 64) });
442
+ } else if (fields.lifecycle === 'dropped') dropped.push({ ...base, dropped_at: fields.dropped_at ?? null });
440
443
  }
441
- built.sort((a, b) => String(b.delivered_at ?? '').localeCompare(String(a.delivered_at ?? '')));
442
- building.sort((a, b) => (a.days_since_last_change ?? Number.MAX_SAFE_INTEGER) - (b.days_since_last_change ?? Number.MAX_SAFE_INTEGER) || a.item.localeCompare(b.item));
443
- pending.sort((a, b) => (a.days_since_last_change ?? Number.MAX_SAFE_INTEGER) - (b.days_since_last_change ?? Number.MAX_SAFE_INTEGER) || a.item.localeCompare(b.item));
444
- possible.sort((a, b) => (b.age_days ?? 0) - (a.age_days ?? 0));
445
-
446
444
  // The decisions that set the order. The owner's words are what ordered
447
- // this work, so the view ends with them, newest first.
445
+ // this work, so the view ends with them, newest first. Release 0.2.3:
446
+ // his newest priority decision also orders the rows above.
448
447
  const products = only ? [only] : [...new Set(items.filter(registered).map((row) => row.fields.product))];
449
448
  const order = [];
449
+ const orders = new Map();
450
450
  for (const productId of products) {
451
- for (const row of await decisionsFor(productId)) {
451
+ const decisions = await decisionsFor(productId);
452
+ orders.set(productId, priorityOrderOf(decisions, productId));
453
+ for (const row of decisions) {
452
454
  if (since && String(row.fields?.date ?? '') < since) continue;
453
455
  if (row.fields?.kind === 'priority' || (Array.isArray(row.fields?.priority_order) && row.fields.priority_order.length)) {
454
456
  order.push({ decision: row.id, product: productId, kind: row.fields?.kind ?? 'decision', date: row.fields?.date ?? '', text: clean(row.fields?.text ?? row.title, 400), priority_order: row.fields?.priority_order ?? [], citation: { decision: row.id, date: row.fields?.date ?? '', source: row.fields?.source ?? '' } });
@@ -456,6 +458,12 @@ export function createCapabilityVerbs(core) {
456
458
  }
457
459
  }
458
460
  order.sort((a, b) => String(b.date).localeCompare(String(a.date)));
461
+ const byDays = (a, b) => (a.days_since_last_change ?? Number.MAX_SAFE_INTEGER) - (b.days_since_last_change ?? Number.MAX_SAFE_INTEGER) || a.item.localeCompare(b.item);
462
+ built.sort((a, b) => String(b.delivered_at ?? '').localeCompare(String(a.delivered_at ?? '')));
463
+ building.sort(byPriority(orders, byDays));
464
+ pending.sort(byPriority(orders, byDays));
465
+ possible.sort(byPriority(orders, (a, b) => (b.age_days ?? 0) - (a.age_days ?? 0)));
466
+ const waitsOf = (row) => (row.waits_on ? ` · ${waitsOnLine(row.waits_on)}` : '');
459
467
 
460
468
  // D3 rule 2: a row names its product. The board does not group, so every
461
469
  // line carries the id (review H6).
@@ -464,20 +472,23 @@ export function createCapabilityVerbs(core) {
464
472
  const block = [
465
473
  `Built, pending, possible — ${only ?? `every product${unregisteredShown ? `, and ${plural(unregisteredShown, 'item')} that name${unregisteredShown === 1 ? 's' : ''} none` : ''}`} · read ${readAt.slice(0, 19)}Z${since ? ` · since ${since}` : ''}`,
466
474
  section(since ? `Built since ${since}` : 'Built', built, (row) => listLine(row.title, `${where_(row)} · ${row.verdict} ${day(row.delivered_at)} · ${prsOf(row)} · ${row.item}${row.mark_id ? ` · mark ${row.mark_id.slice(0, 8)}` : ''}`)),
467
- section('Building', building, (row) => listLine(`${row.title} · ${row.stage || 'no stage'} · next: ${row.next_step || 'not set'}`, `${where_(row)} · ${row.item} · ${row.days_since_last_change === null ? 'no movement on record' : `${plural(row.days_since_last_change, 'day')} since ${row.last_change_source}`}`)),
468
- section('Pending, parked until someone returns to it', pending, (row) => listLine(`${row.title} — ${row.reason}`, `${where_(row)} · ${row.item}`)),
469
- section('Possible, proposed and waiting for a yes', possible, (row) => listLine(row.title, `${where_(row)} · ${row.age_days === null ? 'age unknown' : `${plural(row.age_days, 'day')} old`} · ${row.item}`)),
475
+ section('Building', building, (row) => listLine(`${row.title} · ${row.stage || 'no stage'}${waitsOf(row)} · next: ${row.next_step || 'not set'}`, `${where_(row)} · ${row.item} · ${row.days_since_last_change === null ? 'no movement on record' : `${plural(row.days_since_last_change, 'day')} since ${row.last_change_source}`}${orderNote(orders, row)}`)),
476
+ section('Pending, parked until someone returns to it', pending, (row) => listLine(`${row.title}${waitsOf(row)} — ${row.reason}`, `${where_(row)} · ${row.item}${orderNote(orders, row)}`)),
477
+ section('Possible, proposed and waiting for a yes', possible, (row) => listLine(`${row.title}${waitsOf(row)}`, `${where_(row)} · ${row.age_days === null ? 'age unknown' : `${plural(row.age_days, 'day')} old`} · ${row.item}${orderNote(orders, row)}`)),
470
478
  section('The decisions that set the order', order, (row) => listLine(`${row.kind}: ${row.text}`, `${row.product} · ${row.date} · ${row.decision}`)),
471
- ...(unregisteredTotal ? [[
472
- `Not in the registry (${unregisteredTotal}${only ? '' : `, ${unregisteredShown} of them in the rows above, named "unregistered"`})`,
479
+ ...(dropped.length ? [`Dropped by you (${dropped.length}): they stay in the graph, and no live list names them.`] : []),
480
+ // Release 0.2.3: a read narrowed to
481
+ // one product says nothing about items that name no product. The
482
+ // count stays in the result, as unregistered_total.
483
+ ...(unregisteredTotal && !only ? [[
484
+ `Not in the registry (${unregisteredTotal}, ${unregisteredShown} of them in the rows above, named "unregistered")`,
473
485
  ' The slice L5 sweep gives them a product.',
474
- ...(only ? [' A read narrowed to one product cannot show them: an item with no product belongs to none.'] : []),
475
486
  ].join('\n')] : []),
476
487
  ].join('\n\n');
477
488
  const rendered = scrubSecrets(block);
478
489
  return scrubbed({
479
490
  verb: 'built_pending_possible', read_at: readAt, product: only, since,
480
- built, building, pending, possible, order, unregistered_total: unregisteredTotal, unregistered_in_rows: unregisteredShown,
491
+ built, building, pending, possible, order, dropped_total: dropped.length, unregistered_total: unregisteredTotal, unregistered_in_rows: unregisteredShown,
481
492
  rows: built.length + building.length + pending.length + possible.length,
482
493
  redactions: redactions + rendered.redactions, text: rendered.text,
483
494
  });
@@ -13,7 +13,7 @@
13
13
  // 3. Movement is read, never stored. The view computes it at question time
14
14
  // from the marks and from the repos' pull requests (D3 rule 5, R1).
15
15
  import { refuse, text, isoDate, url, stringList, sha256 } from './verb-fields.mjs';
16
- import { oneLine, ROW_LINE_MAX } from './questions.mjs';
16
+ import { oneLine, ROW_LINE_MAX, priorityOrderOf, byPriority, orderNote, waitsOnLine } from './questions.mjs';
17
17
 
18
18
  export const DELIVERY_WRITE_VERBS = Object.freeze(['item_done', 'item_reopen']);
19
19
  export const DELIVERY_READ_VERBS = Object.freeze(['in_flight']);
@@ -545,6 +545,11 @@ export function createDeliveryVerbs(core) {
545
545
  if (repoIds.length > MAX_REPOS) refuse(`${repoIds.length} repos are registered for this view; it reads at most ${MAX_REPOS}. Narrow it with a product id.`);
546
546
  const { pullsByRepo, repoErrors } = await readPullsByRepo(repoIds, readGitHub);
547
547
 
548
+ // Release 0.2.3: the owner's priority decision orders the rows.
549
+ const decisionType = await typeNamed('decision');
550
+ const decisions = await client.listAllEntities({ type: decisionType.name, state: 'active' });
551
+ const orders = new Map([...new Set(items.map((row) => row.fields.product))].map((productId) => [productId, priorityOrderOf(decisions, productId)]));
552
+
548
553
  const readAt = now();
549
554
  const rows = [], delivered = [], notMarked = [];
550
555
  for (const item of items) {
@@ -583,21 +588,23 @@ export function createDeliveryVerbs(core) {
583
588
  // The id is what a reader types back, so the title gives way to it
584
589
  // inside the line budget, as the "Needs me" row does (review G6).
585
590
  oneLine(`${oneLine(item.title, Math.max(1, ROW_LINE_MAX - item.id.length - 3))} · ${item.id}`),
586
- oneLine(`${fields.product} · ${fields.stage ?? 'no stage'} · ${fields.driver ?? 'no driver'} · next: ${fields.next_step || 'not set'}`),
587
- oneLine(`${days === null ? 'no movement on record' : `${days} day${days === 1 ? '' : 's'} since last change`} (${last?.source ?? 'nothing recorded'}) · ${mine.length} pull request${mine.length === 1 ? '' : 's'}${open.length ? ` · ${open.length} merged, not marked` : ''}`),
591
+ oneLine(`${fields.product} · ${fields.stage ?? 'no stage'} · ${fields.driver ?? 'no driver'}${waitsOnLine(fields.waits_on) ? ` · ${waitsOnLine(fields.waits_on)}` : ''} · next: ${fields.next_step || 'not set'}`),
592
+ oneLine(`${days === null ? 'no movement on record' : `${days} day${days === 1 ? '' : 's'} since last change`} (${last?.source ?? 'nothing recorded'}) · ${mine.length} pull request${mine.length === 1 ? '' : 's'}${open.length ? ` · ${open.length} merged, not marked` : ''}${orderNote(orders, { product: fields.product, item: item.id })}`),
588
593
  ],
594
+ waits_on: fields.waits_on ?? null,
589
595
  folded: { title: item.title, next_step: fields.next_step ?? '', scope: fields.scope?.text ?? '', pieces: fields.split_children ?? [], prs: mine.map((pull) => ({ url: pull.url, state: pull.state, matched_by: pull.matched_by, merged_at: pull.merged_at, updated_at: pull.updated_at })), marks: own.map((mark) => ({ id: mark.id, kind: mark.fields.kind, verdict: mark.fields.verdict, at: mark.fields.marked_at })) },
590
596
  citation: { item: item.id, read_at: readAt, repos: [...new Set(mine.map((pull) => pull.repo))] },
591
597
  });
592
598
  }
593
599
  // The owner's rule: order by days since last change, latest on top, and
594
- // show the days on the row.
595
- rows.sort((a, b) => (a.days_since_last_change ?? Number.MAX_SAFE_INTEGER) - (b.days_since_last_change ?? Number.MAX_SAFE_INTEGER) || a.item.localeCompare(b.item));
600
+ // show the days on the row. Release 0.2.3: the items his priority
601
+ // decision names come first, in his order.
602
+ rows.sort(byPriority(orders, (a, b) => (a.days_since_last_change ?? Number.MAX_SAFE_INTEGER) - (b.days_since_last_change ?? Number.MAX_SAFE_INTEGER) || a.item.localeCompare(b.item)));
596
603
  delivered.sort((a, b) => String(b.delivered_at ?? '').localeCompare(String(a.delivered_at ?? '')));
597
604
  notMarked.sort((a, b) => (a.days ?? 0) - (b.days ?? 0));
598
605
  const products = [...new Set(rows.map((row) => row.product))].sort();
599
606
  const byProduct = products.map((product) => ({ product, rows: rows.filter((row) => row.product === product) }));
600
- const block = byProduct.map((group) => [`${group.product} — in flight (${group.rows.length})`, ...group.rows.flatMap((row, index) => row.lines.map((line, position) => `${position === 0 ? `${index + 1}. ` : ' '}${line}`))].join('\n'));
607
+ const block = byProduct.map((group) => [`${group.product} — in flight (${group.rows.length})${orders.get(group.product) ? ` · in your order of ${orders.get(group.product).date}, then by the last change` : ''}`, ...group.rows.flatMap((row, index) => row.lines.map((line, position) => `${position === 0 ? `${index + 1}. ` : ' '}${line}`))].join('\n'));
601
608
  if (notMarked.length) block.push([`merged, not marked (${notMarked.length})`, ...notMarked.map((row) => listLine(row.title, `${row.pr} · ${row.item} · merged ${row.days} day${row.days === 1 ? '' : 's'} ago`))].join('\n'));
602
609
  if (repoErrors.length) block.push([`GitHub did not answer for ${repoErrors.length} repo${repoErrors.length === 1 ? '' : 's'}; movement below is from the graph only`, ...repoErrors.map((row) => oneLine(` ${row.repo} — ${row.reason}`, ROW_LINE_MAX))].join('\n'));
603
610
  if (unregisteredTotal && !only) {
@@ -615,6 +622,7 @@ export function createDeliveryVerbs(core) {
615
622
  if (since && delivered.length) block.push([`delivered since ${since} (${delivered.length})`, ...delivered.map((row) => listLine(row.item_title, `${row.verdict}${(row.gate_warnings ?? []).length ? `, gate warning on ${row.gate_warnings.map((warning) => warning.check).join(' ')}` : ''} · ${String(row.delivered_at ?? '').slice(0, 10)} · ${row.prs.join(' ') || 'no pull request on the mark'}`))].join('\n'));
616
623
  return {
617
624
  verb: 'in_flight', read_at: readAt, product: only, since, rows: rows.length, products: byProduct,
625
+ priority: Object.fromEntries([...orders].filter(([, order]) => order).map(([productId, order]) => [productId, { decision: order.decision, date: order.date }])),
618
626
  merged_not_marked: notMarked, delivered: since ? delivered : delivered.slice(0, 20), unregistered, unregistered_total: unregisteredTotal, repos_read: repoIds, repo_errors: repoErrors,
619
627
  text: block.join('\n\n') || 'Nothing is in flight: no active item on a registered product.',
620
628
  };
@@ -5,7 +5,7 @@
5
5
  // The verbs run behind the same door, the same journal and the same registry
6
6
  // checks as slice L1. Every record carries request_id and writer.
7
7
  import protectedPaths from '../protected-paths.json' with { type: 'json' };
8
- import { refuse, text, oneOf, isoDate, url, stringList } from './verb-fields.mjs';
8
+ import { refuse, text, oneOf, isoDate, url, stringList, LIVE_LIFECYCLES } from './verb-fields.mjs';
9
9
 
10
10
  // R5: the cost of waiting is a closed vocabulary, ranked. Index 0 costs most.
11
11
  export const COSTS = Object.freeze(['blocked-merge', 'paused-item', 'spend', 'date']);
@@ -62,13 +62,50 @@ function questionContext(value) {
62
62
  return context;
63
63
  }
64
64
 
65
+ // Release 0.2.3: the owner's order. One rule for every view, the same one
66
+ // part six of "where are we with X" uses: the current priority decision is the
67
+ // newest decision of kind priority for the product. When it carries a
68
+ // priority_order, the rows it names come first, in its order; every other row
69
+ // follows, in the view's own order. When it carries none, no order applies:
70
+ // an older list never stands in for the newest decision.
71
+ export function currentPriorityOf(decisions, productId) {
72
+ return decisions
73
+ .filter((row) => row.fields?.product === productId && row.fields?.kind === 'priority')
74
+ .sort((a, b) => String(b.fields?.date ?? '').localeCompare(String(a.fields?.date ?? '')) || String(b.created_at ?? '').localeCompare(String(a.created_at ?? '')))[0] ?? null;
75
+ }
76
+ export function priorityOrderOf(decisions, productId) {
77
+ const latest = currentPriorityOf(decisions, productId);
78
+ if (!latest || !Array.isArray(latest.fields?.priority_order) || !latest.fields.priority_order.length) return null;
79
+ return { decision: latest.id, date: latest.fields?.date ?? '', rank: new Map(latest.fields.priority_order.map((id, index) => [id, index])) };
80
+ }
81
+ // orders maps a product id to priorityOrderOf's result, or to null.
82
+ export const byPriority = (orders, fallback) => (a, b) => {
83
+ const ra = orders.get(a.product)?.rank.get(a.item);
84
+ const rb = orders.get(b.product)?.rank.get(b.item);
85
+ if (ra !== undefined && rb !== undefined) return ra - rb;
86
+ if (ra !== undefined) return -1;
87
+ if (rb !== undefined) return 1;
88
+ return fallback(a, b);
89
+ };
90
+ export const orderNote = (orders, row) => {
91
+ const rank = orders.get(row.product)?.rank.get(row.item);
92
+ return rank === undefined ? '' : ` · your order: ${rank + 1}`;
93
+ };
94
+ // Release 0.2.3: what an item waits on, as one short phrase for a row.
95
+ export function waitsOnLine(waits) {
96
+ if (!waits || typeof waits !== 'object' || !waits.on) return '';
97
+ const what = { 'owner-decision': 'your decision', 'owner-action': 'your action', item: 'another item', gate: 'the gate', nothing: 'nothing' }[waits.on] ?? String(waits.on);
98
+ return `waits on ${what}${waits.item ? ` ${waits.item}` : ''}${waits.note ? `: ${waits.note}` : ''}`;
99
+ }
100
+
65
101
  // Row line two: the id is the citation a reader types back, so the title gives
66
102
  // way to it, not the other way round (review G6).
67
- export function itemLine(item, product) {
103
+ export function itemLine(item, product, lifecycle = 'active') {
68
104
  // Each part is flattened on its own, so no field in the graph can add a
69
105
  // fourth line to a three-line row (review G8). The separator is added after
70
- // the flattening, because flattening also trims the ends.
71
- const tail = oneLine(`${product} · ${item.fields?.stage ?? 'unknown stage'} · ${item.id}`);
106
+ // the flattening, because flattening also trims the ends. An item that is
107
+ // not active says so in place of its stage (release 0.2.3).
108
+ const tail = oneLine(`${product} · ${lifecycle === 'active' ? (item.fields?.stage ?? 'unknown stage') : `item ${lifecycle}`} · ${item.id}`);
72
109
  const budget = ROW_LINE_MAX - tail.length - 3;
73
110
  if (budget < 1) return oneLine(`${oneLine(item.title)} · ${tail}`);
74
111
  return `${oneLine(item.title, budget)} · ${tail}`;
@@ -134,6 +171,7 @@ export function createQuestionVerbs(core) {
134
171
  const prior = await landed(type, requestId, meta);
135
172
  // A retry after an interrupted write finds the record and re-makes the edge it lost (review G1).
136
173
  if (prior) { await edge(prior, item, 'references', requestId); return { verb: 'question_ask', question_id: prior.id, version: prior.version, status: prior.fields.status, item: prior.fields.item, product: prior.fields.product, cost: prior.fields.cost, replayed_from_graph: true }; }
174
+ if (item.fields?.lifecycle === 'dropped') refuse(`item ${item.id} is dropped; the owner closed it, so a question on it can never need him`);
137
175
  const entity = await create(type, requestId, 'question_ask', {
138
176
  digest: meta?.digest, title: oneLine(questionText, 200), body: questionText,
139
177
  fields: {
@@ -250,20 +288,23 @@ export function createQuestionVerbs(core) {
250
288
  for (const row of open) {
251
289
  const item = items.get(row.fields.item) ?? null;
252
290
  const lifecycle = item?.fields?.lifecycle ?? 'unknown';
253
- // D3 rule 1: active items only. A question on any other item is named, not dropped in silence.
254
- if (lifecycle !== 'active') { omitted.push({ question_id: row.id, item: row.fields.item, item_lifecycle: lifecycle, reason: item ? `the item is ${lifecycle}; paused and proposed items are backlog` : (unreadable.get(row.fields.item) ?? 'the item cannot be read') }); continue; }
291
+ // Release 0.2.3: a question on a
292
+ // proposed or paused item still needs him, so it is a row. D3 rule 1
293
+ // kept active items only, and it hid real questions. A question on a
294
+ // done or dropped item is named, not dropped in silence.
295
+ if (!LIVE_LIFECYCLES.includes(lifecycle)) { omitted.push({ question_id: row.id, item: row.fields.item, item_lifecycle: lifecycle, reason: item ? `the item is ${lifecycle}; only a proposed, active or paused item can still need you` : (unreadable.get(row.fields.item) ?? 'the item cannot be read') }); continue; }
255
296
  const cost = row.fields.cost;
256
297
  const rank = COSTS.indexOf(cost);
257
298
  const asked = row.fields.asked_by ?? {};
258
299
  kept.push({
259
- question_id: row.id, product: row.fields.product, item: item.id, cost, cost_rank: rank === -1 ? COSTS.length : rank,
300
+ question_id: row.id, product: row.fields.product, item: item.id, item_lifecycle: lifecycle, cost, cost_rank: rank === -1 ? COSTS.length : rank,
260
301
  asked_at: row.fields.asked_at ?? row.created_at ?? '', asked_by: asked.agent ?? 'unknown',
261
302
  lines: [
262
303
  oneLine(row.fields.text),
263
- itemLine(item, row.fields.product),
304
+ itemLine(item, row.fields.product, lifecycle),
264
305
  oneLine(`${cost}: ${COST_MEANING[cost] ?? 'cost outside the vocabulary'} · asked by ${asked.agent ?? 'unknown'} on ${(row.fields.asked_at ?? '').slice(0, 10)}`),
265
306
  ],
266
- folded: { question: row.fields.text, context: row.fields.context ?? {}, asked_by: asked, item_title: item.title, item_stage: item.fields.stage ?? '', item_next_step: item.fields.next_step ?? '' },
307
+ folded: { question: row.fields.text, context: row.fields.context ?? {}, asked_by: asked, item_title: item.title, item_stage: item.fields.stage ?? '', item_next_step: item.fields.next_step ?? '', item_waits_on: item.fields.waits_on ?? null },
267
308
  citation: { question: row.id, item: item.id, read_at: readAt },
268
309
  });
269
310
  }
@@ -271,7 +312,10 @@ export function createQuestionVerbs(core) {
271
312
  const products = [...new Set(kept.map((row) => row.product))].sort();
272
313
  const byProduct = products.map((product) => ({ product, rows: kept.filter((row) => row.product === product) }));
273
314
  const render = byProduct.map((group) => [`${group.product} — needs you (${group.rows.length})`, ...group.rows.flatMap((row, index) => row.lines.map((line, position) => `${position === 0 ? `${index + 1}. ` : ' '}${line}`))].join('\n')).join('\n\n');
274
- return { verb: 'needs_me', read_at: readAt, product: only, row_budget: ROW_LINES, rows: kept.length, products: byProduct, omitted, text: render || 'Nothing needs you: no open question on an active item.' };
315
+ // A question left open on a done or dropped item is not a row, but the
316
+ // text says it exists, so nothing waits in silence.
317
+ const aside = omitted.length ? `${omitted.length} open question${omitted.length === 1 ? '' : 's'} sit${omitted.length === 1 ? 's' : ''} on done, dropped or unreadable items; they are listed under omitted.` : '';
318
+ return { verb: 'needs_me', read_at: readAt, product: only, row_budget: ROW_LINES, rows: kept.length, products: byProduct, omitted, text: [render || 'Nothing needs you: no open question on a proposed, active or paused item.', aside].filter(Boolean).join('\n\n') };
275
319
  },
276
320
  };
277
321
  return verbs;
@@ -5,6 +5,9 @@
5
5
  import { createHash } from 'node:crypto';
6
6
 
7
7
  export const MAX_TEXT = 20_000;
8
+ // The lifecycles a view counts as live work (release 0.2.3). A dropped item is
9
+ // closed by the owner's word, and no live list names it.
10
+ export const LIVE_LIFECYCLES = Object.freeze(['proposed', 'active', 'paused']);
8
11
 
9
12
  export class VerbError extends Error {
10
13
  constructor(message, code = 'refused') { super(message); this.name = 'VerbError'; this.code = code; }
@@ -13,13 +13,13 @@
13
13
  // M2 slice L1 adds the repo kind in 0.5.0, which carries 0.4.0 whole.
14
14
  import manifestCurrent from '../manifest-0.5.0.json' with { type: 'json' };
15
15
  import { EngramHttpError } from './client.mjs';
16
- import { VerbError, refuse, canonicalJson, sha256, text, oneOf, origin, url, stringList } from './verb-fields.mjs';
16
+ import { VerbError, refuse, canonicalJson, sha256, text, oneOf, origin, url, stringList, LIVE_LIFECYCLES } from './verb-fields.mjs';
17
17
  import { createQuestionVerbs, COSTS, QUESTION_WRITE_VERBS, QUESTION_READ_VERBS } from './questions.mjs';
18
18
  import { createDeliveryVerbs, DELIVERY_WRITE_VERBS, DELIVERY_READ_VERBS } from './delivery.mjs';
19
19
  import { createCapabilityVerbs, CAPABILITY_READ_VERBS } from './capability.mjs';
20
20
  import { createSweepVerbs, SWEEP_WRITE_VERBS, SWEEP_READ_VERBS } from './sweep.mjs';
21
21
 
22
- export { VerbError, canonicalJson, sha256, COSTS };
22
+ export { VerbError, canonicalJson, sha256, COSTS, LIVE_LIFECYCLES };
23
23
  export const MANIFEST_VERSION = '0.5.0';
24
24
  export const PRODUCT_ID = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
25
25
  export const REPO_ID = /^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,99})\/[A-Za-z0-9_.-]{1,100}$/;
@@ -29,6 +29,10 @@ export const DRIVERS = Object.freeze(['owner-led', 'agent']);
29
29
  // The repo kind of M2 amendment A1.5. The repo-shape check reads it to decide
30
30
  // which of the seven files a repo needs.
31
31
  export const REPO_KINDS = Object.freeze(['service', 'library', 'schema']);
32
+ // What an item waits on (release 0.2.3). One line on the item, shown on its
33
+ // rows. "item" names another work item by id.
34
+ export const WAITS_ON = Object.freeze(['owner-decision', 'owner-action', 'item', 'gate', 'nothing']);
35
+ const MAX_EDITS = 20;
32
36
  export const LINK_KINDS = Object.freeze(['pr', 'issue', 'artifact', 'design-doc', 'conversation', 'research', 'slack', 'drive', 'other']);
33
37
  export const AUTHORITY_FIELDS = Object.freeze(['writer', 'session', 'actor', 'owner_present', 'agent', 'token_id']);
34
38
  const MAX_CHILDREN = 32;
@@ -131,11 +135,17 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
131
135
  }
132
136
  const requireOwner = (verb) => { if (!ownerPresent) refuse(`${verb} needs the owner-present flag on the server host (ATLAS_WORK_OWNER_PRESENT=1)`); };
133
137
 
134
- async function patch(entity, verb, requestId, fields, { tags, title, digest } = {}) {
138
+ // fields may be a function of the record as read, so a retry after a 409
139
+ // recomputes it from the newer record. guard, when given, is checked again on
140
+ // that newer record: a write that another writer made wrong is refused, not
141
+ // forced through (release 0.2.3).
142
+ async function patch(entity, verb, requestId, fields, { tags, title, digest, guard } = {}) {
135
143
  let current = entity;
136
144
  for (let attempt = 0; attempt < 2; attempt += 1) {
145
+ if (attempt > 0 && guard) guard(current);
146
+ const next = typeof fields === 'function' ? fields(current) : fields;
137
147
  const receipts = { ...(current.fields?.atlas_receipts ?? {}), [requestId]: { verb, at: now(), ...(digest ? { digest } : {}) } };
138
- const body = { version: current.version, fields: { ...(current.fields ?? {}), ...fields, writer: label, request_id: requestId, atlas_receipts: receipts } };
148
+ const body = { version: current.version, fields: { ...(current.fields ?? {}), ...next, writer: label, request_id: requestId, atlas_receipts: receipts } };
139
149
  if (tags) body.tags = [...new Set([...(current.tags ?? []), ...tags])];
140
150
  if (title) body.title = title;
141
151
  try { await client.updateEntity(current.id, body); return client.getEntity(current.id); }
@@ -430,12 +440,82 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
430
440
  const item = await requireItem(args.item_id);
431
441
  const reason = text(args.reason, 'reason', { max: 2000 });
432
442
  if (alreadyPatched(item, requestId, meta)) return { verb: 'item_resume', item_id: item.id, version: item.version, lifecycle: item.fields.lifecycle, reason, replayed_from_graph: true };
433
- if (item.fields?.lifecycle !== 'paused') refuse(`item ${item.id} is ${item.fields?.lifecycle}; only a paused item can be resumed`);
443
+ // Release 0.2.3: resume also undoes a drop, back to the lifecycle the
444
+ // item left. The drop's words stay on the item.
445
+ if (item.fields?.lifecycle === 'dropped') {
446
+ const back = LIVE_LIFECYCLES.includes(item.fields.dropped_from) ? item.fields.dropped_from : 'proposed';
447
+ const undone = await patch(item, 'item_resume', requestId, { lifecycle: back, resume_reason: reason, resumed_at: now(), undropped_at: now() }, { digest: meta?.digest });
448
+ return { verb: 'item_resume', item_id: undone.id, version: undone.version, lifecycle: back, reason, undid_drop: true };
449
+ }
450
+ if (item.fields?.lifecycle !== 'paused') refuse(`item ${item.id} is ${item.fields?.lifecycle}; only a paused or dropped item can be resumed`);
434
451
  const lifecycle = item.fields.parked_from === 'proposed' ? 'proposed' : 'active';
435
452
  const entity = await patch(item, 'item_resume', requestId, { lifecycle, pause_reason: '', resume_reason: reason, resumed_at: now(), parked_from: null }, { digest: meta?.digest });
436
453
  return { verb: 'item_resume', item_id: entity.id, version: entity.version, lifecycle, reason };
437
454
  },
438
455
 
456
+ // Release 0.2.3: the owner drops a piece of work in his own words. A
457
+ // dropped item stays in the graph; no live view lists it. A delivered item
458
+ // is not dropped: item_reopen is its path.
459
+ async item_drop(args, requestId, meta) {
460
+ requireOwner('item drop');
461
+ const item = await requireItem(args.item_id);
462
+ const words = origin(args.origin);
463
+ if (alreadyPatched(item, requestId, meta)) return { verb: 'item_drop', item_id: item.id, version: item.version, lifecycle: item.fields.lifecycle, dropped_from: item.fields.dropped_from ?? null, replayed_from_graph: true };
464
+ const live = (current) => { if (!LIVE_LIFECYCLES.includes(current.fields?.lifecycle)) refuse(`item ${current.id} is ${current.fields?.lifecycle}; only a proposed, active or paused item can be dropped`); };
465
+ live(item);
466
+ const at = now();
467
+ const entity = await patch(item, 'item_drop', requestId, (current) => ({ lifecycle: 'dropped', drop_reason: words, dropped_at: at, dropped_from: current.fields.lifecycle }), { digest: meta?.digest, guard: live });
468
+ return { verb: 'item_drop', item_id: entity.id, version: entity.version, lifecycle: 'dropped', dropped_from: entity.fields?.dropped_from ?? item.fields.lifecycle, reason: words };
469
+ },
470
+
471
+ // Release 0.2.3: edit an item's next step, what it waits on, and its title.
472
+ // The next step and the wait are the lead's to keep true, so any
473
+ // in-session caller may change them. The title is the owner's words, so a
474
+ // new title needs the owner present. Each edit keeps the old values.
475
+ async item_edit(args, requestId, meta) {
476
+ const item = await requireItem(args.item_id);
477
+ const fields = item.fields ?? {};
478
+ if (alreadyPatched(item, requestId, meta)) return { verb: 'item_edit', item_id: item.id, version: item.version, title: item.title, next_step: fields.next_step ?? '', waits_on: fields.waits_on ?? null, replayed_from_graph: true };
479
+ const live = (current) => { if (!LIVE_LIFECYCLES.includes(current.fields?.lifecycle)) refuse(`item ${current.id} is ${current.fields?.lifecycle}; only a proposed, active or paused item can be edited`); };
480
+ live(item);
481
+ const change = {}, previous = {};
482
+ let title;
483
+ if (args.title !== undefined) {
484
+ requireOwner('item edit of a title');
485
+ title = text(args.title, 'title', { max: 300 });
486
+ if (title !== item.title) previous.title = item.title ?? '';
487
+ }
488
+ if (args.next_step !== undefined) {
489
+ change.next_step = text(args.next_step, 'next_step', { max: 1000 });
490
+ previous.next_step = fields.next_step ?? '';
491
+ }
492
+ if (args.waits_on !== undefined) {
493
+ const given = args.waits_on;
494
+ if (!given || typeof given !== 'object' || Array.isArray(given)) refuse(`waits_on must be { on, note?, item? }, with on one of ${WAITS_ON.join(', ')}`);
495
+ const wait = { on: oneOf(given.on, 'waits_on.on', WAITS_ON) };
496
+ const note = text(given.note, 'waits_on.note', { optional: true, max: 300 });
497
+ if (note) wait.note = note;
498
+ if (wait.on === 'item') {
499
+ const other = await requireItem(given.item);
500
+ if (other.id === item.id) refuse('an item cannot wait on itself');
501
+ wait.item = other.id;
502
+ } else if (given.item !== undefined) refuse('waits_on.item is only for on: item');
503
+ if (wait.on !== 'nothing' && wait.on !== 'item' && !wait.note) refuse(`waits_on.note is required for on: ${wait.on}; say what exactly it waits on`);
504
+ change.waits_on = wait;
505
+ previous.waits_on = fields.waits_on ?? null;
506
+ }
507
+ if (args.title === undefined && !Object.keys(change).length) refuse('item edit needs title, next_step or waits_on');
508
+ const reason = text(args.reason, 'reason', { optional: true, max: 1000 });
509
+ const entry = { at: now(), by: label.agent, previous, ...(reason ? { reason } : {}) };
510
+ // The history keeps the last MAX_EDITS entries. The first title the
511
+ // owner gave is kept on its own, so a title never falls out of it.
512
+ const entity = await patch(item, 'item_edit', requestId, (current) => ({
513
+ ...change, edits: [...(Array.isArray(current.fields?.edits) ? current.fields.edits : []), entry].slice(-MAX_EDITS),
514
+ ...(previous.title !== undefined && current.fields?.first_title === undefined ? { first_title: previous.title } : {}),
515
+ }), { digest: meta?.digest, guard: live, ...(title ? { title } : {}) });
516
+ return { verb: 'item_edit', item_id: entity.id, version: entity.version, title: entity.title, next_step: entity.fields?.next_step ?? '', waits_on: entity.fields?.waits_on ?? null, changed: [...(args.title !== undefined ? ['title'] : []), ...Object.keys(change)] };
517
+ },
518
+
439
519
  async item_stage(args, requestId, meta) {
440
520
  const item = await requireItem(args.item_id);
441
521
  const stage = oneOf(args.stage, 'stage', STAGES);
@@ -474,7 +554,7 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
474
554
  Object.assign(verbs, createCapabilityVerbs({ ...core, readGitHub }));
475
555
  Object.assign(verbs, createSweepVerbs({ ...core, requireRepos, inFlight: (args) => verbs.in_flight(args), stages: STAGES, tiers: TIERS, drivers: DRIVERS }));
476
556
 
477
- const WRITE_VERBS = Object.freeze(['setup', 'product_add', 'product_edit', 'repo_add', 'repo_edit', 'item_propose', 'item_lock', 'item_split', 'item_park', 'item_resume', 'item_stage', 'item_link', ...QUESTION_WRITE_VERBS, ...DELIVERY_WRITE_VERBS, ...SWEEP_WRITE_VERBS]);
557
+ const WRITE_VERBS = Object.freeze(['setup', 'product_add', 'product_edit', 'repo_add', 'repo_edit', 'item_propose', 'item_lock', 'item_split', 'item_park', 'item_resume', 'item_drop', 'item_edit', 'item_stage', 'item_link', ...QUESTION_WRITE_VERBS, ...DELIVERY_WRITE_VERBS, ...SWEEP_WRITE_VERBS]);
478
558
  const READ_VERBS = Object.freeze(['registry_list', ...QUESTION_READ_VERBS, ...DELIVERY_READ_VERBS, ...CAPABILITY_READ_VERBS, ...SWEEP_READ_VERBS]);
479
559
 
480
560
  async function run(verb, args = {}) {
package/work/mcp.mjs CHANGED
@@ -14,7 +14,7 @@ import { existsSync, realpathSync, readFileSync, writeFileSync, mkdirSync } from
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { createEngramClient } from './lib/client.mjs';
16
16
  import { createActionStore } from './lib/action-store.mjs';
17
- import { createVerbs, STAGES, TIERS, DRIVERS, LINK_KINDS, REPO_KINDS, MANIFEST_VERSION, COSTS } from './lib/verbs.mjs';
17
+ import { createVerbs, STAGES, TIERS, DRIVERS, LINK_KINDS, REPO_KINDS, WAITS_ON, MANIFEST_VERSION, COSTS } from './lib/verbs.mjs';
18
18
  import { COST_MEANING, DECISION_KINDS } from './lib/questions.mjs';
19
19
  import { FATAL_CONCLUSIONS } from './lib/delivery.mjs';
20
20
  import { createMcpServer } from './lib/mcp-stdio.mjs';
@@ -41,8 +41,10 @@ export const TOOLS = [
41
41
  { name: 'item_propose', description: `Create a proposed work item (lifecycle proposed, stage capture) for a product, with its repos and the owner's words as origin. When: the owner names a piece of work that is not yet locked. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'title', 'product', 'repos', 'origin'], properties: { request_id: REQUEST_ID, title: S('Short title in the owner\'s words.'), product: S('Product id.'), repos: { type: 'array', items: { type: 'string' }, description: 'Repo ids under that product.' }, origin: ORIGIN, tier: S(`One of ${TIERS.join(', ')}. Default standard.`), driver: S(`One of ${DRIVERS.join(', ')}. Default owner-led.`), next_step: S('One line: what happens next.'), summary: S('Short summary.'), body: S('Longer body, optional.'), tags: { type: 'array', items: { type: 'string' } } }, additionalProperties: false } },
42
42
  { name: 'item_lock', description: `Lock a proposed or active item: product and repos by registry id, the scope (text, optional design doc URL and acceptance-criteria hash), and the owner's words as origin. Lifecycle becomes active; stage defaults to build. Refuses an unknown product or repo id, a missing scope or origin, and an item that is already locked. When: the owner says "lock". Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'product', 'repos', 'scope', 'origin'], properties: { request_id: REQUEST_ID, item_id: S('Entity id of the work item (container).'), product: S('Product id.'), repos: { type: 'array', items: { type: 'string' }, description: 'Repo ids under that product.' }, scope: { type: 'object', required: ['text'], properties: { text: S('The accepted scope, in full.'), design_doc: S('URL of the design doc at its lock commit.'), acceptance_criteria_hash: S('64-hex sha256 of the frozen acceptance text, when the design doc carries one.') } }, origin: ORIGIN, tier: S(`One of ${TIERS.join(', ')}.`), stage: S(`One of ${STAGES.join(', ')}. Default build.`), driver: S(`One of ${DRIVERS.join(', ')}.`), next_step: S('One line: what happens next.') }, additionalProperties: false } },
43
43
  { name: 'item_split', description: `Split an item into pieces. Each child is a proposed item (stage capture) with a spun_out_of relation to the parent and the reason. Children inherit product, repos, tier, origin and driver unless given. Refuses an unknown parent, a missing reason, or a child repo outside the product. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'reason', 'children'], properties: { request_id: REQUEST_ID, item_id: S('Parent item id.'), reason: S('Why the split, in the owner\'s words.'), children: { type: 'array', items: { type: 'object', required: ['title'], properties: { title: S('Child title.'), repos: { type: 'array', items: { type: 'string' } }, tier: S(`One of ${TIERS.join(', ')}.`), origin: ORIGIN, driver: S(`One of ${DRIVERS.join(', ')}.`), next_step: S('One line.'), summary: S('Short summary.') } } } }, additionalProperties: false } },
44
- { name: 'item_park', description: `Park an item: lifecycle paused with the reason. Paused items are backlog and appear only on request. Refuses a missing reason. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'reason'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), reason: S('Why it is parked.') }, additionalProperties: false } },
45
- { name: 'item_resume', description: `Resume a parked item to its earlier lifecycle with a reason. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'reason'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), reason: S('Why it resumes.') }, additionalProperties: false } },
44
+ { name: 'item_park', description: `Park an item: lifecycle paused with the reason. Paused items are backlog: the in_flight rows leave them out, and needs_me still shows their open questions. Refuses a missing reason. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'reason'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), reason: S('Why it is parked.') }, additionalProperties: false } },
45
+ { name: 'item_resume', description: `Resume a parked item to its earlier lifecycle with a reason. It also undoes a drop: a dropped item goes back to the lifecycle it left, and the drop's words stay on it. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'reason'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), reason: S('Why it resumes.') }, additionalProperties: false } },
46
+ { name: 'item_drop', description: `Drop an item in the owner's words: lifecycle dropped, with the words, the date and the lifecycle it left. A dropped item stays in the graph, and no live view lists it. Refuses a done item (reopen it instead) and an item already dropped. When: the owner retires a piece of work. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'origin'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), origin: { ...ORIGIN, description: "The owner's words that drop the item, verbatim, with where and when they were said." } }, additionalProperties: false } },
47
+ { name: 'item_edit', description: `Edit a proposed, active or paused item: its next step, what it waits on, or its title. Each edit appends the old values to the item's edits, and the last 20 are kept; the first title stays in first_title. When: the next step or the wait changes; the lead keeps them true at the end of each session. No owner flag for next_step and waits_on; a new title is the owner's words, so it needs the owner present. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), next_step: S('One line: what happens next.'), waits_on: { type: 'object', required: ['on'], description: 'What the item waits on. Shown on its rows.', properties: { on: S(`One of ${WAITS_ON.join(', ')}.`), note: S('What exactly it waits on, in plain words. Required unless on is item or nothing.'), item: S('The item id it waits on, for on: item.') } }, title: S("A new title, in the owner's words. Owner present only."), reason: S('One line on why it changed.') }, additionalProperties: false } },
46
48
  { name: 'item_stage', description: `Move an active item to a stage (${STAGES.join(', ')}). When: work moves; the lead or the delivering agent calls it. No owner flag needed. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'stage'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), stage: S(`One of ${STAGES.join(', ')}.`), note: S('One line on what moved.') }, additionalProperties: false } },
47
49
  { name: 'item_link', description: `Attach a typed link (kind, url, role, note) to an item: a PR, an issue, an artifact, a design doc, a conversation. When: any in-session agent has a link the item should carry, for example a PR it opened. No owner flag needed. A duplicate kind and url is a no-op. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'kind', 'url'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), kind: S(`One of ${LINK_KINDS.join(', ')}.`), url: S('Absolute http or https URL.'), role: S('What the link is to the item, for example "delivers", "design", "evidence".'), note: S('One line.') }, additionalProperties: false } },
48
50
  { name: 'item_adopt', description: `Give an existing work item its product and repos. This is the slice L5 sweep: most items in the tenant were made before the registry existed and name no product, so no view can place them. Refuses an item that already names another registered product; an item is named once. The audit stamps adopted_at and adopted_from move only when the product actually changes. When: the owner says which product a piece of work belongs to. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'product', 'repos'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), product: S('Product id the item belongs to.'), repos: { type: 'array', items: { type: 'string' }, description: 'Repo ids under that product. Required. Pass an empty list for an item that has no repo, such as a container root; a repo of another product is dropped and named in dropped_repos.' }, stage: S(`One of ${STAGES.join(', ')}. Active items only.`), tier: S(`One of ${TIERS.join(', ')}.`), driver: S(`One of ${DRIVERS.join(', ')}.`), next_step: S('One line: what happens next.') }, additionalProperties: false } },
@@ -50,11 +52,11 @@ export const TOOLS = [
50
52
  { name: 'question_answer', description: `Record the owner's answer to one open question. One call writes the decision in the owner's words, keeps the rendered answer as a receipt, and closes the question, so the "Needs me" view loses the row. Refuses an unknown question and one that is already answered. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'question_id', 'text', 'date'], properties: { request_id: REQUEST_ID, question_id: S('Entity id of the open question.'), text: S("The owner's answer, verbatim."), date: S('ISO date the owner said it, in the form YYYY-MM-DD.'), source: S('Where the words are on record: a chat turn, a doc URL, an issue.'), kind: S(`One of ${DECISION_KINDS.join(', ')}. Default answer.`), items: { type: 'array', items: { type: 'string' }, description: 'Item ids the decision touches. Default: the question\'s item.' }, priority_order: { type: 'array', items: { type: 'string' }, description: 'Item ids in the order the owner set.' }, replaces: S('Entity id of the decision this one replaces.'), rendered: S('The view text the owner acted on. Default: the question and the answer.'), view: S('Which view the owner read, for example needs-me.') }, additionalProperties: false } },
51
53
  { name: 'decision_record', description: `Record what the owner decided outside a question, in their own words. Refuses missing text and a missing date. Pass rendered to keep the view the owner acted on as a receipt in the same call. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'text', 'date'], properties: { request_id: REQUEST_ID, text: S("The owner's words, verbatim."), date: S('ISO date the owner said it.'), source: S('Where the words are on record.'), kind: S(`One of ${DECISION_KINDS.join(', ')}. Default decision.`), product: S('Product id the decision is about.'), items: { type: 'array', items: { type: 'string' }, description: 'Item ids the decision touches.' }, priority_order: { type: 'array', items: { type: 'string' }, description: 'Item ids in the order the owner set.' }, replaces: S('Entity id of the decision this one replaces.'), rendered: S('The view text the owner acted on.'), view: S('Which view the owner read.') }, additionalProperties: false } },
52
54
  { name: 'receipt_keep', description: `Keep the rendered answer the owner acted on and attach it to a decision. Refuses when there is no decision to attach to. Secret patterns are redacted before the write. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'decision_id', 'rendered'], properties: { request_id: REQUEST_ID, decision_id: S('Entity id of the decision this receipt belongs to.'), rendered: S('The answer as the owner read it.'), view: S('Which view it came from, for example needs-me.') }, additionalProperties: false } },
53
- { name: 'item_done', description: `Mark an item delivered. What proves it follows the repo kind (amendment A1.7). A service repo gets the three read-only live checks: the deployed commit carries the pull requests, the health endpoint answers, and a functional check scoped to the change passes. Every check is a GET. The merge state of each pull request and the gate runs come from GitHub; the caller supplies none of them, and a red gate (${FATAL_CONCLUSIONS.join(', ')}) refuses the mark. A library or schema repo has no deploy target, so its delivery is proven by the merge and the gate alone: pass no functional, and the product needs no health_url. An item with no registered repo, or a repo whose kind was never set, keeps the three checks. The delivering agent calls this; the lead calls it to catch up a delivery made outside a session. No owner flag. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'prs'], properties: { request_id: REQUEST_ID, item_id: S('Item id. The item must be active, and its product must carry a health_url.'), prs: { type: 'array', items: { type: 'string' }, description: 'The merged GitHub pull request URLs this delivery shipped. Required: the item\'s own pr links are not read, because a link can be a design or a lock, not a delivery.' }, functional: { type: 'object', required: ['url', 'contains'], description: 'The read-only functional check. Required for a service repo, and refused for a library or schema repo. The verb GETs this URL once. No method, body or headers are accepted.', properties: { url: S('Absolute URL on the product health endpoint\'s origin, under one of the product\'s check_paths, and not the health endpoint itself.'), contains: { type: 'array', items: { type: 'string' }, description: 'Required. Strings the answer must carry, chosen so they exist only because of this change. Only whether each was found is recorded; the answer itself never enters the graph.' }, status: { type: 'number', description: 'Expected HTTP status, 2xx only. Default 200.' } } }, note: S('One line on what was delivered.') }, additionalProperties: false } },
55
+ { name: 'item_done', description: `Mark an item delivered. What proves it follows the repo kind (amendment A1.7). A service or schema repo gets the three read-only live checks: the deployed commit carries the pull requests, the health endpoint answers, and a functional check scoped to the change passes. Every check is a GET. The merge state of each pull request and the gate runs come from GitHub; the caller supplies none of them, and a red gate (${FATAL_CONCLUSIONS.join(', ')}) refuses the mark. A library repo has no deploy target, so its delivery is proven by the merge and the gate alone: pass no functional, and the product needs no health_url. A schema repo keeps the three checks, because its migration runs after the merge. An item with no registered repo, or a repo whose kind was never set, keeps the three checks. The delivering agent calls this; the lead calls it to catch up a delivery made outside a session. No owner flag. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'prs'], properties: { request_id: REQUEST_ID, item_id: S('Item id. The item must be active. Unless every repo it names is a library, its product must carry a health_url.'), prs: { type: 'array', items: { type: 'string' }, description: 'The merged GitHub pull request URLs this delivery shipped. Required: the item\'s own pr links are not read, because a link can be a design or a lock, not a delivery.' }, functional: { type: 'object', required: ['url', 'contains'], description: 'The read-only functional check. Required for a service or schema repo, and refused for a library repo. The verb GETs this URL once. No method, body or headers are accepted.', properties: { url: S('Absolute URL on the product health endpoint\'s origin, under one of the product\'s check_paths, and not the health endpoint itself.'), contains: { type: 'array', items: { type: 'string' }, description: 'Required. Strings the answer must carry, chosen so they exist only because of this change. Only whether each was found is recorded; the answer itself never enters the graph.' }, status: { type: 'number', description: 'Expected HTTP status, 2xx only. Default 200.' } } }, note: S('One line on what was delivered.') }, additionalProperties: false } },
54
56
  { name: 'item_reopen', description: `Reopen a delivered item: the lifecycle goes back to active and a reopen mark is appended, citing the failed check by URL. The delivery mark is never erased; the reopen points at it with the corrects relation. The owner, or the lead in this session, calls it. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'reason', 'failed_check'], properties: { request_id: REQUEST_ID, item_id: S('Item id carrying at least one delivery mark.'), reason: S('Why it is reopened, in plain words.'), failed_check: S('Absolute URL of the check that failed: a CI run, a monitor, an issue.'), corrects: S('Mark id of the delivery this reopen corrects. Default: the item\'s latest delivery mark.') }, additionalProperties: false } },
55
- { name: 'in_flight', description: 'Answer "what are we working on, and what changed since a date?". Active items per product, ordered by days since the last change with the latest on top, and the days shown on each row. Movement is read from the marks and from each registered repo\'s recent pull requests: the item\'s own pr links, plus any pull request whose title, body or branch names the item id. Merged pull requests with no delivery mark are listed as "merged, not marked". A legacy seal reads as a delivery mark with verdict "legacy". Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', properties: { product: S('Limit the view to one product id.'), since: S('ISO date. Adds what was delivered since then.') }, additionalProperties: false } },
57
+ { name: 'in_flight', description: 'Answer "what are we working on, and what changed since a date?". Active items per product, ordered by the owner\'s current priority decision first, when it carries an order, then by days since the last change with the latest on top, and the days shown on each row. Each row says what the item waits on. Movement is read from the marks and from each registered repo\'s recent pull requests: the item\'s own pr links, plus any pull request whose title, body or branch names the item id. Merged pull requests with no delivery mark are listed as "merged, not marked". A legacy seal reads as a delivery mark with verdict "legacy". Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', properties: { product: S('Limit the view to one product id.'), since: S('ISO date. Adds what was delivered since then.') }, additionalProperties: false } },
56
58
  { name: 'sweep_check', description: 'Check the sweep against the "In flight" view: the newest decision record of kind sweep names the items the owner called in flight, and this reads the view and names every difference — what is listed and not in flight, and what is in flight and not listed. A product with no sweep on record is named too. It answers two things apart: `matches` says each product\'s view holds exactly what its sweep lists, and `complete` says that and that no item in the tenant sits outside every product. An item nobody adopted is missing from both sides, so `matches` alone can read "equal" over an unfinished sweep. Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', properties: { product: S('Limit the check to one product id.') }, additionalProperties: false } },
57
- { name: 'needs_me', description: 'Answer "what needs me?": every open question on an active item, per product, ordered by what waiting costs. Each row is three lines, the question, the item and the cost, with the context folded under it. Computed at read time and never stored. Scope: read, no owner flag needed. Pass the row text back as rendered when the owner answers.', inputSchema: { type: 'object', properties: { product: S('Limit the view to one product id.') }, additionalProperties: false } },
59
+ { name: 'needs_me', description: 'Answer "what needs me?": every open question on a proposed, active or paused item, per product, ordered by what waiting costs. A row on an item that is not active names its lifecycle. Each row is three lines, the question, the item and the cost, with the context folded under it. Computed at read time and never stored. Scope: read, no owner flag needed. Pass the row text back as rendered when the owner answers.', inputSchema: { type: 'object', properties: { product: S('Limit the view to one product id.') }, additionalProperties: false } },
58
60
  { name: 'code_read_brief', description: 'The brief for the sub-agents that read the code for "where are we with X", one per registered repo of the product. It carries the never-read and never-quote rules, the citation contract and the JSON shape of the digest. Give one brief to one sub-agent; sub-agents never receive this server or the Engram MCP. Pass each digest back in where_are_we.code. When: before answering "where are we with X". Scope: read, no owner flag needed; it opens no repo and writes nothing.', inputSchema: { type: 'object', required: ['product', 'capability'], properties: { product: S('Product registry id.'), capability: S('The capability to read for, the X of the question.'), repos: { type: 'array', description: 'Limit the briefs to these registered repo ids. Default: every repo of the product.', items: { type: 'string' } }, roots: { type: 'object', description: 'Repo id to the local checkout path the sub-agent reads.', additionalProperties: { type: 'string' } }, commits: { type: 'object', description: 'Repo id to the commit the read runs against.', additionalProperties: { type: 'string' } }, focus: { type: 'array', description: 'Extra things the read should look for.', items: { type: 'string' } } }, additionalProperties: false } },
59
61
  { name: 'where_are_we', description: 'Answer "where are we with X" in six parts: live support and known limits from the code read at question time, then in progress, delivered and proposed from the items, then the current priority decision. Every row carries a citation: a path and a line, an entity id, a pull request, or a decision date. The code half comes from the sub-agent digests, which are refused when they cite a never-read path or quote a never-quote path, and every string is scrubbed by pattern. Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', required: ['product', 'capability', 'code'], properties: { product: S('Product registry id.'), capability: S('The capability the question is about, the X.'), code: { type: 'array', description: 'One digest per repo, from the sub-agents briefed by code_read_brief. At least one is required: an answer with no code read says nothing about what the code does today.', items: CODE_DIGEST }, items: { type: 'array', description: 'Item ids the lead judges to answer this question.', items: { type: 'string' } }, match: { type: 'array', description: 'Terms to match against item titles, summaries and tags. Give items, match, or both.', items: { type: 'string' } }, movement_repos: { type: 'array', description: "Narrow the movement read to these registered repo ids. Default: every repo of the product. The digests' own repos are always read.", items: { type: 'string' } }, since: S('ISO date. Adds what changed since then.') }, additionalProperties: false } },
60
62
  { name: 'built_pending_possible', description: 'Answer "what are we building, what is built, what is pending and what can be done": delivered items by date, active items, paused items with their reason, proposed items with their age, and the decisions that set the order. Item records, delivery marks and decisions only; no code read and no GitHub read. Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', properties: { product: S('Limit the view to one product id.'), since: S('ISO date. Limits built and the decisions to what came after it.') }, additionalProperties: false } },