@arjunkhera/atlas 0.4.18 → 0.4.20
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/door/cli.mjs +6 -1
- package/door/lib/gate.mjs +34 -3
- package/package.json +1 -1
- package/skills/lead/SKILL.md +5 -0
- package/skills/upkeep/SKILL.md +4 -5
- package/work/lib/activity.mjs +9 -4
- package/work/lib/delivery.mjs +5 -1
- package/work/lib/questions.mjs +5 -19
- package/work/lib/verbs.mjs +13 -19
- package/work/mcp.mjs +1 -4
- package/work/lib/sweep.mjs +0 -189
package/door/cli.mjs
CHANGED
|
@@ -38,7 +38,7 @@ import { changedCommand as testsChanged } from './lib/changed-tests.mjs';
|
|
|
38
38
|
import { beforeCommand as testsBefore } from './lib/fails-before.mjs';
|
|
39
39
|
import { afterCommand as testsAfter } from './lib/passes-after.mjs';
|
|
40
40
|
import { upkeepCommand } from './lib/upkeep.mjs';
|
|
41
|
-
import { gateCommand } from './lib/gate.mjs';
|
|
41
|
+
import { gateCommand, mergeList } from './lib/gate.mjs';
|
|
42
42
|
import { judgeCommand } from './lib/judge.mjs';
|
|
43
43
|
|
|
44
44
|
export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -290,6 +290,8 @@ function statusCommand(chosen) {
|
|
|
290
290
|
line(` ${row.why}`);
|
|
291
291
|
if (row.copy) line(` copy sha256 ${row.copy.sha256.slice(0, 12)}… check version ${row.copy.check_version ?? '?'}`);
|
|
292
292
|
if (row.packaged) line(` package sha256 ${row.packaged.sha256.slice(0, 12)}… check version ${row.packaged.check_version}${row.packaged.released ? '' : ' (not released)'}`);
|
|
293
|
+
const list = mergeList(row.root);
|
|
294
|
+
if (list) line(` ${list.line}`);
|
|
293
295
|
line('');
|
|
294
296
|
}
|
|
295
297
|
line(`Decided by the release list in Atlas ${ATLAS_VERSION}, never by git history.`);
|
|
@@ -461,6 +463,9 @@ function checkCommand(chosen) {
|
|
|
461
463
|
const report = scan({ root, repoId: chosen.repo ?? null });
|
|
462
464
|
const decision = verdict(report, chosen['fail-on'] ?? 'error');
|
|
463
465
|
process.stdout.write(toText(report));
|
|
466
|
+
// A report, never a reason for red: the list is an owner-only key.
|
|
467
|
+
const list = mergeList(root);
|
|
468
|
+
if (list && list.state !== 'listed') line(list.line);
|
|
464
469
|
line(`${decision.green ? 'GREEN' : 'RED'}: ${decision.why}`);
|
|
465
470
|
return decision.green ? 0 : 1;
|
|
466
471
|
}
|
package/door/lib/gate.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// The plan is built by pure functions, so a test reads it without GitHub. The
|
|
16
16
|
// gh calls sit in one place, behind `gh`, which a test replaces.
|
|
17
17
|
import { execFileSync } from 'node:child_process';
|
|
18
|
-
import { readFileSync, existsSync, mkdirSync, writeFileSync, rmSync, renameSync } from 'node:fs';
|
|
18
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync, rmSync, renameSync, readdirSync } from 'node:fs';
|
|
19
19
|
import { homedir } from 'node:os';
|
|
20
20
|
import { dirname, join } from 'node:path';
|
|
21
21
|
import { childEnvironment } from '../../work/lib/child-env.mjs';
|
|
@@ -38,16 +38,40 @@ export function gateFacts(root) {
|
|
|
38
38
|
return factsOf(readFileSync(path, 'utf8'));
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// A repo with no list cannot merge through the door: ready reads the list,
|
|
42
|
+
// and the door never makes one up (item 3d675dc7).
|
|
43
|
+
export const NO_LIST = 'atlas.yaml lists no required_checks.';
|
|
44
|
+
export const LIST_FIX = 'Fix: add required_checks to atlas.yaml, with the names of the CI jobs that run on every pull request. The key is owner-only, so the owner merges that pull request by hand.';
|
|
45
|
+
|
|
41
46
|
function factsOf(text) {
|
|
42
47
|
const facts = parseYaml(text) ?? {};
|
|
43
48
|
const checks = facts.required_checks;
|
|
44
49
|
if (!Array.isArray(checks) || checks.length === 0 || checks.some((name) => typeof name !== 'string' || !name.trim())) {
|
|
45
|
-
throw new GateError(
|
|
50
|
+
throw Object.assign(new GateError(`${NO_LIST} Nothing was changed.`), { noList: true });
|
|
46
51
|
}
|
|
47
52
|
if (facts.up_to_date !== undefined && typeof facts.up_to_date !== 'boolean') throw new GateError('atlas.yaml up_to_date must be true or false. Nothing was changed.');
|
|
48
53
|
return { checks: [...checks], upToDate: facts.up_to_date === true };
|
|
49
54
|
}
|
|
50
55
|
|
|
56
|
+
// Can this repo merge through the door? The answer does not read the repo
|
|
57
|
+
// kind: each kind merges on the same list. `atlas status` and `atlas check`
|
|
58
|
+
// print it.
|
|
59
|
+
// listed atlas.yaml names the checks
|
|
60
|
+
// missing CI runs, but atlas.yaml names no checks
|
|
61
|
+
// no-ci no workflow runs, so no list can exist yet
|
|
62
|
+
export function mergeList(root) {
|
|
63
|
+
const path = join(root, 'atlas.yaml');
|
|
64
|
+
if (!existsSync(path)) return null;
|
|
65
|
+
let checks = null;
|
|
66
|
+
try { checks = factsOf(readFileSync(path, 'utf8')).checks; } catch (error) { if (!error.noList) return { state: 'missing', checks: [], line: `merge door: atlas.yaml cannot be read (${error.message}). ${LIST_FIX}` }; }
|
|
67
|
+
if (checks) return { state: 'listed', checks, line: `merge door: ${checks.length} required check(s) in atlas.yaml.` };
|
|
68
|
+
const flows = join(root, '.github', 'workflows');
|
|
69
|
+
const ci = existsSync(flows) && readdirSync(flows).some((name) => /\.ya?ml$/.test(name));
|
|
70
|
+
return ci
|
|
71
|
+
? { state: 'missing', checks: [], line: `merge door: ${NO_LIST} ${LIST_FIX}` }
|
|
72
|
+
: { state: 'no-ci', checks: [], line: 'merge door: no list, because no CI workflow runs here. Merges stay with the owner.' };
|
|
73
|
+
}
|
|
74
|
+
|
|
51
75
|
// The two ruleset bodies, as the GitHub rulesets API takes them.
|
|
52
76
|
export function rulesets({ checks, upToDate }, enforcement = 'active') {
|
|
53
77
|
return [
|
|
@@ -317,7 +341,14 @@ export function readyCommand({ repo, pr, answer }, line, gh = ghApi) {
|
|
|
317
341
|
if (answer && typeof pull?.head?.sha === 'string') answer.sha = pull.head.sha;
|
|
318
342
|
if (pull?.state !== 'open') { line(`atlas gate ready: pull request #${pr} is ${pull?.merged ? 'merged' : pull?.state ?? 'not found'}. NOT READY.`); return 1; }
|
|
319
343
|
const sha = pull.head.sha;
|
|
320
|
-
|
|
344
|
+
let checks;
|
|
345
|
+
try { checks = checksAt(gh, repo, pull.base.ref); } catch (error) {
|
|
346
|
+
if (!error.noList) throw error;
|
|
347
|
+
line(`atlas gate ready: #${pr} at ${sha.slice(0, 7)}: atlas.yaml lists no required_checks on ${pull.base.ref}.`);
|
|
348
|
+
line(` ${LIST_FIX}`);
|
|
349
|
+
line('NOT READY: do not merge. Until the list exists, merges in this repo stay with the owner.');
|
|
350
|
+
return 1;
|
|
351
|
+
}
|
|
321
352
|
const { kept, refused } = pinnedRuns(gh, repo, { pr, sha, baseRef: pull.base.ref, head: pull.head, checks });
|
|
322
353
|
const rows = checkStates(checks, kept);
|
|
323
354
|
line(`atlas gate ready: #${pr} at ${sha.slice(0, 7)}, ${checks.length} required check(s) from atlas.yaml on ${pull.base.ref}.`);
|
package/package.json
CHANGED
package/skills/lead/SKILL.md
CHANGED
|
@@ -117,6 +117,11 @@ set. A test keeps the two lists equal.
|
|
|
117
117
|
A person merges every file that `atlas tooling` writes.
|
|
118
118
|
A person merges every file that `atlas tests write` writes.
|
|
119
119
|
|
|
120
|
+
When a repo joins Atlas, its first pull request lists `required_checks` in
|
|
121
|
+
`atlas.yaml`: the CI jobs that run on every pull request. The owner merges
|
|
122
|
+
that pull request by hand. With no list, the merge check says NOT READY,
|
|
123
|
+
and merges stay with the owner.
|
|
124
|
+
|
|
120
125
|
## 7. The first reply of a cloud session
|
|
121
126
|
|
|
122
127
|
A cloud session can start from an old setup and run an old Atlas. So in a
|
package/skills/upkeep/SKILL.md
CHANGED
|
@@ -21,8 +21,8 @@ Design: `docs/design-docs/upkeep-routine/` in the Atlas repo.
|
|
|
21
21
|
|
|
22
22
|
The run never calls these verbs. They are the owner's acts.
|
|
23
23
|
|
|
24
|
-
1. `item_lock`, `item_propose`, `item_split
|
|
25
|
-
2. `decision_record`, `question_answer`, `question_ask
|
|
24
|
+
1. `item_lock`, `item_propose`, `item_split`.
|
|
25
|
+
2. `decision_record`, `question_answer`, `question_ask`.
|
|
26
26
|
3. `item_drop`, `item_park`, `item_resume`, `item_reopen`.
|
|
27
27
|
4. `product_add`, `product_edit`, `repo_add`, `repo_edit`, `setup`.
|
|
28
28
|
|
|
@@ -62,9 +62,8 @@ Pass the product to every read. Show no other product.
|
|
|
62
62
|
that a tracker item links, or that names an item id. Comment the item id
|
|
63
63
|
and its state, and close the issue as completed. Owner rule, 21 October
|
|
64
64
|
2026: an issue is not kept open once its item exists.
|
|
65
|
-
4. **Read only, report only.** Call `
|
|
66
|
-
|
|
67
|
-
report. Do not act on them.
|
|
65
|
+
4. **Read only, report only.** Call `needs_me` for the product. Put any
|
|
66
|
+
question older than seven days in the report. Do not act on it.
|
|
68
67
|
5. **Red merges.** When `atlas.yaml` lists `required_checks`, run
|
|
69
68
|
`atlas gate` with the verb `audit`, `--repo` and `--since` set to
|
|
70
69
|
yesterday's date. Put each merge it names in the report, under "Left for
|
package/work/lib/activity.mjs
CHANGED
|
@@ -17,7 +17,7 @@ export const ACTIVITY_READ_VERBS = Object.freeze(['item_activity']);
|
|
|
17
17
|
export const VOICES = Object.freeze(['agent', 'owner']);
|
|
18
18
|
// The item verbs whose writes carry a diff (step 9). item_propose writes a
|
|
19
19
|
// "created" diff when it makes the item.
|
|
20
|
-
export const LOG_VERBS = Object.freeze(['item_lock', 'item_split', 'item_park', 'item_resume', 'item_drop', 'item_edit', 'item_stage', 'item_link', 'item_block', 'item_unblock', 'item_done', 'item_reopen'
|
|
20
|
+
export const LOG_VERBS = Object.freeze(['item_lock', 'item_split', 'item_park', 'item_resume', 'item_drop', 'item_edit', 'item_stage', 'item_link', 'item_block', 'item_unblock', 'item_done', 'item_reopen']);
|
|
21
21
|
export const ENTRY_VERBS = Object.freeze(['item_propose', ...LOG_VERBS]);
|
|
22
22
|
const MAX_COMMENT = 4000;
|
|
23
23
|
const PREVIEW = 300;
|
|
@@ -207,9 +207,14 @@ export function createActivityVerbs(core) {
|
|
|
207
207
|
if (row.fields?.item !== item.id) continue;
|
|
208
208
|
rows.push({ at: row.fields.marked_at, kind: 'mark', mark_id: row.id, mark_kind: row.fields.kind, verdict: row.fields.verdict ?? null });
|
|
209
209
|
}
|
|
210
|
-
|
|
210
|
+
// A piece the feed cannot read keeps its row, with the reason, so a
|
|
211
|
+
// split never hides a piece (roadmap R70 line 1).
|
|
212
|
+
const pieces = await Promise.all((Array.isArray(fields.split_children) ? fields.split_children : []).map((id) => client.getEntity(id).catch((error) => ({ unreadable: id, reason: oneLine(String(error?.message ?? error), 200) }))));
|
|
211
213
|
for (const piece of pieces) {
|
|
212
|
-
if (!piece)
|
|
214
|
+
if (!piece || piece.unreadable) {
|
|
215
|
+
rows.push({ at: null, kind: 'piece', item: piece?.unreadable ?? null, title: null, split_order: null, unreadable: true, reason: piece?.reason ?? 'the graph returned nothing' });
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
213
218
|
// The piece's date is the split that made it, not its last write.
|
|
214
219
|
const made = Object.values(piece.fields?.atlas_receipts ?? {}).filter((receipt) => receipt?.verb === 'item_split').map((receipt) => receipt.at).sort()[0] ?? piece.created_at ?? null;
|
|
215
220
|
rows.push({ at: made, kind: 'piece', item: piece.id, title: oneLine(piece.title ?? '', 200), split_order: piece.fields?.split_order ?? null });
|
|
@@ -239,7 +244,7 @@ function lineOf(row) {
|
|
|
239
244
|
case 'question': return oneLine(`${when} question (${row.status}) ${row.text}`);
|
|
240
245
|
case 'decision': return oneLine(`${when} decision (${row.decision_kind}) "${row.text}"`);
|
|
241
246
|
case 'mark': return oneLine(`${when} mark ${row.mark_kind}${row.verdict ? `: ${row.verdict}` : ''}`);
|
|
242
|
-
case 'piece': return oneLine(`${when} piece ${row.title} · ${row.item}`);
|
|
247
|
+
case 'piece': return row.unreadable ? oneLine(`${when} piece ${row.item} could not be read: ${row.reason}`) : oneLine(`${when} piece ${row.title} · ${row.item}`);
|
|
243
248
|
case 'link': return oneLine(`${when} link (${row.link_kind}) ${row.url}`);
|
|
244
249
|
default: return oneLine(`${when} ${row.kind}`);
|
|
245
250
|
}
|
package/work/lib/delivery.mjs
CHANGED
|
@@ -809,7 +809,11 @@ export function createDeliveryVerbs(core) {
|
|
|
809
809
|
const { last, days } = movementOf({ item, marks: own, pulls: mine, legacy, readAt });
|
|
810
810
|
const state = fields.lifecycle === 'done' ? 'delivered' : (fields.delivery_state === 'reopened' ? 'reopened' : fields.lifecycle);
|
|
811
811
|
|
|
812
|
-
|
|
812
|
+
// Only active work feeds "merged, not marked". A done item's other
|
|
813
|
+
// pull requests (its design, its lock) are history, not a gap; a
|
|
814
|
+
// reopened item is active again, so its pull requests come back
|
|
815
|
+
// (roadmap R70 line 5).
|
|
816
|
+
const inScope = fields.lifecycle === 'active';
|
|
813
817
|
for (const pull of (inScope ? open : [])) notMarked.push({ item: item.id, item_title: oneLine(item.title, 120), product: fields.product, pr: pull.url, title: pull.title, merged_at: pull.merged_at, days: daysBetween(pull.merged_at, readAt), matched_by: pull.matched_by, note: 'merged, not marked' });
|
|
814
818
|
for (const mark of deliveries) if (!since || String(mark.fields.marked_at ?? '') >= since) delivered.push({ item: item.id, item_title: oneLine(item.title, 120), product: fields.product, mark_id: mark.id, verdict: correctedBy.has(mark.id) ? 'corrected' : (mark.fields.verdict ?? 'delivered'), ...(correctedBy.has(mark.id) ? { corrected_by: correctedBy.get(mark.id) } : {}), delivered_at: mark.fields.marked_at, prs: (mark.fields.prs ?? []).map((pull) => pull.url), deployed_commit: mark.fields.deployed?.commit ?? null, gate_warnings: mark.fields.gate_warnings ?? [] });
|
|
815
819
|
if (legacy && (!since || String(legacy.marked_at) >= since)) delivered.push({ item: item.id, item_title: oneLine(item.title, 120), product: fields.product, mark_id: null, verdict: 'legacy', delivered_at: legacy.marked_at, prs: [], deployed_commit: null, source: legacy.source });
|
package/work/lib/questions.mjs
CHANGED
|
@@ -16,7 +16,7 @@ export { COSTS, COST_MEANING, ROW_LINE_MAX, oneLine, CLEARED_LIFECYCLES, blocker
|
|
|
16
16
|
// any other: the owner's words, dated, listing the items (design finding R7).
|
|
17
17
|
export const DECISION_KINDS = Object.freeze(['decision', 'priority', 'answer', 'sweep']);
|
|
18
18
|
export const QUESTION_STATUS = Object.freeze(['open', 'answered']);
|
|
19
|
-
export const QUESTION_WRITE_VERBS = Object.freeze(['question_ask', 'question_answer', 'decision_record'
|
|
19
|
+
export const QUESTION_WRITE_VERBS = Object.freeze(['question_ask', 'question_answer', 'decision_record']);
|
|
20
20
|
export const QUESTION_READ_VERBS = Object.freeze(['needs_me']);
|
|
21
21
|
|
|
22
22
|
// R8: the row budget. Three visible lines; everything else folds under it.
|
|
@@ -131,10 +131,9 @@ export function createQuestionVerbs(core) {
|
|
|
131
131
|
return entity;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
//
|
|
135
|
-
// the sweep's real ceiling
|
|
136
|
-
//
|
|
137
|
-
// so a long list costs one read each.
|
|
134
|
+
// A sweep record of slice L5 lists every active item of a product, so this
|
|
135
|
+
// cap is the sweep's real ceiling (architect review of Batch 15, finding 5).
|
|
136
|
+
// Every id is verified, so a long list costs one read each.
|
|
138
137
|
async function itemsTouched(value, reg, fallback) {
|
|
139
138
|
if (value === undefined) return fallback ? [fallback] : [];
|
|
140
139
|
const ids = stringList(value, 'items', { max: 200 });
|
|
@@ -148,7 +147,7 @@ export function createQuestionVerbs(core) {
|
|
|
148
147
|
const type = await typeNamed('receipt');
|
|
149
148
|
const prior = (await findCreated(type, requestId))[0];
|
|
150
149
|
if (prior) return prior;
|
|
151
|
-
const entity = await create(type, requestId, meta?.verb ?? '
|
|
150
|
+
const entity = await create(type, requestId, meta?.verb ?? 'decision_record', {
|
|
152
151
|
digest: meta?.digest,
|
|
153
152
|
title: `Receipt · ${oneLine(view || 'answer', 80)} · ${(question ?? decision).id.slice(0, 8)}`,
|
|
154
153
|
body: clean.text,
|
|
@@ -260,19 +259,6 @@ export function createQuestionVerbs(core) {
|
|
|
260
259
|
return { verb: 'decision_record', decision_id: decision.id, version: decision.version, kind: decision.fields.kind, date: decision.fields.date, items: decision.fields.items ?? [], receipt_id: receipt?.id ?? null, redactions: receipt?.fields.redactions ?? 0, ...(prior ? { replayed_from_graph: true } : {}) };
|
|
261
260
|
},
|
|
262
261
|
|
|
263
|
-
// Keep the rendered answer the owner acted on. D2: refuses with no decision to attach to.
|
|
264
|
-
async receipt_keep(args, requestId, meta) {
|
|
265
|
-
requireOwner('receipt keep');
|
|
266
|
-
const decision = await recordOfKind(args.decision_id, 'decision', 'decision_id');
|
|
267
|
-
const rendered = text(args.rendered, 'rendered', { max: MAX_RENDERED });
|
|
268
|
-
const view = text(args.view, 'view', { optional: true, max: 200 }) ?? '';
|
|
269
|
-
const type = await typeNamed('receipt');
|
|
270
|
-
const prior = await landed(type, requestId, meta);
|
|
271
|
-
if (prior) { await edge(decision, prior, 'references', requestId); return { verb: 'receipt_keep', receipt_id: prior.id, version: prior.version, decision_id: decision.id, redactions: prior.fields.redactions ?? 0, replayed_from_graph: true }; }
|
|
272
|
-
const receipt = await writeReceipt({ decision, question: null, rendered, view, requestId, meta, product: decision.fields?.product ?? '' });
|
|
273
|
-
return { verb: 'receipt_keep', receipt_id: receipt.id, version: receipt.version, decision_id: decision.id, redactions: receipt.fields.redactions ?? 0 };
|
|
274
|
-
},
|
|
275
|
-
|
|
276
262
|
// D3: the "Needs me" view. Computed at question time, per product, never stored.
|
|
277
263
|
async needs_me(args = {}) {
|
|
278
264
|
const reg = await registry();
|
package/work/lib/verbs.mjs
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
// Slice L3 adds the done and reopen verbs and the "In flight" view from
|
|
5
5
|
// delivery.mjs, through the same door again. Slice L4 adds the code-read
|
|
6
6
|
// brief, the six-part answer and the "Built, pending, possible" view from
|
|
7
|
-
// capability.mjs: three read verbs that write nothing. Slice L5
|
|
8
|
-
// sweep
|
|
9
|
-
//
|
|
7
|
+
// capability.mjs: three read verbs that write nothing. Slice L5 added the
|
|
8
|
+
// one-time sweep and its two verbs. The sweep was done, and the verbs left
|
|
9
|
+
// the server on 26 October 2026 (roadmap R70 line 3).
|
|
10
10
|
// Design: the Atlas M1 study, pages D1, D2, D3 and D6,
|
|
11
11
|
// lock block L1, L2 and L3. Every write carries request_id and writer. The
|
|
12
12
|
// server, not the caller, supplies the session. Unknown ids are refused.
|
|
@@ -19,7 +19,6 @@ import { VerbError, refuse, canonicalJson, sha256, text, oneOf, origin, url, str
|
|
|
19
19
|
import { createQuestionVerbs, COSTS, QUESTION_WRITE_VERBS, QUESTION_READ_VERBS, oneLine, scrubSecrets } from './questions.mjs';
|
|
20
20
|
import { createDeliveryVerbs, DELIVERY_WRITE_VERBS, DELIVERY_READ_VERBS } from './delivery.mjs';
|
|
21
21
|
import { createCapabilityVerbs, CAPABILITY_READ_VERBS, hubOf } from './capability.mjs';
|
|
22
|
-
import { createSweepVerbs, SWEEP_WRITE_VERBS, SWEEP_READ_VERBS } from './sweep.mjs';
|
|
23
22
|
import { createRoundupVerbs, ROUNDUP_READ_VERBS } from './roundup.mjs';
|
|
24
23
|
import { createActivityVerbs, diffOf, LOG_VERBS, ENTRY_VERBS, ACTIVITY_WRITE_VERBS, ACTIVITY_READ_VERBS } from './activity.mjs';
|
|
25
24
|
import { createStepInVerbs, STEP_IN_WRITE_VERBS, STEP_IN_READ_VERBS } from './step-ins.mjs';
|
|
@@ -452,15 +451,15 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
452
451
|
const reg = await registry();
|
|
453
452
|
const product = await requireProduct(args.product, reg);
|
|
454
453
|
const repos = await requireRepos(args.repos, reg, product.fields.registry_id);
|
|
455
|
-
if (!args.scope || typeof args.scope !== 'object' || Array.isArray(args.scope)) refuse('scope is required: { text, design_doc
|
|
454
|
+
if (!args.scope || typeof args.scope !== 'object' || Array.isArray(args.scope)) refuse('scope is required: { text, design_doc? }');
|
|
455
|
+
// A design doc once carried a hash of its criteria. The definition of
|
|
456
|
+
// done is the scope text now, and the verb keeps its own fingerprint.
|
|
457
|
+
// A new lock never takes a hash; an old record that holds one still
|
|
458
|
+
// reads (roadmap R70 line 4).
|
|
459
|
+
if (args.scope.acceptance_criteria_hash !== undefined) refuse('scope.acceptance_criteria_hash is for old records only: a new lock takes no hash. Put the definition of done in scope.text, and the verb keeps its fingerprint');
|
|
456
460
|
const scope = { text: text(args.scope.text, 'scope.text') };
|
|
457
461
|
if (args.scope.design_doc !== undefined) scope.design_doc = url(args.scope.design_doc, 'scope.design_doc');
|
|
458
|
-
|
|
459
|
-
text(args.scope.acceptance_criteria_hash, 'scope.acceptance_criteria_hash', { max: 64 });
|
|
460
|
-
if (!/^[a-f0-9]{64}$/.test(args.scope.acceptance_criteria_hash)) refuse('scope.acceptance_criteria_hash must be a 64-hex sha256');
|
|
461
|
-
scope.acceptance_criteria_hash = args.scope.acceptance_criteria_hash;
|
|
462
|
-
}
|
|
463
|
-
const scopeHash = scope.acceptance_criteria_hash ?? sha256(scope.text);
|
|
462
|
+
const scopeHash = sha256(scope.text);
|
|
464
463
|
const stage = oneOf(args.stage, 'stage', STAGES, fields.lifecycle === 'active' && fields.stage ? fields.stage : 'build');
|
|
465
464
|
const change = {
|
|
466
465
|
lifecycle: 'active', stage, product: product.fields.registry_id, repos, tier: oneOf(args.tier, 'tier', TIERS, fields.tier ?? 'standard'),
|
|
@@ -743,16 +742,11 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
743
742
|
// reopen verbs and the four views share this door, this journal, this
|
|
744
743
|
// registry and this writer stamp. The delivery verbs also get the two
|
|
745
744
|
// read-only readers: GitHub through the gh CLI, and one GET against the
|
|
746
|
-
// deployed app. Slice L4 adds three read verbs, which write nothing.
|
|
747
|
-
// L5 adds item_adopt, the one verb that gives an existing item a product,
|
|
748
|
-
// and sweep_check, which reads the sweep record against the "In flight"
|
|
749
|
-
// view. The sweep record itself is written by decision_record with kind
|
|
750
|
-
// "sweep": slice L5 adds no second way to write a decision.
|
|
745
|
+
// deployed app. Slice L4 adds three read verbs, which write nothing.
|
|
751
746
|
const core = { client, label, now, typeNamed, kindOf, registry, requireProduct, requireItem, requireOwner, patch, create, edge, landed, alreadyPatched, findCreated };
|
|
752
747
|
Object.assign(verbs, createQuestionVerbs(core));
|
|
753
748
|
Object.assign(verbs, createDeliveryVerbs({ ...core, ownerPresent, readGitHub, readLive }));
|
|
754
749
|
Object.assign(verbs, createCapabilityVerbs({ ...core, readGitHub }));
|
|
755
|
-
Object.assign(verbs, createSweepVerbs({ ...core, requireRepos, inFlight: (args) => verbs.in_flight(args), stages: STAGES, tiers: TIERS, drivers: DRIVERS }));
|
|
756
750
|
Object.assign(verbs, createRoundupVerbs({ ...core, readGitHub, inFlight: (args) => verbs.in_flight(args), needsMe: (args) => verbs.needs_me(args) }));
|
|
757
751
|
const activity = createActivityVerbs(core);
|
|
758
752
|
Object.assign(verbs, activity.verbs);
|
|
@@ -761,8 +755,8 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
761
755
|
const stepIns = createStepInVerbs(core);
|
|
762
756
|
Object.assign(verbs, stepIns.verbs);
|
|
763
757
|
|
|
764
|
-
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_block', 'item_unblock', 'item_stage', 'item_link', ...QUESTION_WRITE_VERBS, ...DELIVERY_WRITE_VERBS, ...
|
|
765
|
-
const READ_VERBS = Object.freeze(['registry_list', ...QUESTION_READ_VERBS, ...DELIVERY_READ_VERBS, ...CAPABILITY_READ_VERBS, ...
|
|
758
|
+
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_block', 'item_unblock', 'item_stage', 'item_link', ...QUESTION_WRITE_VERBS, ...DELIVERY_WRITE_VERBS, ...ACTIVITY_WRITE_VERBS, ...STEP_IN_WRITE_VERBS]);
|
|
759
|
+
const READ_VERBS = Object.freeze(['registry_list', ...QUESTION_READ_VERBS, ...DELIVERY_READ_VERBS, ...CAPABILITY_READ_VERBS, ...ROUNDUP_READ_VERBS, ...ACTIVITY_READ_VERBS, ...STEP_IN_READ_VERBS]);
|
|
766
760
|
|
|
767
761
|
async function run(verb, args = {}) {
|
|
768
762
|
if (!verbs[verb]) refuse(`unknown verb: ${verb}`);
|
package/work/mcp.mjs
CHANGED
|
@@ -48,7 +48,7 @@ export const TOOLS = [
|
|
|
48
48
|
{ name: 'repo_add', description: `Register a GitHub repo under a product. The server reads GitHub itself: the id must equal the repo's full name and the owner must have push rights, or the add is refused. The default branch comes from GitHub. The kinds say what the repo is; item_done reads them to choose how a delivery is proved. Give kinds, a list; kind, one value, is the older form. Refuses an unknown product id and an existing repo id; repo ids are immutable. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'registry_id', 'product'], properties: { request_id: REQUEST_ID, registry_id: S('GitHub "owner/repo", exact spelling.'), product: S('Existing product id.'), kinds: KINDS, kind: S(`The older form: one kind. One of ${REPO_KINDS.join(', ')}. Give kinds instead.`), notes: S('Free text.') }, additionalProperties: false } },
|
|
49
49
|
{ name: 'repo_edit', description: `Change a repo's kinds or notes, or refresh its default branch from GitHub. registry_id and product never change. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'registry_id'], properties: { request_id: REQUEST_ID, registry_id: S('Existing repo id.'), kinds: KINDS, kind: S(`The older form: one kind. One of ${REPO_KINDS.join(', ')}. Give kinds instead.`), notes: S('Free text.'), refresh_default_branch: { type: 'boolean', description: 'Re-read the default branch and push rights from GitHub.' } }, additionalProperties: false } },
|
|
50
50
|
{ 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 } },
|
|
51
|
-
{ name: 'item_lock', description: `Record the owner's approval of a short design: product and repos by registry id, the scope (the definition of done as text, and the design file), and the owner's words of approval as origin. That approval is the lock (target design, flow 4). The verb keeps a fingerprint of the text for the tools; nobody reads or writes a hash. 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; a change after approval is a decision_record on the same item. When: the owner approves the design. 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 definition of done, in full.'), design_doc: S('URL of the design file the owner approved.'), acceptance_criteria_hash: S('
|
|
51
|
+
{ name: 'item_lock', description: `Record the owner's approval of a short design: product and repos by registry id, the scope (the definition of done as text, and the design file), and the owner's words of approval as origin. That approval is the lock (target design, flow 4). The verb keeps a fingerprint of the text for the tools; nobody reads or writes a hash. 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; a change after approval is a decision_record on the same item. When: the owner approves the design. 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 definition of done, in full.'), design_doc: S('URL of the design file the owner approved.'), acceptance_criteria_hash: S('Refused: old records only. A new lock takes no hash; put the definition of done in text.') } }, 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 } },
|
|
52
52
|
{ 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 } },
|
|
53
53
|
{ 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 } },
|
|
54
54
|
{ 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 } },
|
|
@@ -62,15 +62,12 @@ export const TOOLS = [
|
|
|
62
62
|
{ name: 'step_in_name', description: `Name what one message of the person was, or link the lesson it led to (roadmap R15). The step-in hook counts each message the person sends, and the server writes each one as a step-in mark; this verb never adds, skips or deletes one. It writes a new mark that corrects the step-in, and the newest name stands. A message with no class is a step-in. The classes: ${STEP_IN_CLASSES.join(', ')}. The first eight are the classes of the owner-only list. "answer" needs question_id, a question that was open when the message came. When: the lead knows what the message was, or a lesson came out of it. No owner flag. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'step_in'], properties: { request_id: REQUEST_ID, step_in: S('The step-in mark id, from step_ins.'), class: { type: 'string', enum: [...STEP_IN_CLASSES], description: 'What the message was. Leave it out to keep it a step-in and only link a lesson.' }, question_id: S('For class answer: the question the message answered.'), item_id: S('The item the message was about. An answer takes the item of its question.'), lesson: S('The lesson the step-in led to: a URL, or the id of a tracker record.') }, additionalProperties: false } },
|
|
63
63
|
{ name: 'step_ins', description: 'List the step-in marks, newest first, with the name that stands on each: the time, the session, the repos, and the class and lesson the lead named. step_ins counts the messages with no class; not_counted_sessions counts the sessions whose hooks were off. When: before step_in_name, to find the id. Scope: read, no owner flag needed.', inputSchema: { type: 'object', properties: { session: S('Only this session.'), since: S('ISO date or time: only step-ins from then on.'), limit: { type: 'integer', minimum: 1, maximum: 100, description: 'How many rows. Default 20.' } }, additionalProperties: false } },
|
|
64
64
|
{ 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 } },
|
|
65
|
-
{ 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 } },
|
|
66
65
|
{ name: 'question_ask', description: `Ask the owner one question that blocks work on an item, and say what waiting costs. When: an agent is blocked and cannot decide by itself. Any in-session agent may call it; no owner flag. The question shows in the "Needs me" view until it is answered. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'text', 'item_id', 'product', 'cost'], properties: { request_id: REQUEST_ID, text: S('The question, in plain words, answerable without opening a source.'), item_id: S('Entity id of the work item the question blocks.'), product: S('Product id of that item.'), cost: S(`What waiting costs. One of: ${COSTS.map((cost) => `${cost} (${COST_MEANING[cost]})`).join('; ')}.`), context: { type: 'object', description: 'What the owner needs to answer without opening a source.', properties: { current_behavior: S('What happens today.'), change: S('What would change.'), affected: { type: 'array', items: { type: 'string' }, description: 'Parts that change.' }, options: { type: 'array', items: { type: 'string' }, description: 'The options, one per entry.' }, consequences: S('What follows from each option.'), sources: { type: 'array', items: { type: 'string' }, description: 'Absolute http or https URLs that back the context.' } } } }, additionalProperties: false } },
|
|
67
66
|
{ 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 } },
|
|
68
67
|
{ 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 } },
|
|
69
|
-
{ 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 } },
|
|
70
68
|
{ 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. One exception, a last resort for an extraordinary case with no other option: the owner can accept a known red test on one merged pull request. Name it in accepted_red with the decision that records the owner's words; those words must name the pull request and quote the exact test, and one decision accepts one red check. accepted_red needs the owner present. The gate names a check, not a test, so the verb proves the check level, and every other red check still 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. When atlas.yaml on the base of a merge lists required_checks, each one needs a successful newest GitHub Actions run on the pull request head or on the merge commit; a skipped check and a check that never ran are named apart. When atlas.yaml says live: release, a published release must hold each merge commit, and the mark stores that version; a red run on the merge commit is then a warning on the mark, because the release was built from a later green main. 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.'), accepted_red: { type: 'array', description: 'Red checks the owner accepted, one entry for each pull request and check. Owner present only. The decision must be a decision or an answer on this item, written by decision_record or question_answer and not replaced. Its words must name the pull request and quote the test. One decision accepts one check on one pull request. A listed check that is not red is refused.', items: { type: 'object', required: ['pr', 'check', 'test', 'decision_id'], additionalProperties: false, properties: { pr: S('One of the prs of this delivery.'), check: S('The name of the red check run in the merge gate, for example "test".'), test: S('The exact name of the red test, as the owner\'s words quote it.'), decision_id: S('The decision that records the owner\'s acceptance.') } } } }, additionalProperties: false } },
|
|
71
69
|
{ 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 } },
|
|
72
70
|
{ 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 } },
|
|
73
|
-
{ 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 } },
|
|
74
71
|
{ 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 } },
|
|
75
72
|
{ name: 'roundup', description: 'Answer "where does this product stand, and where is it going?" in five parts: 1 needs you (the needs_me read); 2 where we stand (items tagged stage-<n> in ladder order, a count of every item by lifecycle, and the active items); 3 what moved in the window (delivery marks, merged pull requests, new items); 4 stuck or drifting (active items with no change for 14 days, merged but not marked, open pull requests that name no item, questions waiting 14 days); 5 the trend per week for four weeks. Every row cites an item, a question or a pull request. Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', required: ['product'], properties: { product: S('Product id.'), days: { type: 'integer', minimum: 1, maximum: 90, description: 'The window for part 3, in days. Default 7.' } }, additionalProperties: false } },
|
|
76
73
|
{ 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 } },
|
package/work/lib/sweep.mjs
DELETED
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
// Atlas M1 slice L5: the one-time sweep.
|
|
2
|
-
// Design: the Atlas M1 study, D5 slice L5, design
|
|
3
|
-
// finding R7, and the frozen L5 paragraph of the lock block.
|
|
4
|
-
//
|
|
5
|
-
// The frozen text asks for two things and nothing else:
|
|
6
|
-
// 1. The owner's one-time sweep, written as a decision record that lists the
|
|
7
|
-
// active items. That record is the oracle (R7). It is written by the
|
|
8
|
-
// decision_record verb of slice L2 with kind "sweep"; this module adds no
|
|
9
|
-
// second way to write a decision.
|
|
10
|
-
// 2. The "In flight" view equal to that list. sweep_check reads the view of
|
|
11
|
-
// slice L3 and compares it with the record. It writes nothing.
|
|
12
|
-
//
|
|
13
|
-
// One write verb stands between the two: item_adopt. The sweep gives an item a
|
|
14
|
-
// product, and until now no verb could. item_propose and item_lock name a
|
|
15
|
-
// product when they make or lock an item; items made before the registry
|
|
16
|
-
// existed carry no product at all. Both L3 and L4 views say so on every read.
|
|
17
|
-
import { refuse, text, oneOf, stringList } from './verb-fields.mjs';
|
|
18
|
-
import { oneLine, scrubSecrets, ROW_LINE_MAX } from './questions.mjs';
|
|
19
|
-
import { listLine } from './delivery.mjs';
|
|
20
|
-
|
|
21
|
-
export const SWEEP_WRITE_VERBS = Object.freeze(['item_adopt']);
|
|
22
|
-
export const SWEEP_READ_VERBS = Object.freeze(['sweep_check']);
|
|
23
|
-
// The kind that marks a decision record as a sweep. `kind` is a free string in
|
|
24
|
-
// the manifest, so this costs no manifest version (the M2 lane holds it).
|
|
25
|
-
export const SWEEP_KIND = 'sweep';
|
|
26
|
-
|
|
27
|
-
// A sweep record can hold at most this many item ids, because decision_record
|
|
28
|
-
// verifies each one. The same number bounds what the check reads back, so the
|
|
29
|
-
// two can never disagree (architect review, finding 5).
|
|
30
|
-
export const MAX_SWEPT = 200;
|
|
31
|
-
const MAX_SWEEPS = 300;
|
|
32
|
-
const TITLES_FETCHED = 25;
|
|
33
|
-
|
|
34
|
-
export function createSweepVerbs(core) {
|
|
35
|
-
const { client, now, typeNamed, registry, requireProduct, requireRepos, requireItem, requireOwner, patch, alreadyPatched, inFlight, stages, tiers, drivers } = core;
|
|
36
|
-
|
|
37
|
-
const verbs = {
|
|
38
|
-
// Give an existing item its product and repos. Owner present, because the
|
|
39
|
-
// sweep is the owner's own list (R7) and a product is a name the owner owns.
|
|
40
|
-
async item_adopt(args, requestId, meta) {
|
|
41
|
-
requireOwner('item adopt');
|
|
42
|
-
const item = await requireItem(args.item_id);
|
|
43
|
-
const fields = item.fields ?? {};
|
|
44
|
-
if (alreadyPatched(item, requestId, meta)) {
|
|
45
|
-
return { verb: 'item_adopt', item_id: item.id, version: item.version, product: fields.product ?? null, repos: fields.repos ?? [], lifecycle: fields.lifecycle ?? null, stage: fields.stage ?? null, replayed_from_graph: true };
|
|
46
|
-
}
|
|
47
|
-
const reg = await registry();
|
|
48
|
-
const product = await requireProduct(args.product, reg);
|
|
49
|
-
const productId = product.fields.registry_id;
|
|
50
|
-
const named = typeof fields.product === 'string' ? fields.product : '';
|
|
51
|
-
// An item is named once. Moving it between registered products is not a
|
|
52
|
-
// sweep, and no verb of M1 does it: the reader of any view would see the
|
|
53
|
-
// work change product with nothing on the record to say why.
|
|
54
|
-
if (named && named !== productId && reg.products.has(named)) refuse(`item ${item.id} already names the registered product ${named}, not ${productId}; the sweep names an item once`);
|
|
55
|
-
// `repos` is required, and an empty list is how a caller says "this item
|
|
56
|
-
// has no repo" — a container root is the real case. Leaving the argument
|
|
57
|
-
// out used to fall back to whatever survived the product filter, which
|
|
58
|
-
// could silently empty the field and leave an item no other verb could
|
|
59
|
-
// have written (architect review, finding 4).
|
|
60
|
-
if (args.repos === undefined) {
|
|
61
|
-
const kept = (Array.isArray(fields.repos) ? fields.repos : []).filter((id) => reg.repos.get(id)?.fields?.product === productId);
|
|
62
|
-
refuse(`repos is required: name the repo ids under ${productId}, or pass an empty list for an item with no repo.${kept.length ? ` This item already names ${kept.join(', ')}.` : ''}`);
|
|
63
|
-
}
|
|
64
|
-
const asked = stringList(args.repos, 'repos', { max: 16 });
|
|
65
|
-
const repos = asked.length ? await requireRepos(asked, reg, productId) : [];
|
|
66
|
-
const dropped = (Array.isArray(fields.repos) ? fields.repos : []).filter((id) => !repos.includes(id));
|
|
67
|
-
const change = { product: productId, repos };
|
|
68
|
-
if (args.tier !== undefined) change.tier = oneOf(args.tier, 'tier', tiers);
|
|
69
|
-
if (args.driver !== undefined) change.driver = oneOf(args.driver, 'driver', drivers);
|
|
70
|
-
if (args.next_step !== undefined) change.next_step = text(args.next_step, 'next_step', { max: 1000 });
|
|
71
|
-
if (args.stage !== undefined) {
|
|
72
|
-
const stage = oneOf(args.stage, 'stage', stages);
|
|
73
|
-
if (fields.lifecycle !== 'active') refuse(`item ${item.id} is ${fields.lifecycle ?? 'unknown'}; only an active item carries a stage`);
|
|
74
|
-
change.stage = stage;
|
|
75
|
-
}
|
|
76
|
-
const same = named === productId && [...repos].sort().join(' ') === [...(Array.isArray(fields.repos) ? fields.repos : [])].sort().join(' ');
|
|
77
|
-
if (same && Object.keys(change).length === 2) return { verb: 'item_adopt', item_id: item.id, version: item.version, product: productId, repos, lifecycle: fields.lifecycle ?? null, stage: fields.stage ?? null, unchanged: true };
|
|
78
|
-
// The audit stamps say when the sweep NAMED this item and what it came
|
|
79
|
-
// from. A later call that only sets a stage or a next step must not move
|
|
80
|
-
// them, or "adopted_from: engram" ends up on an item already in engram
|
|
81
|
-
// and the sweep's own date is lost (architect review, finding 3).
|
|
82
|
-
const stamps = named === productId ? {} : { adopted_at: now(), adopted_from: named || null };
|
|
83
|
-
const entity = await patch(item, 'item_adopt', requestId, { ...change, ...stamps }, {
|
|
84
|
-
digest: meta?.digest,
|
|
85
|
-
tags: [`product:${productId}`, ...repos.map((id) => `repo:${id}`), ...(change.tier ? [`tier:${change.tier}`] : [])],
|
|
86
|
-
});
|
|
87
|
-
return { verb: 'item_adopt', item_id: entity.id, version: entity.version, product: productId, repos, lifecycle: entity.fields?.lifecycle ?? null, stage: entity.fields?.stage ?? null, previous_product: named || null, ...(dropped.length ? { dropped_repos: dropped } : {}) };
|
|
88
|
-
},
|
|
89
|
-
|
|
90
|
-
// R7 made mechanical: the sweep record is the oracle, so the check reads
|
|
91
|
-
// the record and the view and names every difference. Read only.
|
|
92
|
-
async sweep_check(args = {}) {
|
|
93
|
-
const reg = await registry();
|
|
94
|
-
let only = null;
|
|
95
|
-
if (args.product !== undefined) only = (await requireProduct(args.product, reg)).fields.registry_id;
|
|
96
|
-
const type = await typeNamed('decision');
|
|
97
|
-
const decisions = await client.listAllEntities({ type: type.name, state: 'active' });
|
|
98
|
-
// Filter to sweeps BEFORE bounding. Every answered question writes a
|
|
99
|
-
// decision, so a count over all decisions would one day refuse the verb
|
|
100
|
-
// with no argument that helps (architect review, finding 6).
|
|
101
|
-
const sweeps = decisions.filter((row) => row.fields?.kind === SWEEP_KIND && typeof row.fields?.product === 'string' && row.fields.product && (!only || row.fields.product === only));
|
|
102
|
-
if (sweeps.length > MAX_SWEEPS) refuse(`${sweeps.length} sweep records are on record; the check reads at most ${MAX_SWEEPS}. Narrow it with a product id.`);
|
|
103
|
-
// A sweep for a product the registry does not hold cannot be checked: the
|
|
104
|
-
// view shows rows only for registered products, so every listed item
|
|
105
|
-
// would read as missing for ever (architect review, finding 7).
|
|
106
|
-
const orphaned = [...new Set(sweeps.filter((row) => !reg.products.has(row.fields.product)).map((row) => row.fields.product))].sort();
|
|
107
|
-
// One sweep stands per product. A sweep that names another with
|
|
108
|
-
// `replaces` supersedes it outright; otherwise the newest by the owner's
|
|
109
|
-
// date wins, then by write time, then by id so a tie is never decided by
|
|
110
|
-
// list order (architect review, findings 12 and 13).
|
|
111
|
-
const replaced = new Set(sweeps.map((row) => row.fields?.replaces).filter(Boolean));
|
|
112
|
-
const latest = new Map();
|
|
113
|
-
for (const row of sweeps) {
|
|
114
|
-
if (!reg.products.has(row.fields.product)) continue;
|
|
115
|
-
if (replaced.has(row.id)) continue;
|
|
116
|
-
const at = `${String(row.fields.date ?? '')} ${String(row.created_at ?? row.updated_at ?? '')} ${row.id}`;
|
|
117
|
-
const held = latest.get(row.fields.product);
|
|
118
|
-
if (!held || at > held.at) latest.set(row.fields.product, { row, at, superseded: held ? [...held.superseded, held.row.id] : [] });
|
|
119
|
-
else held.superseded.push(row.id);
|
|
120
|
-
}
|
|
121
|
-
for (const [product, held] of latest) held.superseded = [...new Set([...held.superseded, ...sweeps.filter((row) => row.fields.product === product && replaced.has(row.id)).map((row) => row.id)])];
|
|
122
|
-
const view = await inFlight(only ? { product: only } : {});
|
|
123
|
-
const inView = new Map(view.products.map((group) => [group.product, group.rows.map((row) => row.item)]));
|
|
124
|
-
const titles = new Map(view.products.flatMap((group) => group.rows.map((row) => [row.item, row.folded?.title ?? row.lines[0]])));
|
|
125
|
-
|
|
126
|
-
const checks = [];
|
|
127
|
-
const unreadable = [];
|
|
128
|
-
for (const productId of [...latest.keys()].sort()) {
|
|
129
|
-
const { row, superseded } = latest.get(productId);
|
|
130
|
-
// A malformed record must not make a read verb refuse: it is reported
|
|
131
|
-
// as unreadable and the other products still answer (finding 12).
|
|
132
|
-
let listed;
|
|
133
|
-
try { listed = [...new Set(stringList(row.fields.items ?? [], 'items', { max: MAX_SWEPT }))]; }
|
|
134
|
-
catch (error) { unreadable.push({ product: productId, decision: row.id, reason: error?.message ?? 'items is not a list of ids' }); continue; }
|
|
135
|
-
const seen = inView.get(productId) ?? [];
|
|
136
|
-
const missing = listed.filter((id) => !seen.includes(id));
|
|
137
|
-
const extra = seen.filter((id) => !listed.includes(id));
|
|
138
|
-
// A missing id is not in the view, so the view carries no title for it.
|
|
139
|
-
// The owner reads titles, not ids, so the check fetches the few it needs.
|
|
140
|
-
for (const [index, id] of missing.entries()) {
|
|
141
|
-
if (titles.has(id)) continue;
|
|
142
|
-
if (index >= TITLES_FETCHED) { titles.set(id, 'title not read: past the fetch bound of this check'); continue; }
|
|
143
|
-
try { const entity = await client.getEntity(id); titles.set(id, entity?.title ?? ''); }
|
|
144
|
-
catch { titles.set(id, 'the graph did not answer for this id'); }
|
|
145
|
-
}
|
|
146
|
-
checks.push({
|
|
147
|
-
product: productId, decision: row.id, date: row.fields.date ?? null, source: row.fields.source ?? '',
|
|
148
|
-
listed: listed.length, in_view: seen.length,
|
|
149
|
-
missing_from_view: missing.map((id) => ({ item: id, title: oneLine(titles.get(id) ?? '', 120) })),
|
|
150
|
-
extra_in_view: extra.map((id) => ({ item: id, title: oneLine(titles.get(id) ?? '', 120) })),
|
|
151
|
-
matches: !missing.length && !extra.length, items: listed, ...(superseded.length ? { superseded } : {}),
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
const notSwept = [...reg.products.keys()].filter((id) => (!only || id === only) && !latest.has(id)).sort();
|
|
155
|
-
// `matches` answers one narrow question: does each product's view hold
|
|
156
|
-
// exactly what its sweep lists? `complete` answers the wider one the
|
|
157
|
-
// owner actually asked: is every item in the tenant on a registered
|
|
158
|
-
// product? An item nobody adopted is missing from the record AND from
|
|
159
|
-
// the view, so the two sides agree by omission, and a headline reading
|
|
160
|
-
// "equal" over a fifth of the corpus would be a lie (architect review,
|
|
161
|
-
// finding 2). The two are reported apart, and the text says both.
|
|
162
|
-
const matches = checks.length > 0 && !notSwept.length && !unreadable.length && !orphaned.length && checks.every((check) => check.matches);
|
|
163
|
-
const outside = view.unregistered_total ?? 0;
|
|
164
|
-
const complete = matches && outside === 0;
|
|
165
|
-
|
|
166
|
-
const block = [
|
|
167
|
-
`Sweep check — ${only ?? 'every registered product'} · read ${String(view.read_at).slice(0, 19)}Z · ${matches ? 'the view equals the sweep' : 'the view and the sweep differ'}${matches ? (complete ? ', and every item in the tenant is on a registered product' : `, but ${outside} item${outside === 1 ? '' : 's'} sit outside every product, so the sweep is not complete`) : ''}`,
|
|
168
|
-
...checks.map((check) => [
|
|
169
|
-
`${check.product} — sweep ${String(check.date ?? 'no date')} lists ${check.listed}, the view shows ${check.in_view} · ${check.matches ? 'equal' : 'not equal'}`,
|
|
170
|
-
` decision ${check.decision}`,
|
|
171
|
-
...check.missing_from_view.map((row) => listLine(`listed, not in flight: ${row.title}`, row.item)),
|
|
172
|
-
...check.extra_in_view.map((row) => listLine(`in flight, not listed: ${row.title}`, row.item)),
|
|
173
|
-
].join('\n')),
|
|
174
|
-
...(notSwept.length ? [`no sweep on record (${notSwept.length})\n${notSwept.map((id) => ` ${id}`).join('\n')}`] : []),
|
|
175
|
-
...(orphaned.length ? [`a sweep names a product the registry does not hold (${orphaned.length})\n${orphaned.map((id) => ` ${id} — the view can show no row for it, so its items would read as missing for ever`).join('\n')}`] : []),
|
|
176
|
-
...(unreadable.length ? [`unreadable sweep record (${unreadable.length})\n${unreadable.map((row) => ` ${row.product} · ${row.decision} — ${row.reason}`).join('\n')}`] : []),
|
|
177
|
-
...(outside ? [oneLine(`${outside} item${outside === 1 ? '' : 's'} still name no registered product, so the sweep is not complete. Each one is invisible to both sides of this check.`, ROW_LINE_MAX)] : []),
|
|
178
|
-
...((view.repo_errors ?? []).length ? [`GitHub did not answer for ${view.repo_errors.length} repo${view.repo_errors.length === 1 ? '' : 's'}; the movement half of the view came from the graph alone\n${view.repo_errors.map((row) => ` ${row.repo} — ${row.reason}`).join('\n')}`] : []),
|
|
179
|
-
].join('\n\n');
|
|
180
|
-
const rendered = scrubSecrets(block);
|
|
181
|
-
return {
|
|
182
|
-
verb: 'sweep_check', read_at: view.read_at, product: only, matches, complete, checks, not_swept: notSwept,
|
|
183
|
-
...(orphaned.length ? { product_not_registered: orphaned } : {}), ...(unreadable.length ? { unreadable } : {}),
|
|
184
|
-
unregistered_total: outside, repo_errors: view.repo_errors ?? [], redactions: rendered.redactions, text: rendered.text,
|
|
185
|
-
};
|
|
186
|
-
},
|
|
187
|
-
};
|
|
188
|
-
return verbs;
|
|
189
|
-
}
|