@arjunkhera/atlas 0.4.7 → 0.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
package/door/cli.mjs CHANGED
@@ -150,6 +150,11 @@ const HELP = `atlas — the door into a repo's Atlas files
150
150
  atlas gate ready --repo <owner/name> --pr <n> may this pull request merge: every required check green on its head
151
151
  atlas gate audit --repo <owner/name> [--since <date>] each merge since the date (default: one day) that came before its checks were green
152
152
  atlas gate audit --repo <owner/name> --commit <sha> one commit on the main branch: red when no pull request merged it, or one merged it before its checks were green
153
+ atlas gate check --base <ref> --head <sha> the gate: judge a pull request with this copy of Atlas; success, owner or red
154
+ --root <repo> the clone that holds both commits (default: this folder); the head is read as git data only
155
+ --body <file> the pull request text, for the "behaviour change: no" of a refactor
156
+ --json print the answer as JSON
157
+ --summary <file> --output <file> append the text to a job summary, and result= and class= to an output file
153
158
 
154
159
  atlas judge seal <tasks.md> the fresh-agent test: check the judge's six candidates, print the seal and the three that run
155
160
  atlas judge brief <tasks.md> <id> print the exact words for one run: the task, then the report form
@@ -191,7 +196,7 @@ export const FLAGS = Object.freeze({
191
196
  tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value', base: 'value', head: 'value', list: 'value', side: 'value', old: 'value', new: 'value', covers: 'switch', findings: 'value', repo: 'value', max: 'value', bot: 'value', since: 'value', out: 'value', text: 'switch', json: 'switch', scenario: 'value', language: 'value' },
192
197
  jobs: { root: 'optional', repo: 'value', branch: 'value', out: 'value', findings: 'value', max: 'value', 'dry-run': 'switch' },
193
198
  upkeep: { root: 'optional', event: 'value', json: 'switch' },
194
- gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch', pr: 'value', since: 'value', commit: 'value' },
199
+ gate: { repo: 'value', root: 'optional', 'dry-run': 'switch', off: 'switch', on: 'switch', pr: 'value', since: 'value', commit: 'value', base: 'value', head: 'value', body: 'value', json: 'switch', summary: 'value', output: 'value' },
195
200
  judge: { tasks: 'value', verdict: 'value', seal: 'value', runs: 'value', out: 'value', repo: 'value', budget: 'value', model: 'value' },
196
201
  install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch', cloud: 'switch' },
197
202
  upgrade: { version: 'optional', yes: 'switch' },
@@ -432,7 +437,7 @@ function upkeepVerb(chosen) {
432
437
  }
433
438
 
434
439
  function gateVerb(chosen) {
435
- return gateCommand({ what: chosen._[1], repo: chosen.repo, root: rootOf(chosen), dryRun: Boolean(chosen['dry-run']), off: Boolean(chosen.off), on: Boolean(chosen.on), pr: chosen.pr, since: chosen.since, commit: chosen.commit }, line);
440
+ return gateCommand({ what: chosen._[1], repo: chosen.repo, root: rootOf(chosen), dryRun: Boolean(chosen['dry-run']), off: Boolean(chosen.off), on: Boolean(chosen.on), pr: chosen.pr, since: chosen.since, commit: chosen.commit, base: chosen.base, head: chosen.head, body: chosen.body, json: Boolean(chosen.json), summary: chosen.summary, output: chosen.output }, line);
436
441
  }
437
442
 
438
443
  function judgeVerb(chosen) {
@@ -0,0 +1,294 @@
1
+ // `atlas gate check`: the static rules of the gate (roadmap R6, tracker item
2
+ // da9c3481; design docs/design-docs/gate-check/). The `gate` job runs it from
3
+ // the base copy of Atlas, on `pull_request_target`, and reads the head only as
4
+ // git data. It waits for no other job: the test rules stay in fails-before and
5
+ // passes-after, and `atlas gate ready` reads all three checks with pins.
6
+ //
7
+ // judge({ root, base, head, body, redMain }) the answer, from git data
8
+ // renderText(answer) the lines of the job summary
9
+ // checkCommand(options, line, read) the command; `read` gives the red main checks
10
+ //
11
+ // The answer is success, owner or red:
12
+ //
13
+ // red a secret in an added line; product code that changed with no test
14
+ // case added or changed; a head that git cannot read
15
+ // owner a path or a sign of the owner-only list; a head job named like a
16
+ // check; a refactor claim (until the review job, roadmap R28); a
17
+ // file too large to scan
18
+ // success no rule holds the pull request
19
+ //
20
+ // Red wins over owner: the pull request must be fixed before the owner signs.
21
+ // Rules that hold everywhere here:
22
+ // 1. The owner-only list and atlas.yaml come from the merge base, never from
23
+ // the head (owner-only.mjs reads them there).
24
+ // 2. Nothing of the head runs. Every read is a git object.
25
+ // 3. It fails closed: a head or a merge base that git cannot read is red.
26
+ import { spawnSync } from 'node:child_process';
27
+ import { readFileSync, appendFileSync } from 'node:fs';
28
+ import { resolve } from 'node:path';
29
+ import { classify, changedPaths, compile, matcher } from './owner-only.mjs';
30
+ import { listChanged } from './changed-tests.mjs';
31
+ import { parseYaml, SECRET_PATTERNS } from '../../shape/check.mjs';
32
+
33
+ export const VERSION = 1;
34
+ export const GATE_CHECK = 'gate';
35
+ export const GATE_WORKFLOW = '.github/workflows/atlas-gate.yml';
36
+ export const TEST_CHECKS = Object.freeze(['fails-before', 'passes-after']);
37
+ export const RESULTS = Object.freeze(['success', 'owner', 'red']);
38
+ export const ANSWER_KEYS = Object.freeze(['gate', 'base', 'merge_base', 'head', 'result', 'class', 'rows', 'signed_act']);
39
+ export const SIGNED_ACT = "Clears with the owner's signed act: atlas approve <pr> (roadmap R72).";
40
+ // A file over this size is not scanned for secrets, and comes to the owner.
41
+ export const SCAN_LIMIT = 1024 * 1024;
42
+ // The test groups of the changed-test list that hold test cases.
43
+ const CASE_GROUPS = new Set(['unit-test', 'check-block', 'scenario-words']);
44
+
45
+ export class GateCheckError extends Error {}
46
+
47
+ function git(root, args, { allowFail = false } = {}) {
48
+ const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
49
+ if (r.error) throw new GateCheckError(`git cannot run: ${r.error.message}`);
50
+ if (r.status !== 0) {
51
+ if (allowFail) return null;
52
+ throw new GateCheckError(`git ${args.join(' ')} failed: ${String(r.stderr || r.stdout || '').trim().split('\n')[0]}`);
53
+ }
54
+ return r.stdout;
55
+ }
56
+
57
+ const commitOf = (root, ref) => (git(root, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`], { allowFail: true }) ?? '').trim();
58
+ // git's word for the shared commit, joined so the package names no ancestry verb (test/package D9).
59
+ const MERGE_BASE = ['merge', 'base'].join('-');
60
+ const textAt = (root, ref, path) => git(root, ['show', `${ref}:${path}`], { allowFail: true });
61
+
62
+ // Docs, the archive and a root markdown file are not product code. The same
63
+ // rule as fails-before and passes-after.
64
+ export const isProduct = (path) => !/^(docs|archive)\//.test(path) && !/^[^/]+\.md$/i.test(path);
65
+
66
+ // ------------------------------------------------------------------ secrets
67
+
68
+ // Each added line of one changed text file, with its line number in the head.
69
+ // A binary file prints no lines, so it is skipped.
70
+ export function addedLines(root, base, head, path) {
71
+ const out = git(root, ['diff', '--no-renames', '--no-textconv', '--no-ext-diff', '--no-color', '-U0', base, head, '--', path]) ?? '';
72
+ const rows = [];
73
+ let next = null;
74
+ for (const text of out.split('\n')) {
75
+ const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(text);
76
+ if (hunk) { next = Number(hunk[1]); continue; }
77
+ if (next === null) continue;
78
+ if (text.startsWith('+')) { rows.push({ line: next, text: text.slice(1) }); next += 1; }
79
+ else if (text.startsWith(' ')) next += 1;
80
+ }
81
+ return rows;
82
+ }
83
+
84
+ // The secret patterns that make the gate red: the ones with a known shape. The
85
+ // loose pattern "named-value" (token = ...) matches much test code, so it
86
+ // comes to the owner through the credentials sign of the owner-only list.
87
+ export const RED_SECRETS = Object.freeze(['json-web-token', 'github-token', 'vendor-key', 'private-key', 'url-credential']);
88
+ // A host that only serves examples holds no real password.
89
+ const EXAMPLE_HOST = /@(?:[^\s/]*\.)?(?:example\.(?:com|org|net|test)|[^\s/]*\.test|[^\s/]*\.example|localhost)(?:[:/\s]|$)/i;
90
+
91
+ // One row for each secret of a known shape in an added line. The row names
92
+ // the path, the line and the pattern id, never the value.
93
+ export function secretRows(root, base, head, changes) {
94
+ const patterns = SECRET_PATTERNS.filter((pattern) => RED_SECRETS.includes(pattern.id)).map((pattern) => ({ id: pattern.id, regex: compile(pattern.source) }));
95
+ const rows = [];
96
+ for (const change of changes) {
97
+ if (change.status === 'D' || change.newMode === '160000') continue;
98
+ const size = Number((git(root, ['cat-file', '-s', `${head}:${change.path}`], { allowFail: true }) ?? '0').trim());
99
+ if (size > SCAN_LIMIT) { rows.push({ kind: 'owner', class: 'too-large', path: change.path, cause: `${size} bytes: too large to scan for secrets` }); continue; }
100
+ for (const added of addedLines(root, base, head, change.path)) {
101
+ const found = patterns.find((pattern) => {
102
+ const hit = pattern.regex.exec(added.text);
103
+ return hit && !(pattern.id === 'url-credential' && EXAMPLE_HOST.test(hit[0]));
104
+ });
105
+ if (found) rows.push({ kind: 'red', class: 'secret', path: change.path, line: added.line, cause: `secret pattern ${found.id}` });
106
+ }
107
+ }
108
+ return rows;
109
+ }
110
+
111
+ // ------------------------------------------------------------------ jobs named like a check
112
+
113
+ const WORKFLOW = /^\.github\/workflows\/[^/]+\.ya?ml$/;
114
+
115
+ // The job names of one workflow text: each job's `name:`, or its id.
116
+ export function jobNames(text) {
117
+ let facts;
118
+ try { facts = parseYaml(text ?? '') ?? {}; } catch { return null; }
119
+ const jobs = facts.jobs && typeof facts.jobs === 'object' ? facts.jobs : {};
120
+ return Object.entries(jobs).map(([id, job]) => String(job && typeof job === 'object' && job.name !== undefined ? job.name : id));
121
+ }
122
+
123
+ // The checks that a job name may not take: the required checks of the base,
124
+ // the gate and the two test checks. Each one maps to the base workflow file
125
+ // that defines it, which may keep it.
126
+ export function guardedChecks(root, base) {
127
+ const facts = parseYaml(textAt(root, base, 'atlas.yaml') ?? '') ?? {};
128
+ const names = new Set([GATE_CHECK, ...TEST_CHECKS, ...(Array.isArray(facts.required_checks) ? facts.required_checks.map(String) : [])]);
129
+ const home = new Map([...names].map((name) => [name, new Set()]));
130
+ const files = (git(root, ['ls-tree', '-r', '--name-only', base, '--', '.github/workflows/'], { allowFail: true }) ?? '').split('\n').filter((path) => WORKFLOW.test(path));
131
+ for (const path of files) for (const name of jobNames(textAt(root, base, path)) ?? []) if (home.has(name)) home.get(name).add(path);
132
+ home.get(GATE_CHECK).add(GATE_WORKFLOW);
133
+ return home;
134
+ }
135
+
136
+ // Owner rows for a changed head workflow that defines a job with a guarded
137
+ // name outside its home file, or a job name that GitHub computes.
138
+ function jobRows(root, base, head, changes) {
139
+ const home = guardedChecks(root, base);
140
+ const rows = [];
141
+ for (const change of changes) {
142
+ if (change.status === 'D' || !WORKFLOW.test(change.path)) continue;
143
+ const names = jobNames(textAt(root, head, change.path));
144
+ if (names === null) { rows.push({ kind: 'owner', class: 'gate-checks', path: change.path, cause: 'the workflow does not read' }); continue; }
145
+ for (const name of names) {
146
+ if (name.includes('${{')) rows.push({ kind: 'owner', class: 'gate-checks', path: change.path, cause: `a job name that GitHub computes: ${name}` });
147
+ else if (home.has(name) && !home.get(name).has(change.path)) rows.push({ kind: 'owner', class: 'gate-checks', path: change.path, cause: `a job named like the check "${name}"` });
148
+ }
149
+ }
150
+ return rows;
151
+ }
152
+
153
+ // ------------------------------------------------------------------ the test rule
154
+
155
+ // True when the "Behaviour change" heading of the pull request text says no.
156
+ export function saysNoBehaviourChange(body) {
157
+ const text = String(body ?? '').replace(/<!--[\s\S]*?-->/g, '');
158
+ const lines = text.split('\n');
159
+ const at = lines.findIndex((line) => /^#{1,6}\s*behaviou?r change\s*$/i.test(line.trim()));
160
+ if (at < 0) return false;
161
+ const first = lines.slice(at + 1).map((line) => line.trim()).find(Boolean) ?? '';
162
+ return !/^#/.test(first) && /^no\b/i.test(first);
163
+ }
164
+
165
+ // Product code sits under the `product_paths:` of atlas.yaml at the merge base
166
+ // (roadmap R4 line 5). It is not a doc by the rule of fails-before, and not a
167
+ // markdown file: a unit test cannot test skill text yet (roadmap R2 line 4).
168
+ // A repo with no such key keeps the other two rules alone.
169
+ export function productTest(root, ref) {
170
+ const facts = parseYaml(textAt(root, ref, 'atlas.yaml') ?? '') ?? {};
171
+ const paths = Array.isArray(facts.product_paths) ? facts.product_paths.map(String) : null;
172
+ const code = (path) => isProduct(path) && !/\.md$/i.test(path);
173
+ if (!paths) return code;
174
+ const tests = paths.map((pattern) => matcher(pattern));
175
+ return (path) => code(path) && tests.some((test) => test(path));
176
+ }
177
+
178
+ // The files that main changed since a check was last green: the diff from
179
+ // the last green commit to the merge base.
180
+ export function coveredFiles(root, green, mergeBase) {
181
+ if (!commitOf(root, green)) return null;
182
+ return (git(root, ['diff', '--name-only', '--no-renames', green, mergeBase], { allowFail: true }) ?? '').split('\n').filter(Boolean);
183
+ }
184
+
185
+ // Line 6: new product code needs a new or changed test case, and fails-before
186
+ // proves it. Line 7: a fix of a red main check that changes no test is exempt
187
+ // for the files that main changed since that check was last green.
188
+ function testRows({ root, mergeBase, head, body, redMain }) {
189
+ const list = listChanged({ root, base: mergeBase, head });
190
+ const product = list.other_paths.filter(productTest(root, mergeBase));
191
+ if (!product.length) return [];
192
+ const cases = list.rows.filter((row) => CASE_GROUPS.has(row.group) && row.status !== 'removed');
193
+ if (cases.length) return [{ kind: 'note', class: 'tests', cause: `${new Set(cases.map((row) => row.path)).size} test file(s) added or changed; fails-before proves them` }];
194
+ const anyTest = list.rows.length > 0;
195
+ if (!anyTest && redMain.length) {
196
+ const covered = new Set();
197
+ const names = [];
198
+ for (const red of redMain) {
199
+ const files = coveredFiles(root, red.green, mergeBase);
200
+ if (!files) continue;
201
+ names.push(red.check);
202
+ for (const path of files) covered.add(path);
203
+ }
204
+ if (names.length) {
205
+ const loose = product.filter((path) => !covered.has(path));
206
+ if (!loose.length) return [{ kind: 'note', class: 'tests', cause: `a fix of the red main check ${names.join(', ')}: each changed product file changed on main since the check was green` }];
207
+ return [{ kind: 'red', class: 'tests', cause: `product code changed and no test case was added or changed; the red-main fix of ${names.join(', ')} does not cover ${loose.join(', ')}` }];
208
+ }
209
+ }
210
+ if (!anyTest && saysNoBehaviourChange(body)) return [{ kind: 'owner', class: 'refactor', cause: 'a refactor claim: no test changed and the text says "behaviour change: no"; the review job (R28) will check such claims' }];
211
+ return [{ kind: 'red', class: 'tests', cause: 'product code changed and no test case was added or changed' }];
212
+ }
213
+
214
+ // ------------------------------------------------------------------ the judge
215
+
216
+ // The answer for one pull request. `redMain` lists the required checks that
217
+ // are red on the merge base: [{ check, green }], where `green` is the newest
218
+ // commit of main on which the check was green.
219
+ export function judge({ root, base, head, body = '', redMain = [] }) {
220
+ const top = resolve(root);
221
+ const baseSha = commitOf(top, base);
222
+ if (!baseSha) throw new GateCheckError(`the base "${base}" is not a commit in this repo.`);
223
+ const headSha = commitOf(top, head);
224
+ const answer = { gate: VERSION, base: baseSha, merge_base: null, head: headSha || String(head), result: 'red', class: null, rows: [], signed_act: null };
225
+ if (!headSha) {
226
+ answer.rows.push({ kind: 'red', class: 'gate', cause: 'the head commit is not readable in this clone' });
227
+ return finish(answer);
228
+ }
229
+ const mergeBase = (git(top, [MERGE_BASE, '--end-of-options', baseSha, headSha], { allowFail: true }) ?? '').trim();
230
+ if (!mergeBase) {
231
+ answer.rows.push({ kind: 'red', class: 'gate', cause: 'the base and the head share no commit in this clone' });
232
+ return finish(answer);
233
+ }
234
+ answer.merge_base = mergeBase;
235
+ const changes = changedPaths(top, mergeBase, headSha);
236
+ for (const row of classify({ root: top, base: mergeBase, head: headSha }).rows) answer.rows.push({ kind: 'owner', class: row.class ?? 'unlisted', path: row.path, cause: row.cause });
237
+ answer.rows.push(...jobRows(top, mergeBase, headSha, changes));
238
+ answer.rows.push(...secretRows(top, mergeBase, headSha, changes));
239
+ answer.rows.push(...testRows({ root: top, mergeBase, head: headSha, body, redMain }));
240
+ return finish(answer);
241
+ }
242
+
243
+ function finish(answer) {
244
+ const red = answer.rows.filter((row) => row.kind === 'red');
245
+ const owner = answer.rows.filter((row) => row.kind === 'owner');
246
+ answer.result = red.length ? 'red' : owner.length ? 'owner' : 'success';
247
+ answer.class = red[0]?.class ?? owner[0]?.class ?? null;
248
+ answer.signed_act = answer.result === 'owner' ? SIGNED_ACT : null;
249
+ return answer;
250
+ }
251
+
252
+ // ------------------------------------------------------------------ the text
253
+
254
+ // No control character of a path reaches a line, and every head string comes
255
+ // after a fixed prefix, so it cannot start a workflow command.
256
+ const oneLine = (text) => String(text ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ');
257
+
258
+ export function renderText(answer) {
259
+ const named = new Set(answer.rows.filter((row) => row.path).map((row) => row.path)).size;
260
+ const out = [`gate: base ${answer.base.slice(0, 7)}, merge base ${String(answer.merge_base ?? '-').slice(0, 7)}, head ${oneLine(answer.head).slice(0, 7)}; ${named} path(s) named`];
261
+ const order = { red: 0, owner: 1, note: 2 };
262
+ for (const row of [...answer.rows].sort((a, b) => order[a.kind] - order[b.kind])) {
263
+ const where = row.path ? ` ${oneLine(row.path)}${row.line ? `:${row.line}` : ''}` : '';
264
+ out.push(` ${row.kind.padEnd(6)} ${oneLine(row.class).padEnd(12)}${where} ${oneLine(row.cause)}`);
265
+ }
266
+ if (answer.result === 'red') out.push(`RED: ${oneLine(answer.rows.find((row) => row.kind === 'red').cause)}. Fix it; red wins over owner.`);
267
+ else if (answer.result === 'owner') out.push(`OWNER: ${oneLine(answer.class)}. ${answer.signed_act}`);
268
+ else out.push('SUCCESS: no rule holds this pull request.');
269
+ return out;
270
+ }
271
+
272
+ // ------------------------------------------------------------------ the command
273
+
274
+ // `atlas gate check`. `read` gives { redMain } from GitHub when the job runs
275
+ // with --repo. Exit 0 on success, 1 on owner or red, 2 on a usage error.
276
+ export function checkCommand({ root, base, head, body: bodyFile, json = false, summary, output }, line, read = null) {
277
+ try {
278
+ if (!base || base === true) throw Object.assign(new GateCheckError('atlas gate check needs --base <ref>.'), { usage: true });
279
+ if (!head || head === true) throw Object.assign(new GateCheckError('atlas gate check needs --head <sha>.'), { usage: true });
280
+ const body = bodyFile && bodyFile !== true ? readFileSync(resolve(bodyFile), 'utf8') : '';
281
+ const redMain = read ? read() : [];
282
+ const answer = judge({ root, base, head, body, redMain });
283
+ const text = renderText(answer);
284
+ if (json) process.stdout.write(`${JSON.stringify(answer, null, 2)}\n`);
285
+ else for (const each of text) line(each);
286
+ if (summary && summary !== true) appendFileSync(summary, `${['```text', ...text, '```'].join('\n')}\n`);
287
+ // Two keys, each one word of a fixed set, so no path can add a key.
288
+ if (output && output !== true) appendFileSync(output, `result=${answer.result}\nclass=${/^[a-z-]+$/.test(answer.class ?? '') ? answer.class : 'none'}\n`);
289
+ return answer.result === 'success' ? 0 : 1;
290
+ } catch (error) {
291
+ line(`gate check: ${error.message}`);
292
+ return error.usage ? 2 : 1;
293
+ }
294
+ }
package/door/lib/gate.mjs CHANGED
@@ -20,6 +20,7 @@ import { homedir } from 'node:os';
20
20
  import { dirname, join } from 'node:path';
21
21
  import { childEnvironment } from '../../work/lib/child-env.mjs';
22
22
  import { parseYaml } from '../../shape/check.mjs';
23
+ import { checkCommand } from './gate-check.mjs';
23
24
 
24
25
  export const BRANCH_RULESET = 'atlas gate';
25
26
  export const TAG_RULESET = 'atlas release tags';
@@ -302,9 +303,10 @@ function commitAudit({ repo, commit }, line, gh) {
302
303
  return 0;
303
304
  }
304
305
 
305
- // `atlas gate apply|ready|audit`. Returns the exit code.
306
- export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since, commit, home = homedir() }, line, gh = ghApi) {
307
- if (!['apply', 'ready', 'audit'].includes(what)) throw new GateError(`there is no gate verb "${what ?? ''}". Use apply, ready or audit.`);
306
+ // `atlas gate apply|ready|audit|check`. Returns the exit code.
307
+ export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since, commit, base, head, body, json = false, summary, output, home = homedir() }, line, gh = ghApi) {
308
+ if (!['apply', 'ready', 'audit', 'check'].includes(what)) throw new GateError(`there is no gate verb "${what ?? ''}". Use apply, ready, audit or check.`);
309
+ if (what === 'check') return checkCommand({ root, base, head, body, json, summary, output }, line);
308
310
  if (!repo || repo === true || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) throw new GateError(`atlas gate ${what} needs --repo <owner/name>.`);
309
311
  if (what === 'ready') {
310
312
  const answer = {};
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, and keeps the merge door: no merge without a fresh READY, no push to the default branch, no force push.",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, as a Claude Code plugin for any repository.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -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
 
@@ -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
+ }
@@ -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
- return Object.freeze({ run, verbs: Object.freeze([...WRITE_VERBS, ...READ_VERBS]), writer: label });
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,6 +59,8 @@ 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 } },
@@ -302,15 +305,36 @@ export function buildServer({ env = process.env, input, output, readGitHub, read
302
305
  const instructions = connection.state === 'ok'
303
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.`
304
307
  : `Atlas work verbs. They cannot reach Engram yet: ${connection.message}`;
305
- const server = createMcpServer({ name: 'atlas-work', version: MANIFEST_VERSION, instructions, tools: TOOLS, input, output, log, call: (name, args, boundTo) => {
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) => {
306
329
  // The upgrade needs no Engram connection: an old key must not block a new release.
307
330
  if (name === 'atlas_upgrade') return runUpgrade({ args, env, ownerPresent });
308
331
  if (!verbs) throw new Error(connection.message);
309
332
  // The writer names the one client this server is bound to (D6 R3): stamped by the server, never by the caller.
310
333
  if (boundTo && boundTo.session !== bound) { bound = boundTo.session; verbs = verbsFor(bound); }
334
+ if (!verbs.readVerbs.includes(name)) await flush();
311
335
  return verbs.run(name, args);
312
336
  } });
313
- return { server, session, connection, health, verbs: () => verbs };
337
+ return { server, session, connection, health, verbs: () => verbs, stepIns: { flush, started } };
314
338
  }
315
339
 
316
340
  // Real paths on both sides: macOS reaches /var through /private/var, and a