@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/agents/artifact-renderer.md +1 -1
- package/agents/code-explorer.md +2 -1
- package/agents/reviewer-architect.md +2 -2
- package/agents/reviewer-pm.md +3 -3
- package/agents/reviewer-security.md +10 -7
- package/package.json +1 -1
- package/skills/sdlc-task/SKILL.md +8 -2
- package/skills/sdlc-task/lifecycle.yaml +3 -1
- package/skills/sdlc-task/templates/design-doc.md +2 -2
- package/skills/sdlc-task/templates/resume-anchor.md +9 -7
- package/work/lib/capability.mjs +34 -23
- package/work/lib/delivery.mjs +14 -6
- package/work/lib/questions.mjs +54 -10
- package/work/lib/verb-fields.mjs +3 -0
- package/work/lib/verbs.mjs +86 -6
- package/work/mcp.mjs +8 -6
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
|
|
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
|
|
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.
|
package/agents/code-explorer.md
CHANGED
|
@@ -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 (
|
|
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
|
-
(
|
|
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 (
|
|
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
|
package/agents/reviewer-pm.md
CHANGED
|
@@ -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
|
-
(
|
|
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 (
|
|
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
|
|
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
|
-
(
|
|
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 (
|
|
9
|
-
|
|
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 (
|
|
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
|
|
44
|
-
|
|
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
|
@@ -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`
|
|
164
|
-
|
|
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.
|
|
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 (
|
|
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 (
|
|
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 (
|
|
1
|
+
<!-- Resume anchor — template (sdlc-task skill, "pause").
|
|
2
2
|
For work BELOW the design-doc threshold: hotfix tier, bug investigations,
|
|
3
|
-
any interrupted
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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|
|
|
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.>
|
package/work/lib/capability.mjs
CHANGED
|
@@ -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
|
-
|
|
386
|
-
const
|
|
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
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
...(
|
|
472
|
-
|
|
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
|
});
|
package/work/lib/delivery.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|
package/work/lib/questions.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
254
|
-
|
|
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
|
-
|
|
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;
|
package/work/lib/verb-fields.mjs
CHANGED
|
@@ -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; }
|
package/work/lib/verbs.mjs
CHANGED
|
@@ -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
|
-
|
|
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 ?? {}), ...
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 } },
|