@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.
- package/.claude-plugin/plugin.json +1 -1
- package/door/cli.mjs +7 -2
- package/door/lib/gate-check.mjs +294 -0
- package/door/lib/gate.mjs +5 -3
- 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/work/lib/step-ins.mjs +217 -0
- package/work/lib/verbs.mjs +10 -3
- package/work/mcp.mjs +26 -2
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
|
|
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,
|
|
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
|
|
|
@@ -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,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
|
-
|
|
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
|