@arjunkhera/atlas 0.4.6 → 0.4.8
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/hooks/hooks.json +12 -1
- package/hooks/step-in.mjs +91 -0
- package/package.json +1 -1
- package/skills/lead/SKILL.md +5 -0
- package/skills/sdlc-task/SKILL.md +4 -0
- package/work/lib/delivery.mjs +147 -7
- package/work/lib/step-ins.mjs +217 -0
- package/work/lib/verbs.mjs +10 -3
- package/work/mcp.mjs +35 -3
package/hooks/hooks.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "Loads the Atlas lead at the start of each session in a repo that runs Atlas,
|
|
2
|
+
"description": "Loads the Atlas lead at the start of each session in a repo that runs Atlas, keeps the merge door (no merge without a fresh READY, no push to the default branch, no force push), and counts each message the person sends as a step-in, with no text.",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"SessionStart": [
|
|
5
5
|
{
|
|
@@ -23,6 +23,17 @@
|
|
|
23
23
|
}
|
|
24
24
|
]
|
|
25
25
|
}
|
|
26
|
+
],
|
|
27
|
+
"UserPromptSubmit": [
|
|
28
|
+
{
|
|
29
|
+
"hooks": [
|
|
30
|
+
{
|
|
31
|
+
"type": "command",
|
|
32
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/step-in.mjs\"",
|
|
33
|
+
"timeout": 5
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
26
37
|
]
|
|
27
38
|
}
|
|
28
39
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// The step-in hook: the third hook Atlas ships (roadmap R15, item ebbf9693).
|
|
2
|
+
// Claude Code runs it on each message the person sends. It appends one row
|
|
3
|
+
// to the step-in journal, so code counts each step-in and the lead only
|
|
4
|
+
// names its class later. A lead that skipped a steer would otherwise pass
|
|
5
|
+
// every test that reads the count.
|
|
6
|
+
//
|
|
7
|
+
// Its limits are the reason the package may ship it:
|
|
8
|
+
// 1. A row holds the time, the session and the repos. It never holds the
|
|
9
|
+
// text of the message.
|
|
10
|
+
// 2. It makes no network call, reads no key and starts no program. It reads
|
|
11
|
+
// the hook input, atlas.yaml in the session folder or one level down, and
|
|
12
|
+
// ATLAS_HOOKS and HOME.
|
|
13
|
+
// 3. It prints nothing, and it always exits 0: a message is never held up.
|
|
14
|
+
// 4. It writes only where the session folder, or a folder one level down,
|
|
15
|
+
// holds atlas.yaml.
|
|
16
|
+
// 5. ATLAS_HOOKS=off, set outside the session, turns it off. The Atlas
|
|
17
|
+
// tools then write one "not counted" mark for the session.
|
|
18
|
+
import { randomUUID } from 'node:crypto';
|
|
19
|
+
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, writeSync } from 'node:fs';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
21
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
const MAX_REPOS = 16;
|
|
25
|
+
const REPO = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9_.-]{1,100}$/;
|
|
26
|
+
const SESSION = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,199}$/;
|
|
27
|
+
|
|
28
|
+
// The folder that the hook writes and the Atlas tools read. The tools keep
|
|
29
|
+
// their own copy of the path, because these bytes are pinned.
|
|
30
|
+
export const stepInFolder = (home = homedir()) => join(home, '.atlas', 'step-ins');
|
|
31
|
+
|
|
32
|
+
// The repo that one atlas.yaml names, from its top-level `repo:` line.
|
|
33
|
+
function repoOf(path) {
|
|
34
|
+
let text;
|
|
35
|
+
try { text = readFileSync(path, 'utf8').slice(0, 65536); } catch { return null; }
|
|
36
|
+
for (const line of text.split('\n')) {
|
|
37
|
+
const match = /^repo:\s*["']?([^"'#\s]+)["']?\s*(?:#.*)?$/.exec(line);
|
|
38
|
+
if (match) return REPO.test(match[1]) ? match[1] : null;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The repos of the session folder: its own atlas.yaml, or else each folder
|
|
44
|
+
// one level down that holds one. Null when no folder runs Atlas.
|
|
45
|
+
export function reposOf(cwd) {
|
|
46
|
+
if (typeof cwd !== 'string' || !isAbsolute(cwd)) return null;
|
|
47
|
+
if (existsSync(join(cwd, 'atlas.yaml'))) { const repo = repoOf(join(cwd, 'atlas.yaml')); return repo ? [repo] : []; }
|
|
48
|
+
let names;
|
|
49
|
+
try { names = readdirSync(cwd, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort(); } catch { return null; }
|
|
50
|
+
const found = names.filter((name) => existsSync(join(cwd, name, 'atlas.yaml')));
|
|
51
|
+
if (!found.length) return null;
|
|
52
|
+
return [...new Set(found.map((name) => repoOf(join(cwd, name, 'atlas.yaml'))).filter(Boolean))].slice(0, MAX_REPOS);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// One message in, one row out, or null when no row is due.
|
|
56
|
+
export function rowOf(input, { now = new Date(), id = randomUUID() } = {}) {
|
|
57
|
+
const session = typeof input?.session_id === 'string' && SESSION.test(input.session_id) ? input.session_id : null;
|
|
58
|
+
if (!session) return null;
|
|
59
|
+
const repos = reposOf(input?.cwd);
|
|
60
|
+
if (repos === null) return null;
|
|
61
|
+
return { v: 1, id, at: now.toISOString(), session, repos };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The row goes to a file of its own, written under a temporary name and
|
|
65
|
+
// then renamed, so a reader never sees half a row.
|
|
66
|
+
export function writeRow(row, home = homedir()) {
|
|
67
|
+
const folder = stepInFolder(home);
|
|
68
|
+
mkdirSync(folder, { recursive: true, mode: 0o700 });
|
|
69
|
+
const name = `${row.at.replace(/[:.]/g, '-')}-${row.id}.json`;
|
|
70
|
+
const temp = join(folder, `.${name}.tmp`);
|
|
71
|
+
const handle = openSync(temp, 'wx', 0o600);
|
|
72
|
+
try { writeSync(handle, `${JSON.stringify(row)}\n`); } finally { closeSync(handle); }
|
|
73
|
+
renameSync(temp, join(folder, name));
|
|
74
|
+
return join(folder, name);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function main(text, env = process.env) {
|
|
78
|
+
if (env.ATLAS_HOOKS === 'off') return null;
|
|
79
|
+
let input;
|
|
80
|
+
try { input = JSON.parse(text); } catch { return null; }
|
|
81
|
+
const row = rowOf(input);
|
|
82
|
+
if (!row) return null;
|
|
83
|
+
try { return writeRow(row, env.HOME || homedir()); } catch { return null; }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
87
|
+
const chunks = [];
|
|
88
|
+
process.stdin.on('data', (chunk) => chunks.push(chunk));
|
|
89
|
+
process.stdin.on('end', () => { main(Buffer.concat(chunks).toString('utf8')); process.exit(0); });
|
|
90
|
+
process.stdin.on('error', () => process.exit(0));
|
|
91
|
+
}
|
package/package.json
CHANGED
package/skills/lead/SKILL.md
CHANGED
|
@@ -70,6 +70,11 @@ A crew reads and reports. It never holds the Atlas tools.
|
|
|
70
70
|
Never record one on your own judgment.
|
|
71
71
|
3. A change after the owner approved a design is a new decision on the
|
|
72
72
|
same item, with the owner's words. The design file names the change.
|
|
73
|
+
4. A hook counts each message the owner sends as a step-in. Some
|
|
74
|
+
messages are a brake, an answer, a signed act or an owner-only act.
|
|
75
|
+
Find each one with `step_ins`, and name it with `step_in_name`. When
|
|
76
|
+
a lesson came from a step-in, link it there. Never name a steer as
|
|
77
|
+
something else.
|
|
73
78
|
|
|
74
79
|
## 4. The learnings rule
|
|
75
80
|
|
|
@@ -191,6 +191,10 @@ next action. One short paragraph.
|
|
|
191
191
|
`library` repo proves it by merged pull requests and a green gate. A
|
|
192
192
|
`service` or `schema` repo also needs its live checks, because a schema
|
|
193
193
|
migration reaches the running node after the merge.
|
|
194
|
+
Each check in the repo's `required_checks` must be green on the pull
|
|
195
|
+
request head or on the merge commit. When `atlas.yaml` says
|
|
196
|
+
`live: release`, call `item_done` after the release run: it refuses
|
|
197
|
+
until a published release holds the merge.
|
|
194
198
|
A red check is a last resort, for an extraordinary case with no other
|
|
195
199
|
option (owner, 22 October 2026). Fix the test first. If the owner still
|
|
196
200
|
approved a merge with one known red test, record the words
|
package/work/lib/delivery.mjs
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
// from the marks and from the repos' pull requests (D3 rule 5, R1).
|
|
15
15
|
import { refuse, text, isoDate, url, stringList, sha256, repoKinds, LIVE_KINDS } from './verb-fields.mjs';
|
|
16
16
|
import { oneLine, ROW_LINE_MAX, priorityOrderOf, byPriority, orderNote, waitsOnLine, blockersOf, blocksOf, blockedPhrase, waitsShown } from './questions.mjs';
|
|
17
|
+
import { parseYaml } from '../../shape/check.mjs';
|
|
18
|
+
import { ACTIONS_APP_ID } from '../../door/lib/gate.mjs';
|
|
17
19
|
|
|
18
20
|
export const DELIVERY_WRITE_VERBS = Object.freeze(['item_done', 'item_reopen']);
|
|
19
21
|
export const DELIVERY_READ_VERBS = Object.freeze(['in_flight']);
|
|
@@ -98,6 +100,60 @@ export function latestCheckRuns(runs) {
|
|
|
98
100
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
99
101
|
}
|
|
100
102
|
|
|
103
|
+
// Roadmap R17 (item 14938416). Where a repo's change counts as live, as
|
|
104
|
+
// atlas.yaml says it with `live:`. release: a published release holds the
|
|
105
|
+
// merge. deploy: the three live checks of a service. merge: the merge itself.
|
|
106
|
+
// A repo that does not say takes the value of its kinds.
|
|
107
|
+
export const LIVE_VALUES = Object.freeze(['release', 'deploy', 'merge']);
|
|
108
|
+
const MAX_RELEASE_COMPARES = 10;
|
|
109
|
+
const RELEASES = 100;
|
|
110
|
+
|
|
111
|
+
// What atlas.yaml said at one commit: the required checks and the live value.
|
|
112
|
+
// A repo with no atlas.yaml, or one that lists no required checks, keeps the
|
|
113
|
+
// older rule of the gate (one check passed, none red).
|
|
114
|
+
export function deliveryFacts(source) {
|
|
115
|
+
const facts = parseYaml(source) ?? {};
|
|
116
|
+
if (!facts || typeof facts !== 'object' || Array.isArray(facts)) return { required: [], live: null };
|
|
117
|
+
const list = Array.isArray(facts.required_checks) ? facts.required_checks : [];
|
|
118
|
+
const required = list.filter((name) => typeof name === 'string' && name.trim()).map((name) => name.trim());
|
|
119
|
+
const live = facts.live === undefined || facts.live === null || facts.live === '' ? null : String(facts.live).trim();
|
|
120
|
+
return { required, live, invalid: live !== null && !LIVE_VALUES.includes(live) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The state of each required check of one pull request. A check counts when
|
|
124
|
+
// the newest GitHub Actions run of that name, on the head commit or on the
|
|
125
|
+
// merge commit, ended in success (R17 line 1). Otherwise it says why:
|
|
126
|
+
// pending, red, skipped (a path filter or a condition skipped every run, so
|
|
127
|
+
// it proved nothing) or missing (it never ran on either commit). Line 2.
|
|
128
|
+
export function requiredStates(names, headRuns, mergeRuns) {
|
|
129
|
+
const newest = (runs, name) => (runs ?? [])
|
|
130
|
+
.filter((run) => run?.name === name && run.app?.id === ACTIONS_APP_ID)
|
|
131
|
+
.sort((a, b) => `${b.started_at ?? b.completed_at ?? ''}#${String(b.id ?? 0).padStart(24, '0')}`.localeCompare(`${a.started_at ?? a.completed_at ?? ''}#${String(a.id ?? 0).padStart(24, '0')}`))[0] ?? null;
|
|
132
|
+
const stateOf = (run) => {
|
|
133
|
+
if (!run) return 'missing';
|
|
134
|
+
if (run.status !== 'completed') return 'pending';
|
|
135
|
+
if (run.conclusion === PASSING_CONCLUSION) return 'green';
|
|
136
|
+
if (run.conclusion === 'skipped') return 'skipped';
|
|
137
|
+
return FATAL_CONCLUSIONS.includes(run.conclusion ?? '') ? 'red' : (run.conclusion ?? 'unknown');
|
|
138
|
+
};
|
|
139
|
+
// When the two commits disagree, the reason names the better state: a
|
|
140
|
+
// green on either commit wins, then a run still going, then a red.
|
|
141
|
+
const rank = (state) => { const at = ['green', 'pending', 'red', 'skipped', 'missing'].indexOf(state); return at < 0 ? 2 : at; };
|
|
142
|
+
return names.map((name) => {
|
|
143
|
+
const seen = [['head', newest(headRuns, name)], ['merge', newest(mergeRuns, name)]]
|
|
144
|
+
.map(([on, run]) => ({ on, state: stateOf(run), conclusion: run?.conclusion ?? null, url: run?.html_url ?? '' }));
|
|
145
|
+
const best = [...seen].sort((a, b) => rank(a.state) - rank(b.state))[0];
|
|
146
|
+
return { name, state: best.state, on: best.state === 'green' ? best.on : null, seen };
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const requiredWhy = (row) => ({
|
|
151
|
+
pending: `${row.name} is still running`,
|
|
152
|
+
red: `${row.name} is red (${row.seen.map((one) => `${one.on} ${one.conclusion ?? one.state}`).join(', ')})`,
|
|
153
|
+
skipped: `${row.name} was skipped on the head and on the merge commit: a path filter or a condition skipped it, so it proved nothing`,
|
|
154
|
+
missing: `${row.name} never ran on the head or on the merge commit`,
|
|
155
|
+
}[row.state] ?? `${row.name} ended ${row.state}`);
|
|
156
|
+
|
|
101
157
|
export const daysBetween = (from, to) => {
|
|
102
158
|
const start = Date.parse(from ?? ''), end = Date.parse(to ?? '');
|
|
103
159
|
if (Number.isNaN(start) || Number.isNaN(end)) return null;
|
|
@@ -211,6 +267,52 @@ export function createDeliveryVerbs(core) {
|
|
|
211
267
|
try { return await readLive(target); }
|
|
212
268
|
catch (error) { refuse(`${name} did not answer: ${error?.message ?? 'unknown error'}`); }
|
|
213
269
|
};
|
|
270
|
+
// A read that may find nothing. GitHub answers 404 for a file that is not
|
|
271
|
+
// there; any other failure refuses, as every read of this verb does.
|
|
272
|
+
const githubOrNull = async (path) => {
|
|
273
|
+
if (typeof readGitHub !== 'function') refuse('this verb needs a GitHub reader; the server supplies one');
|
|
274
|
+
try { return await readGitHub(path); }
|
|
275
|
+
catch (error) {
|
|
276
|
+
if (error?.status === 404 || /HTTP 404|Not Found/i.test(String(error?.message ?? ''))) return null;
|
|
277
|
+
refuse(`GitHub could not read ${path}: ${error?.message ?? 'unknown error'}`);
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
// R17: what atlas.yaml says for one merge. The required checks come from
|
|
281
|
+
// the base of the merge, its first parent: the gate as it stood when the
|
|
282
|
+
// pull request merged, which the pull request could not change. The live
|
|
283
|
+
// value comes from the tip of the branch it merged into: it says how the
|
|
284
|
+
// repo ships now, and a change to atlas.yaml goes to the owner.
|
|
285
|
+
async function atlasYamlAt(repo, ref) {
|
|
286
|
+
const file = await githubOrNull(`repos/${repo}/contents/atlas.yaml?ref=${ref}`);
|
|
287
|
+
if (!file || typeof file.content !== 'string') return { required: [], live: null };
|
|
288
|
+
try { return deliveryFacts(Buffer.from(file.content, 'base64').toString('utf8')); }
|
|
289
|
+
catch (error) { refuse(`atlas.yaml of ${repo} at ${ref.slice(0, 12)} cannot be read: ${error?.message ?? error}`); }
|
|
290
|
+
}
|
|
291
|
+
async function factsBefore(pull) {
|
|
292
|
+
const commit = await github(`repos/${pull.repo}/commits/${pull.merge_commit_sha}`);
|
|
293
|
+
const parent = commit?.parents?.[0]?.sha;
|
|
294
|
+
if (typeof parent !== 'string' || !/^[0-9a-f]{40}$/i.test(parent)) refuse(`GitHub names no parent of the merge commit ${pull.merge_commit_sha} of ${pull.url}`);
|
|
295
|
+
const before = await atlasYamlAt(pull.repo, parent);
|
|
296
|
+
const branch = /^[A-Za-z0-9._-]{1,100}$/.test(pull.base) ? pull.base : null;
|
|
297
|
+
const now = branch ? await atlasYamlAt(pull.repo, branch) : { live: null };
|
|
298
|
+
return { required: before.required, live: now.live, invalid: Boolean(now.invalid), at: parent, live_from: branch };
|
|
299
|
+
}
|
|
300
|
+
// R17 line 4: the first published release, after the merge, whose tag
|
|
301
|
+
// holds the merge commit. Drafts and pre-releases do not count.
|
|
302
|
+
async function releaseHolding(pull) {
|
|
303
|
+
const rows = await github(`repos/${pull.repo}/releases?per_page=${RELEASES}`);
|
|
304
|
+
const after = (Array.isArray(rows) ? rows : [])
|
|
305
|
+
.filter((row) => !row?.draft && !row?.prerelease && typeof row?.tag_name === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/.test(row.tag_name)
|
|
306
|
+
&& typeof row.published_at === 'string' && row.published_at >= pull.merged_at)
|
|
307
|
+
.sort((a, b) => a.published_at.localeCompare(b.published_at));
|
|
308
|
+
for (const row of after.slice(0, MAX_RELEASE_COMPARES)) {
|
|
309
|
+
const compare = await github(`repos/${pull.repo}/compare/${pull.merge_commit_sha}...${row.tag_name}`);
|
|
310
|
+
if (compare?.status === 'identical' || compare?.status === 'ahead') {
|
|
311
|
+
return { version: row.tag_name.replace(/^v(?=\d)/, ''), tag: row.tag_name, url: row.html_url ?? '', published_at: row.published_at };
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
214
316
|
// The reopen door (R9). The owner-present flag, or the lead in this session.
|
|
215
317
|
const requireOwnerOrLead = (verb) => { if (!ownerPresent && label.agent !== 'lead') refuse(`${verb} is for the owner or the lead in session; this session is agent "${label.agent}"`); };
|
|
216
318
|
|
|
@@ -407,6 +509,7 @@ export function createDeliveryVerbs(core) {
|
|
|
407
509
|
delivered_at: standing.fields.marked_at, prs: (standing.fields.prs ?? []).map((pull) => pull.url), deployed_commit: standing.fields.deployed?.commit ?? null,
|
|
408
510
|
checks: (standing.fields.checks ?? []).map((check) => ({ name: check.name, result: check.result, detail: check.detail })),
|
|
409
511
|
verified_by: (standing.fields.verified_by ?? []).map((row) => ({ pr: row.pr, passed: row.passed })), gate_warnings: standing.fields.gate_warnings ?? [],
|
|
512
|
+
releases: standing.fields.releases ?? [],
|
|
410
513
|
accepted_red: (standing.fields.accepted_red ?? []).map((row) => ({ pr: row.pr, check: row.check, test: row.test, decision_id: row.decision_id, detail: row.detail })),
|
|
411
514
|
replayed_from_graph: true };
|
|
412
515
|
}
|
|
@@ -464,7 +567,7 @@ export function createDeliveryVerbs(core) {
|
|
|
464
567
|
const pr = await github(`repos/${parsed.repo}/pulls/${parsed.number}`);
|
|
465
568
|
if (pr?.merged !== true) refuse(`pull request ${parsed.url} is not merged on GitHub; a delivery is marked after the merge`);
|
|
466
569
|
const mergeCommit = text(pr.merge_commit_sha, `merge_commit_sha of ${parsed.url}`, { max: 64 });
|
|
467
|
-
pulls.push({ ...parsed, title: oneLine(pr.title ?? '', 200), merged_at: pr.merged_at ?? '', merge_commit_sha: mergeCommit, branch: pr.head?.ref ?? '', base: pr.base?.ref ?? '' });
|
|
570
|
+
pulls.push({ ...parsed, title: oneLine(pr.title ?? '', 200), merged_at: pr.merged_at ?? '', merge_commit_sha: mergeCommit, head_sha: typeof pr.head?.sha === 'string' ? pr.head.sha : '', branch: pr.head?.ref ?? '', base: pr.base?.ref ?? '' });
|
|
468
571
|
}
|
|
469
572
|
const accepted = await acceptedRed(args.accepted_red, pulls, item);
|
|
470
573
|
|
|
@@ -483,7 +586,7 @@ export function createDeliveryVerbs(core) {
|
|
|
483
586
|
}
|
|
484
587
|
|
|
485
588
|
// The gate run, fetched from GitHub for each merge commit (R10).
|
|
486
|
-
const verified = [], warnings = [], acceptedRows = [];
|
|
589
|
+
const verified = [], warnings = [], acceptedRows = [], released = [];
|
|
487
590
|
for (const pull of pulls) {
|
|
488
591
|
const answer = await github(`repos/${pull.repo}/commits/${pull.merge_commit_sha}/check-runs?per_page=${CHECK_RUNS}`);
|
|
489
592
|
const runs = latestCheckRuns(answer?.check_runs);
|
|
@@ -491,7 +594,43 @@ export function createDeliveryVerbs(core) {
|
|
|
491
594
|
const passed = runs.filter((run) => run.conclusion === PASSING_CONCLUSION);
|
|
492
595
|
const mine = accepted.filter((row) => row.pr === pull.url);
|
|
493
596
|
const unaccepted = failed.filter((run) => !mine.some((row) => row.check === run.name));
|
|
494
|
-
|
|
597
|
+
// R17: what the repo's atlas.yaml said just before this merge. The
|
|
598
|
+
// first parent of the merge commit is the base, so a pull request
|
|
599
|
+
// cannot drop its own check or change its own live value.
|
|
600
|
+
const facts = await factsBefore(pull);
|
|
601
|
+
if (facts.invalid) refuse(`atlas.yaml of ${pull.repo} says live: ${facts.live}; the values are ${LIVE_VALUES.join(', ')}`);
|
|
602
|
+
const repoRecord = reg.repos.get(pull.repo);
|
|
603
|
+
if (facts.live === 'deploy' && repoRecord && !repoKinds(repoRecord.fields).some((kind) => LIVE_KINDS.includes(kind))) {
|
|
604
|
+
refuse(`atlas.yaml of ${pull.repo} says live: deploy, but the repo record has the kinds ${repoKinds(repoRecord.fields).join(', ') || 'none'}; the live checks need a service or a schema kind, which the owner sets with repo edit`);
|
|
605
|
+
}
|
|
606
|
+
// Line 1: each required check has a successful newest run on the head
|
|
607
|
+
// or on the merge commit. Line 2: the reason tells a skipped check
|
|
608
|
+
// from one that never ran.
|
|
609
|
+
let required = [];
|
|
610
|
+
if (facts.required.length) {
|
|
611
|
+
const headRuns = pull.head_sha ? (await github(`repos/${pull.repo}/commits/${pull.head_sha}/check-runs?per_page=${CHECK_RUNS}`))?.check_runs : [];
|
|
612
|
+
required = requiredStates(facts.required, headRuns, answer?.check_runs);
|
|
613
|
+
const short = required.filter((row) => row.state !== 'green');
|
|
614
|
+
if (short.length) refuse(`a required check of ${pull.url} is not green: ${short.map(requiredWhy).join('; ')}. atlas.yaml before the merge lists ${facts.required.length} required check(s).`);
|
|
615
|
+
}
|
|
616
|
+
// Lines 4 and 5: for live: release, a published release must hold
|
|
617
|
+
// the merge commit. A later release holds it too, so a release run
|
|
618
|
+
// that skipped this commit needs no rerun.
|
|
619
|
+
let release = null;
|
|
620
|
+
if (facts.live === 'release') {
|
|
621
|
+
release = await releaseHolding(pull);
|
|
622
|
+
if (!release) refuse(`no published release of ${pull.repo} holds ${pull.url} (merge commit ${pull.merge_commit_sha.slice(0, 7)}) yet; atlas.yaml says live: release, so the change is not live until a release holds it. Wait for the release run, then call item_done again.`);
|
|
623
|
+
released.push({ pr: pull.url, ...release });
|
|
624
|
+
}
|
|
625
|
+
// A red check on the merge commit refuses, as before. One exception:
|
|
626
|
+
// a release that holds the merge was built from a later main that
|
|
627
|
+
// passed the release tests, so the red is recorded on the mark as a
|
|
628
|
+
// warning and the release proves the change (the atlas#188 case).
|
|
629
|
+
if (unaccepted.length && !release) refuse(`the merge gate is red for ${pull.url}: ${unaccepted.map((run) => `${run.name} ${run.conclusion} (${run.url})`).join(', ')}${mine.length ? '; accepted_red does not cover it' : ''}`);
|
|
630
|
+
for (const run of release ? unaccepted : []) {
|
|
631
|
+
warnings.push({ pr: pull.url, check: run.name, latest: run.conclusion, latest_url: run.url, release: release.version,
|
|
632
|
+
detail: `${run.name} concluded ${run.conclusion} on the merge commit of ${pull.url}. Release ${release.version} holds the merge and was built from a later main, so the release proves the change.` });
|
|
633
|
+
}
|
|
495
634
|
// An acceptance of a check that is not red is stale: the owner
|
|
496
635
|
// accepted a red that the gate does not show, so the verb refuses
|
|
497
636
|
// rather than write an acceptance nobody needed.
|
|
@@ -502,7 +641,7 @@ export function createDeliveryVerbs(core) {
|
|
|
502
641
|
acceptedRows.push({ ...kept, conclusion: run.conclusion, run_url: run.url,
|
|
503
642
|
detail: `${row.check} concluded ${run.conclusion} for ${pull.url}. The owner accepted the red test "${row.test}" in decision ${row.decision_id}. The gate names the check, not the test, so the verb proved the check level only.` });
|
|
504
643
|
}
|
|
505
|
-
if (!passed.length) refuse(`no check run succeeded for ${pull.url}; the merge gate is the independent record and it says nothing`);
|
|
644
|
+
if (!required.length && !passed.length) refuse(`no check run succeeded for ${pull.url}; the merge gate is the independent record and it says nothing`);
|
|
506
645
|
// A check name whose LATEST run succeeded is green. A name whose earlier
|
|
507
646
|
// run was red and whose later run was skipped or neutral is not red and
|
|
508
647
|
// not green: the verb cannot tell a re-run that fixed it from a gate
|
|
@@ -516,7 +655,7 @@ export function createDeliveryVerbs(core) {
|
|
|
516
655
|
warnings.push({ pr: pull.url, check: run.name, earlier: last.conclusion, earlier_url: last.url, latest: run.conclusion, latest_url: run.url,
|
|
517
656
|
detail: `${run.name} concluded ${last.conclusion} for ${pull.url}, and its later run concluded ${run.conclusion}, not success. The verb cannot tell a fixed gate from a gate that was switched off.` });
|
|
518
657
|
}
|
|
519
|
-
verified.push({ pr: pull.url, commit: pull.merge_commit_sha, runs, passed: passed.map((run) => run.name), ...(mine.length ? { accepted_red: mine.map((row) => row.check) } : {}) });
|
|
658
|
+
verified.push({ pr: pull.url, commit: pull.merge_commit_sha, runs, passed: passed.map((run) => run.name), ...(required.length ? { required: required.map(({ name, state, on }) => ({ name, state, on })) } : {}), ...(release ? { release: release.version } : {}), ...(mine.length ? { accepted_red: mine.map((row) => row.check) } : {}) });
|
|
520
659
|
}
|
|
521
660
|
|
|
522
661
|
// One row per check that ran. For a repo with no deploy target the row
|
|
@@ -525,7 +664,7 @@ export function createDeliveryVerbs(core) {
|
|
|
525
664
|
const checks = live ? [contains, health, functional] : [{
|
|
526
665
|
name: 'every named pull request is merged',
|
|
527
666
|
result: 'pass',
|
|
528
|
-
detail: `${pulls.length} pull request${pulls.length === 1 ? '' : 's'} merged on GitHub: ${pulls.map((pull) => `${pull.url} at ${pull.merge_commit_sha.slice(0, 7)}`).join(', ')}. No live check ran: ${named.map(said).join(', ')}, so the product has no deployed endpoint to read (amendment A1.7)
|
|
667
|
+
detail: `${pulls.length} pull request${pulls.length === 1 ? '' : 's'} merged on GitHub: ${pulls.map((pull) => `${pull.url} at ${pull.merge_commit_sha.slice(0, 7)}`).join(', ')}. No live check ran: ${named.map(said).join(', ')}, so the product has no deployed endpoint to read (amendment A1.7).${released.length ? ` A published release holds each merge: ${released.map((row) => `${row.pr} in ${row.version}`).join(', ')}.` : ''}`,
|
|
529
668
|
checked_at: now(),
|
|
530
669
|
}];
|
|
531
670
|
const mark = await create(type, requestId, 'item_done', {
|
|
@@ -536,7 +675,7 @@ export function createDeliveryVerbs(core) {
|
|
|
536
675
|
kind: 'delivery', verdict: 'delivered', item: item.id, product: product.fields.registry_id, marked_at: now(),
|
|
537
676
|
prs: pulls.map((pull) => ({ url: pull.url, repo: pull.repo, number: pull.number, title: pull.title, branch: pull.branch, merged_at: pull.merged_at, merge_commit_sha: pull.merge_commit_sha })),
|
|
538
677
|
...(live ? { deployed: { health_url: healthUrl, commit: health.deployed_commit, reported: health.reported, status: health.status, checked_at: health.checked_at } } : {}),
|
|
539
|
-
checks, verified_by: verified, ...(warnings.length ? { gate_warnings: warnings } : {}), ...(acceptedRows.length ? { accepted_red: acceptedRows } : {}), ...(note ? { note } : {}),
|
|
678
|
+
checks, verified_by: verified, ...(released.length ? { releases: released } : {}), ...(warnings.length ? { gate_warnings: warnings } : {}), ...(acceptedRows.length ? { accepted_red: acceptedRows } : {}), ...(note ? { note } : {}),
|
|
540
679
|
},
|
|
541
680
|
tags: ['atlas-mark', `product:${product.fields.registry_id}`, ...pulls.map((pull) => `repo:${pull.repo}`)],
|
|
542
681
|
});
|
|
@@ -554,6 +693,7 @@ export function createDeliveryVerbs(core) {
|
|
|
554
693
|
checks: checks.map((check) => ({ name: check.name, result: check.result, detail: check.detail })),
|
|
555
694
|
verified_by: verified.map((row) => ({ pr: row.pr, passed: row.passed })), gate_warnings: warnings,
|
|
556
695
|
accepted_red: acceptedRows.map((row) => ({ pr: row.pr, check: row.check, test: row.test, decision_id: row.decision_id, detail: row.detail })),
|
|
696
|
+
releases: released,
|
|
557
697
|
...(replay ? { replayed_from_graph: true } : {}),
|
|
558
698
|
};
|
|
559
699
|
},
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Roadmap R15 (item ebbf9693): the step-in record. The step-in hook,
|
|
2
|
+
// packages/atlas/hooks/step-in.mjs, appends one row for each message the
|
|
3
|
+
// person sends: the time, the session and the repos, never the text. This
|
|
4
|
+
// module turns the rows into step-in marks, and lets the lead name the
|
|
5
|
+
// class of one. Code counts; the lead only names.
|
|
6
|
+
//
|
|
7
|
+
// Three rules hold it together:
|
|
8
|
+
// 1. The server writes the rows itself, at its start and before each write
|
|
9
|
+
// verb. No verb takes a row, and no verb skips or deletes one.
|
|
10
|
+
// 2. A mark is never edited. The lead's class is a new mark that corrects
|
|
11
|
+
// the step-in, and the newest one stands.
|
|
12
|
+
// 3. A message with no class is a step-in. A class of the owner-only list,
|
|
13
|
+
// a brake, a signed act or an answer says the message was something
|
|
14
|
+
// else. An answer names a question that was open when the message came.
|
|
15
|
+
import { readdir, readFile, rename, rm, stat } from 'node:fs/promises';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { refuse, text, url } from './verb-fields.mjs';
|
|
18
|
+
import { oneLine } from './questions.mjs';
|
|
19
|
+
import { OWNER_ONLY_CLASSES } from '../../shape/check.mjs';
|
|
20
|
+
|
|
21
|
+
export const STEP_IN_WRITE_VERBS = Object.freeze(['step_in_name']);
|
|
22
|
+
export const STEP_IN_READ_VERBS = Object.freeze(['step_ins']);
|
|
23
|
+
// The class names. The first eight are the classes of the owner-only list
|
|
24
|
+
// (R2). A brake stops work, a signed act approves an owner-only change, and
|
|
25
|
+
// an answer replies to an open question.
|
|
26
|
+
export const STEP_IN_CLASSES = Object.freeze([...OWNER_ONLY_CLASSES, 'brake', 'signed-act', 'answer']);
|
|
27
|
+
export const STEP_IN_KIND = 'step-in';
|
|
28
|
+
export const NAME_KIND = 'step-in-name';
|
|
29
|
+
const ROW_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
30
|
+
const SESSION = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,199}$/;
|
|
31
|
+
const REPO = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9_.-]{1,100}$/;
|
|
32
|
+
const MAX_ROWS = 200;
|
|
33
|
+
const MAX_LISTED = 100;
|
|
34
|
+
// A claim older than this belongs to a server that stopped mid-write. The
|
|
35
|
+
// mark's request id makes a second write of the same row a no-op.
|
|
36
|
+
const STALE_CLAIM_MS = 10 * 60 * 1000;
|
|
37
|
+
|
|
38
|
+
// The folder the hook writes. The hook keeps its own copy of the path,
|
|
39
|
+
// because its bytes are pinned.
|
|
40
|
+
export const stepInFolder = (home) => join(home, '.atlas', 'step-ins');
|
|
41
|
+
|
|
42
|
+
// A row read from the folder, checked field by field. Anything else is not a
|
|
43
|
+
// row: the server leaves the file, and names it in the log.
|
|
44
|
+
export function parseRow(raw) {
|
|
45
|
+
let row;
|
|
46
|
+
try { row = JSON.parse(raw); } catch { return null; }
|
|
47
|
+
if (!row || typeof row !== 'object' || row.v !== 1) return null;
|
|
48
|
+
if (!ROW_ID.test(String(row.id ?? '')) || !SESSION.test(String(row.session ?? ''))) return null;
|
|
49
|
+
if (typeof row.at !== 'string' || Number.isNaN(Date.parse(row.at))) return null;
|
|
50
|
+
if (!Array.isArray(row.repos) || row.repos.length > 16 || !row.repos.every((repo) => typeof repo === 'string' && REPO.test(repo))) return null;
|
|
51
|
+
return { id: row.id, at: new Date(row.at).toISOString(), session: row.session, repos: [...row.repos] };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Turn each row in the folder into a mark. Each row is claimed by a rename
|
|
55
|
+
// first, so two servers on one machine never both write it. A row whose
|
|
56
|
+
// write fails goes back, for the next flush.
|
|
57
|
+
export async function flushStepIns({ folder, record, log = () => {}, now = () => Date.now() }) {
|
|
58
|
+
let names;
|
|
59
|
+
try { names = await readdir(folder); } catch (error) { if (error.code === 'ENOENT') return { written: 0, left: 0 }; throw error; }
|
|
60
|
+
for (const name of names.filter((one) => one.endsWith('.claim'))) {
|
|
61
|
+
try { if (now() - (await stat(join(folder, name))).mtimeMs > STALE_CLAIM_MS) await rename(join(folder, name), join(folder, name.replace(/\.[0-9]+\.claim$/, ''))); } catch { /* another server took it */ }
|
|
62
|
+
}
|
|
63
|
+
const rows = (await readdir(folder)).filter((one) => one.endsWith('.json') && !one.startsWith('.')).sort().slice(0, MAX_ROWS);
|
|
64
|
+
let written = 0, left = 0;
|
|
65
|
+
for (const name of rows) {
|
|
66
|
+
const path = join(folder, name), claimed = `${path}.${process.pid}.claim`;
|
|
67
|
+
try { await rename(path, claimed); } catch { continue; }
|
|
68
|
+
const row = parseRow(await readFile(claimed, 'utf8').catch(() => ''));
|
|
69
|
+
if (!row) { await rename(claimed, `${path}.bad`).catch(() => {}); log(`step-in row ${name} is not a row; it was kept as ${name}.bad`); left += 1; continue; }
|
|
70
|
+
try { await record(row); await rm(claimed, { force: true }); written += 1; }
|
|
71
|
+
catch (error) { await rename(claimed, path).catch(() => {}); log(`step-in row ${name} was not written: ${error.message}`); left += 1; }
|
|
72
|
+
}
|
|
73
|
+
return { written, left };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createStepInVerbs(core) {
|
|
77
|
+
const { client, now, typeNamed, kindOf, registry, requireItem, create, edge, landed } = core;
|
|
78
|
+
|
|
79
|
+
// The product of a row, when its repos name one product of the registry.
|
|
80
|
+
async function productOf(repos) {
|
|
81
|
+
const reg = await registry();
|
|
82
|
+
const products = [...new Set(repos.map((id) => reg.repos.get(id)?.fields?.product).filter(Boolean))];
|
|
83
|
+
return products.length === 1 ? products[0] : '';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function markOf(id) {
|
|
87
|
+
const type = await typeNamed('mark');
|
|
88
|
+
let entity;
|
|
89
|
+
try { entity = await client.getEntity(id); }
|
|
90
|
+
catch (error) { if (error?.status === 404) refuse(`unknown mark id: ${id}`); throw error; }
|
|
91
|
+
if (entity.type_id !== type.id) refuse(`entity ${id} is not a mark`);
|
|
92
|
+
return entity;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// One row in, one step-in mark out. The request id comes from the row, so
|
|
96
|
+
// a row written twice lands once.
|
|
97
|
+
async function record(row) {
|
|
98
|
+
const type = await typeNamed('mark');
|
|
99
|
+
const requestId = `step-in-${row.id}`;
|
|
100
|
+
const prior = await landed(type, requestId, { verb: 'step_in_record' });
|
|
101
|
+
if (prior) return prior;
|
|
102
|
+
const product = await productOf(row.repos);
|
|
103
|
+
return create(type, requestId, 'step_in_record', {
|
|
104
|
+
title: oneLine(`Step-in · ${row.repos.join(', ') || 'no repo'} · ${row.at}`, 200),
|
|
105
|
+
body: 'One message from the person. The hook keeps the time, the session and the repos; it never keeps the text.',
|
|
106
|
+
fields: { kind: STEP_IN_KIND, verdict: 'counted', item: '', product, marked_at: row.at, session: row.session, repos: row.repos, row: row.id, source: 'hook' },
|
|
107
|
+
tags: ['atlas-mark', 'step-in', ...(product ? [`product:${product}`] : [])],
|
|
108
|
+
readBack: false,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// R15 line 7: with the hooks off, nothing counts this session's messages.
|
|
113
|
+
// The server says so once, with one mark for the session.
|
|
114
|
+
async function notCounted(session) {
|
|
115
|
+
const type = await typeNamed('mark');
|
|
116
|
+
const requestId = `step-in-off-${session.replace(/[^A-Za-z0-9._:-]/g, '-')}`;
|
|
117
|
+
const prior = await landed(type, requestId, { verb: 'step_in_record' });
|
|
118
|
+
if (prior) return prior;
|
|
119
|
+
return create(type, requestId, 'step_in_record', {
|
|
120
|
+
title: oneLine(`Step-ins not counted · ${session}`, 200),
|
|
121
|
+
body: 'ATLAS_HOOKS=off stopped the step-in hook, so this session\'s messages were not counted.',
|
|
122
|
+
fields: { kind: STEP_IN_KIND, verdict: 'not counted', item: '', product: '', marked_at: now(), session, repos: [], source: 'ATLAS_HOOKS=off' },
|
|
123
|
+
tags: ['atlas-mark', 'step-in', 'not-counted'],
|
|
124
|
+
readBack: false,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Every step-in mark with the newest name that corrects it.
|
|
129
|
+
async function standing() {
|
|
130
|
+
const type = await typeNamed('mark');
|
|
131
|
+
const rows = await client.listAllEntities({ type: type.name, state: 'active' });
|
|
132
|
+
const names = new Map();
|
|
133
|
+
for (const row of rows.filter((one) => one.fields?.kind === NAME_KIND)) {
|
|
134
|
+
const of = row.fields.corrects;
|
|
135
|
+
const held = names.get(of);
|
|
136
|
+
if (!held || String(row.fields.marked_at ?? '') > String(held.fields.marked_at ?? '')) names.set(of, row);
|
|
137
|
+
}
|
|
138
|
+
return rows.filter((one) => one.fields?.kind === STEP_IN_KIND).map((mark) => ({ mark, name: names.get(mark.id) ?? null }));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const verbs = {
|
|
142
|
+
// R15 lines 3, 4 and 5: the lead names what one message was, and links
|
|
143
|
+
// the lesson it led to. It writes a new mark; the step-in stays.
|
|
144
|
+
async step_in_name(args, requestId, meta) {
|
|
145
|
+
const stepIn = await markOf(text(args.step_in, 'step_in', { max: 200 }));
|
|
146
|
+
if (stepIn.fields?.kind !== STEP_IN_KIND || stepIn.fields?.verdict !== 'counted') refuse(`mark ${stepIn.id} is not a counted step-in mark`);
|
|
147
|
+
const cls = args.class === undefined ? null : text(args.class, 'class', { max: 40 });
|
|
148
|
+
if (cls !== null && !STEP_IN_CLASSES.includes(cls)) refuse(`class must be one of ${STEP_IN_CLASSES.join(', ')}; a message with no class is a step-in`);
|
|
149
|
+
const lesson = args.lesson === undefined ? null : lessonOf(args.lesson);
|
|
150
|
+
if (cls === null && lesson === null) refuse('name a class, a lesson, or both');
|
|
151
|
+
let item = args.item_id === undefined ? null : await requireItem(args.item_id);
|
|
152
|
+
let question = null;
|
|
153
|
+
if (cls === 'answer') {
|
|
154
|
+
if (args.question_id === undefined) refuse('the class "answer" needs question_id: the question that was open when the message came');
|
|
155
|
+
question = await questionOf(text(args.question_id, 'question_id', { max: 200 }));
|
|
156
|
+
const came = stepIn.fields.marked_at;
|
|
157
|
+
const asked = question.fields?.asked_at ?? question.created_at ?? '';
|
|
158
|
+
const closed = question.fields?.status === 'open' ? null : (question.fields?.answered_at ?? null);
|
|
159
|
+
if (!asked || asked > came) refuse(`question ${question.id} was asked at ${asked || 'an unknown time'}, after the message came at ${came}; an answer replies to a question that was already open`);
|
|
160
|
+
if (question.fields?.status !== 'open' && (!closed || closed < came)) refuse(`question ${question.id} was ${question.fields?.status ?? 'not open'} before the message came at ${came}; an answer replies to an open question`);
|
|
161
|
+
if (!item && typeof question.fields?.item === 'string' && question.fields.item) item = await requireItem(question.fields.item);
|
|
162
|
+
} else if (args.question_id !== undefined) refuse('question_id goes with the class "answer" only');
|
|
163
|
+
const type = await typeNamed('mark');
|
|
164
|
+
const prior = await landed(type, requestId, meta);
|
|
165
|
+
const mark = prior ?? await create(type, requestId, 'step_in_name', {
|
|
166
|
+
digest: meta?.digest,
|
|
167
|
+
title: oneLine(`Step-in named · ${cls ?? 'lesson only'} · ${stepIn.fields.marked_at}`, 200),
|
|
168
|
+
body: `${cls ? `The lead named step-in ${stepIn.id} "${cls}".` : `The lead linked a lesson to step-in ${stepIn.id}.`}${lesson ? ` Lesson: ${lesson}.` : ''}`,
|
|
169
|
+
fields: {
|
|
170
|
+
kind: NAME_KIND, verdict: cls ?? 'step-in', item: item?.id ?? '', product: stepIn.fields.product ?? '', marked_at: now(), corrects: stepIn.id,
|
|
171
|
+
...(cls ? { class: cls } : {}), ...(question ? { question: question.id } : {}), ...(lesson ? { lesson } : {}),
|
|
172
|
+
},
|
|
173
|
+
tags: ['atlas-mark', 'step-in', ...(stepIn.fields.product ? [`product:${stepIn.fields.product}`] : [])],
|
|
174
|
+
});
|
|
175
|
+
await edge(mark, stepIn, 'corrects', requestId);
|
|
176
|
+
if (item) await edge(mark, item, 'references', requestId);
|
|
177
|
+
return { verb: 'step_in_name', mark_id: mark.id, step_in: stepIn.id, class: cls, item: item?.id ?? null, question: question?.id ?? null, lesson, ...(prior ? { replayed_from_graph: true } : {}) };
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
// The step-ins of a session or since a time, newest first, with the
|
|
181
|
+
// name that stands on each. The lead reads it to find the id to name.
|
|
182
|
+
async step_ins(args = {}) {
|
|
183
|
+
const session = args.session === undefined ? null : text(args.session, 'session', { max: 200 });
|
|
184
|
+
const since = args.since === undefined ? null : text(args.since, 'since', { max: 40 });
|
|
185
|
+
if (since !== null && Number.isNaN(Date.parse(since))) refuse('since must be an ISO date or time');
|
|
186
|
+
const limit = args.limit === undefined ? 20 : Number(args.limit);
|
|
187
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LISTED) refuse(`limit must be a whole number from 1 to ${MAX_LISTED}`);
|
|
188
|
+
const rows = (await standing())
|
|
189
|
+
.filter(({ mark }) => (!session || mark.fields.session === session) && (!since || String(mark.fields.marked_at) >= new Date(since).toISOString()))
|
|
190
|
+
.sort((a, b) => String(b.mark.fields.marked_at).localeCompare(String(a.mark.fields.marked_at)));
|
|
191
|
+
const shown = rows.slice(0, limit).map(({ mark, name }) => ({
|
|
192
|
+
step_in: mark.id, at: mark.fields.marked_at, session: mark.fields.session, repos: mark.fields.repos ?? [], verdict: mark.fields.verdict,
|
|
193
|
+
class: name?.fields?.class ?? null, lesson: name?.fields?.lesson ?? null, item: name?.fields?.item || null, named_by: name?.id ?? null,
|
|
194
|
+
}));
|
|
195
|
+
const counted = rows.filter(({ mark, name }) => mark.fields.verdict === 'counted' && !name?.fields?.class).length;
|
|
196
|
+
return { verb: 'step_ins', total: rows.length, step_ins: counted, not_counted_sessions: rows.filter(({ mark }) => mark.fields.verdict === 'not counted').length, rows: shown };
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
async function questionOf(id) {
|
|
201
|
+
let entity;
|
|
202
|
+
try { entity = await client.getEntity(id); }
|
|
203
|
+
catch (error) { if (error?.status === 404) refuse(`unknown question id: ${id}`); throw error; }
|
|
204
|
+
if ((await kindOf(entity)) !== 'question') refuse(`entity ${id} is not a question`);
|
|
205
|
+
return entity;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return { verbs, record, notCounted };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// A lesson is a link: a pull request, a learnings entry, a page. Or the id
|
|
212
|
+
// of a tracker record, such as the item the lesson became.
|
|
213
|
+
function lessonOf(value) {
|
|
214
|
+
const plain = text(value, 'lesson', { max: 500 }).trim();
|
|
215
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(plain)) return plain.toLowerCase();
|
|
216
|
+
return url(plain, 'lesson');
|
|
217
|
+
}
|
package/work/lib/verbs.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { createCapabilityVerbs, CAPABILITY_READ_VERBS, hubOf } from './capabilit
|
|
|
22
22
|
import { createSweepVerbs, SWEEP_WRITE_VERBS, SWEEP_READ_VERBS } from './sweep.mjs';
|
|
23
23
|
import { createRoundupVerbs, ROUNDUP_READ_VERBS } from './roundup.mjs';
|
|
24
24
|
import { createActivityVerbs, diffOf, LOG_VERBS, ENTRY_VERBS, ACTIVITY_WRITE_VERBS, ACTIVITY_READ_VERBS } from './activity.mjs';
|
|
25
|
+
import { createStepInVerbs, STEP_IN_WRITE_VERBS, STEP_IN_READ_VERBS } from './step-ins.mjs';
|
|
25
26
|
|
|
26
27
|
export { VerbError, canonicalJson, sha256, COSTS, LIVE_LIFECYCLES, REPO_KINDS, LIVE_KINDS, repoKinds };
|
|
27
28
|
export const MANIFEST_VERSION = '0.7.0';
|
|
@@ -755,9 +756,13 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
755
756
|
Object.assign(verbs, createRoundupVerbs({ ...core, readGitHub, inFlight: (args) => verbs.in_flight(args), needsMe: (args) => verbs.needs_me(args) }));
|
|
756
757
|
const activity = createActivityVerbs(core);
|
|
757
758
|
Object.assign(verbs, activity.verbs);
|
|
759
|
+
// Roadmap R15: the step-in record. The server writes the hook's rows
|
|
760
|
+
// through record and notCounted, which no tool exposes.
|
|
761
|
+
const stepIns = createStepInVerbs(core);
|
|
762
|
+
Object.assign(verbs, stepIns.verbs);
|
|
758
763
|
|
|
759
|
-
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, ...SWEEP_WRITE_VERBS, ...ACTIVITY_WRITE_VERBS]);
|
|
760
|
-
const READ_VERBS = Object.freeze(['registry_list', ...QUESTION_READ_VERBS, ...DELIVERY_READ_VERBS, ...CAPABILITY_READ_VERBS, ...SWEEP_READ_VERBS, ...ROUNDUP_READ_VERBS, ...ACTIVITY_READ_VERBS]);
|
|
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, ...SWEEP_WRITE_VERBS, ...ACTIVITY_WRITE_VERBS, ...STEP_IN_WRITE_VERBS]);
|
|
765
|
+
const READ_VERBS = Object.freeze(['registry_list', ...QUESTION_READ_VERBS, ...DELIVERY_READ_VERBS, ...CAPABILITY_READ_VERBS, ...SWEEP_READ_VERBS, ...ROUNDUP_READ_VERBS, ...ACTIVITY_READ_VERBS, ...STEP_IN_READ_VERBS]);
|
|
761
766
|
|
|
762
767
|
async function run(verb, args = {}) {
|
|
763
768
|
if (!verbs[verb]) refuse(`unknown verb: ${verb}`);
|
|
@@ -795,5 +800,7 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
|
|
|
795
800
|
});
|
|
796
801
|
}
|
|
797
802
|
|
|
798
|
-
|
|
803
|
+
// Roadmap R15: the server writes the step-in rows itself, never a caller.
|
|
804
|
+
const stepInRecord = Object.freeze({ record: (row) => stepIns.record(row), notCounted: (session) => stepIns.notCounted(session) });
|
|
805
|
+
return Object.freeze({ run, verbs: Object.freeze([...WRITE_VERBS, ...READ_VERBS]), readVerbs: READ_VERBS, writer: label, stepIns: stepInRecord });
|
|
799
806
|
}
|
package/work/mcp.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { createEngramClient } from './lib/client.mjs';
|
|
|
22
22
|
import { createActionStore } from './lib/action-store.mjs';
|
|
23
23
|
import { createVerbs, STAGES, TIERS, DRIVERS, LINK_KINDS, REPO_KINDS, WAITS_ON, MANIFEST_VERSION, COSTS } from './lib/verbs.mjs';
|
|
24
24
|
import { COST_MEANING, DECISION_KINDS } from './lib/questions.mjs';
|
|
25
|
+
import { STEP_IN_CLASSES, stepInFolder, flushStepIns } from './lib/step-ins.mjs';
|
|
25
26
|
import { FATAL_CONCLUSIONS } from './lib/delivery.mjs';
|
|
26
27
|
import { createMcpServer } from './lib/mcp-stdio.mjs';
|
|
27
28
|
import { childEnvironment, CLOUD_NODE_VARIABLES } from './lib/child-env.mjs';
|
|
@@ -58,13 +59,15 @@ export const TOOLS = [
|
|
|
58
59
|
{ name: 'item_unblock', description: `Remove one blocked-by link from an item. The removed link stays in the item's log. When: the blocker no longer holds the item back. No owner flag needed. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'blocked_by'], properties: { request_id: REQUEST_ID, item_id: S('The blocked item id.'), blocked_by: S('The blocker item id to remove.'), reason: S('One line on why.') }, additionalProperties: false } },
|
|
59
60
|
{ name: 'item_comment', description: `Add a comment to an item's log: a running note from the lead, or the owner's own words. A comment is never edited; to correct one, add a new comment that names it in corrects. The text goes through the secret filter. When: something about the item is worth keeping that no other verb records. No owner flag for the agent's voice; the owner's voice is his words, word for word, so it needs the owner present. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'text'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), text: S('The comment, at most 4000 characters.'), voice: S('agent (the default) or owner. owner means the text is the owner\'s own words. Owner present only.'), corrects: S('The entry id of an earlier comment on this item that this one corrects.') }, additionalProperties: false } },
|
|
60
61
|
{ name: 'item_activity', description: 'Answer "what happened on this item?". One list, newest first: the comments, each change an item verb made with its old and new values, the questions, the decisions, the delivery marks, the pieces and the links. A write that kept no values, such as one from before manifest 0.7.0, shows its verb and time with "no values recorded". The edits kept on the item before 0.7.0 show too. Scope: read, no owner flag needed.', inputSchema: { type: 'object', required: ['item_id'], properties: { item_id: S('Item id.'), limit: { type: 'integer', minimum: 1, maximum: 200, description: 'How many rows, newest first. Default 50.' } }, additionalProperties: false } },
|
|
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
|
+
{ 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 } },
|
|
61
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 } },
|
|
62
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 } },
|
|
63
66
|
{ 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 } },
|
|
64
67
|
{ 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 } },
|
|
65
68
|
{ 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 } },
|
|
66
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 } },
|
|
67
|
-
{ 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. 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 } },
|
|
70
|
+
{ 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 } },
|
|
68
71
|
{ 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 } },
|
|
69
72
|
{ 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 } },
|
|
70
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 } },
|
|
@@ -192,12 +195,20 @@ function gh(args) {
|
|
|
192
195
|
// "In flight" view needs each repo's recent pull requests (lock block L3).
|
|
193
196
|
const REPO = '[A-Za-z0-9_.-]+\\/[A-Za-z0-9_.-]+';
|
|
194
197
|
const SHA = '[0-9a-fA-F]{7,40}';
|
|
198
|
+
const TAG = '[A-Za-z0-9][A-Za-z0-9_.-]{0,99}';
|
|
199
|
+
const BRANCH = '[A-Za-z0-9._-]{1,100}';
|
|
195
200
|
export const GITHUB_READS = Object.freeze([
|
|
196
201
|
new RegExp(`^repos/${REPO}$`),
|
|
197
202
|
new RegExp(`^repos/${REPO}/pulls/\\d{1,9}$`),
|
|
198
203
|
new RegExp(`^repos/${REPO}/pulls\\?state=all&sort=updated&direction=desc&per_page=\\d{1,3}$`),
|
|
199
204
|
new RegExp(`^repos/${REPO}/commits/${SHA}/check-runs\\?per_page=\\d{1,3}$`),
|
|
200
205
|
new RegExp(`^repos/${REPO}/compare/${SHA}\\.\\.\\.${SHA}$`),
|
|
206
|
+
// Roadmap R17: the base of a merge, atlas.yaml on that base and on the
|
|
207
|
+
// branch tip, the releases, and the compare of a merge with a release tag.
|
|
208
|
+
new RegExp(`^repos/${REPO}/commits/${SHA}$`),
|
|
209
|
+
new RegExp(`^repos/${REPO}/contents/atlas\\.yaml\\?ref=(?:${SHA}|${BRANCH})$`),
|
|
210
|
+
new RegExp(`^repos/${REPO}/releases\\?per_page=\\d{1,3}$`),
|
|
211
|
+
new RegExp(`^repos/${REPO}/compare/${SHA}\\.\\.\\.${TAG}$`),
|
|
201
212
|
]);
|
|
202
213
|
export function githubReader() {
|
|
203
214
|
return async (path) => {
|
|
@@ -294,15 +305,36 @@ export function buildServer({ env = process.env, input, output, readGitHub, read
|
|
|
294
305
|
const instructions = connection.state === 'ok'
|
|
295
306
|
? `Atlas work verbs for the owner's session. Session ${session}; owner present: ${ownerPresent ? 'yes' : 'no: the owner-only tools refuse'}. Call registry_list first to learn the product and repo ids, needs_me to see the open questions, and in_flight to see the active work. Every write needs a request_id. The server stamps writer.session and the bound client; never pass writer, session, actor or role. Unknown product or repo ids are refused. Sub-agents must not receive this server.`
|
|
296
307
|
: `Atlas work verbs. They cannot reach Engram yet: ${connection.message}`;
|
|
297
|
-
|
|
308
|
+
// Roadmap R15: the step-in rows of the hook become marks at the server's
|
|
309
|
+
// start and before each write verb. A failed flush leaves the rows for the
|
|
310
|
+
// next one and never stops the verb. With ATLAS_HOOKS=off nothing counts
|
|
311
|
+
// this session's messages, so the server writes one "not counted" mark.
|
|
312
|
+
const stepInHome = typeof env.HOME === 'string' && isAbsolute(env.HOME) ? env.HOME : null;
|
|
313
|
+
const claudeSession = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,199}$/.test(env.CLAUDE_CODE_SESSION_ID ?? '') ? env.CLAUDE_CODE_SESSION_ID : session;
|
|
314
|
+
const say = (line) => { try { log?.(line); } catch { /* the log is advice */ } };
|
|
315
|
+
let flushing = null;
|
|
316
|
+
const flush = () => {
|
|
317
|
+
if (!verbs || !stepInHome) return Promise.resolve(null);
|
|
318
|
+
const { record } = verbs.stepIns;
|
|
319
|
+
flushing ??= flushStepIns({ folder: stepInFolder(stepInHome), record, log: say })
|
|
320
|
+
.catch((error) => { say(`step-ins: ${error.message}`); return null; })
|
|
321
|
+
.finally(() => { flushing = null; });
|
|
322
|
+
return flushing;
|
|
323
|
+
};
|
|
324
|
+
const started = verbs ? Promise.all([
|
|
325
|
+
flush(),
|
|
326
|
+
env.ATLAS_HOOKS === 'off' ? verbs.stepIns.notCounted(claudeSession).catch((error) => say(`step-ins: the "not counted" mark was not written: ${error.message}`)) : null,
|
|
327
|
+
]) : Promise.resolve();
|
|
328
|
+
const server = createMcpServer({ name: 'atlas-work', version: MANIFEST_VERSION, instructions, tools: TOOLS, input, output, log, call: async (name, args, boundTo) => {
|
|
298
329
|
// The upgrade needs no Engram connection: an old key must not block a new release.
|
|
299
330
|
if (name === 'atlas_upgrade') return runUpgrade({ args, env, ownerPresent });
|
|
300
331
|
if (!verbs) throw new Error(connection.message);
|
|
301
332
|
// The writer names the one client this server is bound to (D6 R3): stamped by the server, never by the caller.
|
|
302
333
|
if (boundTo && boundTo.session !== bound) { bound = boundTo.session; verbs = verbsFor(bound); }
|
|
334
|
+
if (!verbs.readVerbs.includes(name)) await flush();
|
|
303
335
|
return verbs.run(name, args);
|
|
304
336
|
} });
|
|
305
|
-
return { server, session, connection, health, verbs: () => verbs };
|
|
337
|
+
return { server, session, connection, health, verbs: () => verbs, stepIns: { flush, started } };
|
|
306
338
|
}
|
|
307
339
|
|
|
308
340
|
// Real paths on both sides: macOS reaches /var through /private/var, and a
|