@arjunkhera/atlas 0.4.7 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
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/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.8",
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