@arjunkhera/atlas 0.0.0 → 0.2.1

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.
Files changed (43) hide show
  1. package/.claude-plugin/plugin.json +18 -0
  2. package/.mcp.json +18 -0
  3. package/README.md +32 -1
  4. package/agents/artifact-format/example.html +71 -0
  5. package/agents/artifact-format/system.css +273 -0
  6. package/agents/artifact-renderer.md +132 -0
  7. package/agents/capability-reader.md +100 -0
  8. package/agents/code-explorer.md +108 -0
  9. package/agents/reviewer-architect.md +78 -0
  10. package/agents/reviewer-pm.md +81 -0
  11. package/agents/reviewer-security.md +82 -0
  12. package/bin/atlas +5 -0
  13. package/door/cli.mjs +280 -0
  14. package/door/lib/helpers.mjs +139 -0
  15. package/door/lib/install.mjs +260 -0
  16. package/door/lib/kind-drift.mjs +76 -0
  17. package/door/lib/onboarding.mjs +130 -0
  18. package/door/lib/pull-request.mjs +119 -0
  19. package/door/lib/releases.mjs +42 -0
  20. package/door/lib/status.mjs +47 -0
  21. package/door/lib/tooling.mjs +119 -0
  22. package/package.json +24 -3
  23. package/shape/check.mjs +1797 -0
  24. package/shape/releases.json +15 -0
  25. package/skills/sdlc-task/SKILL.md +174 -0
  26. package/skills/sdlc-task/lifecycle.yaml +27 -0
  27. package/skills/sdlc-task/templates/design-doc.md +118 -0
  28. package/skills/sdlc-task/templates/resume-anchor.md +25 -0
  29. package/work/code-read.mjs +65 -0
  30. package/work/lib/action-store.mjs +84 -0
  31. package/work/lib/capability.mjs +487 -0
  32. package/work/lib/child-env.mjs +11 -0
  33. package/work/lib/client.mjs +214 -0
  34. package/work/lib/code-read.mjs +238 -0
  35. package/work/lib/delivery.mjs +624 -0
  36. package/work/lib/mcp-stdio.mjs +68 -0
  37. package/work/lib/questions.mjs +278 -0
  38. package/work/lib/sweep.mjs +189 -0
  39. package/work/lib/verb-fields.mjs +53 -0
  40. package/work/lib/verbs.mjs +511 -0
  41. package/work/manifest-0.5.0.json +609 -0
  42. package/work/mcp.mjs +216 -0
  43. package/work/protected-paths.json +38 -0
@@ -0,0 +1,139 @@
1
+ // The five helpers, and the entry verb that routes to them.
2
+ //
3
+ // Milestone M2, slice L2 stage one. Authority:
4
+ // Atlas amendment A1.6, section 5, the L2 stage
5
+ // one paragraph; the Atlas architecture record, section 5.1.
6
+ //
7
+ // One helper per class of file. You never pick one. You say what changed, and
8
+ // the entry verb routes it. "We now deploy prod on Fly" reaches Facts, which
9
+ // edits atlas/deploy.yaml, and Procedures, which updates the deploy skill. One
10
+ // request, two helpers, one pull request.
11
+ //
12
+ // What a helper does here: it says which files it owns, it refuses a change to
13
+ // a file it does not own, and it carries the change into one pull request with
14
+ // the fixed label and per-change provenance. It does not draft. An agent in a
15
+ // session drafts, because drafting needs a model and a read of the repository.
16
+ // The tooling helper is the exception: both the files it owns are written from
17
+ // the package, so it needs no model at all.
18
+ import { posix } from 'node:path';
19
+
20
+ // R13, from the M2 design study's review panel: agent-invoked helper pull
21
+ // requests carried no provenance. Every change now names where it came from,
22
+ // and the set is closed.
23
+ export const PROVENANCE_SOURCES = Object.freeze(['owner words', 'repo read', 'task input']);
24
+
25
+ // The one label every helper pull request carries. A person filters on it, and
26
+ // slice L4's merge actor reads it.
27
+ export const HELPER_LABEL = 'atlas-helper';
28
+
29
+ const under = (...prefixes) => (path) => prefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
30
+ const isOneOf = (...paths) => (path) => paths.includes(path);
31
+
32
+ export const HELPERS = Object.freeze([
33
+ {
34
+ id: 'entry-map',
35
+ file: 1,
36
+ title: 'Entry map',
37
+ owns: isOneOf('CLAUDE.md', 'AGENTS.md'),
38
+ ownsText: 'CLAUDE.md, AGENTS.md',
39
+ forWhat: 'Keeps the front door true: what the repo is, how to build it, where things are.',
40
+ youMightSay: 'the modules moved, refresh the map',
41
+ words: ['entry map', 'claude.md', 'agents.md', 'front door', 'layout', 'module', 'modules', 'readme', 'structure', 'moved'],
42
+ },
43
+ {
44
+ id: 'rules',
45
+ file: 2,
46
+ title: 'Path rules',
47
+ owns: under('.claude/rules'),
48
+ ownsText: '.claude/rules/*.md',
49
+ forWhat: 'Records a constraint that holds under one path only.',
50
+ youMightSay: 'migrations are append-only under migrations/',
51
+ words: ['rule', 'rules', 'constraint', 'never', 'append-only', 'forbidden', 'must not', 'policy'],
52
+ },
53
+ {
54
+ id: 'procedures',
55
+ file: 3,
56
+ title: 'Procedures',
57
+ owns: (path) => path.startsWith('.claude/skills/') && path.endsWith('/SKILL.md'),
58
+ ownsText: '.claude/skills/<name>/SKILL.md',
59
+ forWhat: 'Keeps the how-to for running, testing, deploying and releasing.',
60
+ youMightSay: 'we changed how we deploy',
61
+ words: ['skill', 'procedure', 'how to', 'run locally', 'test', 'tests', 'deploy', 'release', 'ship', 'runbook'],
62
+ },
63
+ {
64
+ id: 'facts',
65
+ file: 4,
66
+ title: 'Repo facts',
67
+ owns: (path) => path === 'atlas.yaml' || under('atlas')(path),
68
+ ownsText: 'atlas.yaml, atlas/',
69
+ forWhat: 'Holds where it runs, where the logs are, and every credential by name.',
70
+ youMightSay: 'add a staging environment on Fly',
71
+ words: ['fact', 'facts', 'atlas.yaml', 'environment', 'staging', 'prod', 'logs', 'metrics', 'credential', 'credentials', 'dependency', 'dependencies', 'kind', 'deploy target', 'fly', 'region'],
72
+ },
73
+ {
74
+ id: 'tooling',
75
+ file: 7,
76
+ title: 'Tooling',
77
+ owns: (path) => path === '.github/atlas/check.mjs' || path === '.github/workflows/atlas-check.yml',
78
+ ownsText: '.github/atlas/check.mjs, .github/workflows/atlas-check.yml',
79
+ forWhat: 'Keeps the automatic checks this repo runs.',
80
+ youMightSay: 'refresh the Atlas check',
81
+ words: ['check', 'workflow', 'ci', 'lint', 'linter', 'action', 'tooling', 'refresh the check', 'atlas check'],
82
+ },
83
+ ]);
84
+
85
+ export const helperById = (id) => HELPERS.find((helper) => helper.id === id) ?? null;
86
+
87
+ // Which helper owns a path. null means no helper owns it, and no helper may
88
+ // write it.
89
+ export function ownerOf(path) {
90
+ const clean = posix.normalize(String(path ?? '')).replace(/^\.\//, '');
91
+ return HELPERS.find((helper) => helper.owns(clean)) ?? null;
92
+ }
93
+
94
+ // The entry verb's routing. You say what changed; this says which helpers it
95
+ // reaches, in file order. An empty list is an honest answer: it means the
96
+ // sentence names nothing any helper owns, and the caller should say more.
97
+ export function route(say) {
98
+ const text = String(say ?? '').toLowerCase();
99
+ if (!text.trim()) return [];
100
+ return HELPERS.filter((helper) => helper.words.some((word) => text.includes(word)));
101
+ }
102
+
103
+ // First setup is the same verb. It runs all five, in file order. There is no
104
+ // separate first-run path to maintain.
105
+ export const setupOrder = () => [...HELPERS];
106
+
107
+ export class HelperError extends Error {}
108
+
109
+ // Every change a helper carries, checked before anything is written.
110
+ //
111
+ // Two rules, and both are about who may write what. A helper writes only the
112
+ // files it owns, so one request cannot quietly rewrite another class of file.
113
+ // Every change names its source, so a reader of the pull request can tell an
114
+ // owner decision from a guess.
115
+ export function checkChanges(helperId, changes) {
116
+ const helper = helperById(helperId);
117
+ if (!helper) throw new HelperError(`there is no helper "${helperId}"; the helpers are ${HELPERS.map((one) => one.id).join(', ')}`);
118
+ if (!Array.isArray(changes) || !changes.length) throw new HelperError('a helper needs at least one change to carry');
119
+ for (const change of changes) {
120
+ const path = String(change?.path ?? '');
121
+ if (!path) throw new HelperError('a change has no path');
122
+ if (path.startsWith('/') || path.includes('..')) throw new HelperError(`"${path}" leaves the repo; a helper writes inside one repo only`);
123
+ if (!helper.owns(path)) {
124
+ const other = ownerOf(path);
125
+ throw new HelperError(other
126
+ ? `the ${helper.id} helper does not own "${path}"; the ${other.id} helper does. Route the change there.`
127
+ : `no helper owns "${path}"; the five helpers own ${HELPERS.map((one) => one.ownsText).join('; ')}`);
128
+ }
129
+ if (typeof change.contents !== 'string') throw new HelperError(`the change to "${path}" carries no contents`);
130
+ const source = change?.provenance?.source;
131
+ if (!PROVENANCE_SOURCES.includes(source)) {
132
+ throw new HelperError(`the change to "${path}" names no source; every change names one of ${PROVENANCE_SOURCES.join(', ')} (review finding R13)`);
133
+ }
134
+ if (!String(change?.provenance?.detail ?? '').trim()) {
135
+ throw new HelperError(`the change to "${path}" names the source "${source}" and says nothing about it; give the words, the file read, or the task`);
136
+ }
137
+ }
138
+ return helper;
139
+ }
@@ -0,0 +1,260 @@
1
+ // atlas install, atlas upgrade, atlas doctor: Atlas on one Mac.
2
+ //
3
+ // Row F11, amendment A1.9; the design is "Install and upgrade on the Mac" in
4
+ // the Atlas F11 study.
5
+ //
6
+ // The catalog is one folder on the Mac, ~/.atlas/catalog. It holds one entry
7
+ // that pins an exact npm version, so nothing moves Atlas to another version
8
+ // without `atlas upgrade`. The installer writes that one file and asks Claude
9
+ // Code to add the catalog and install the plugin. It writes no settings file.
10
+ import { execFileSync } from 'node:child_process';
11
+ import { createHash } from 'node:crypto';
12
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join, relative } from 'node:path';
15
+ import { createInterface } from 'node:readline/promises';
16
+
17
+ export const CATALOG_NAME = 'atlas';
18
+ export const PLUGIN_ID = 'atlas@atlas';
19
+ export const DEV_PLUGIN_ID = 'atlas@atlas-dev';
20
+ const EXACT_VERSION = /^\d+\.\d+\.\d+$/;
21
+
22
+ const packageJson = (packageRoot) => JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'));
23
+ export function repoOf(packageRoot) {
24
+ const url = packageJson(packageRoot).repository?.url ?? '';
25
+ const found = /github\.com[/:]([^/]+\/[^/.]+)/.exec(url)?.[1];
26
+ if (!found) throw new Error('package.json names no GitHub repository');
27
+ return found;
28
+ }
29
+
30
+ export const catalogDir = (env) => join(env.HOME, '.atlas', 'catalog');
31
+ export const catalogFile = (env) => join(catalogDir(env), '.claude-plugin', 'marketplace.json');
32
+
33
+ // The one file the installer writes. `local` puts a copy of the package inside
34
+ // the catalog instead of naming npm; CI and a development Mac use it.
35
+ export function catalogFor({ name, version, local = false }) {
36
+ if (!local && !EXACT_VERSION.test(version)) throw new Error(`"${version}" is not an exact version`);
37
+ return {
38
+ name: CATALOG_NAME,
39
+ owner: { name: 'Atlas' },
40
+ description: 'Atlas on this Mac. atlas upgrade moves the pin; nothing else does.',
41
+ plugins: [{
42
+ name: 'atlas',
43
+ description: 'The delivery lifecycle, its crews and the Atlas tools.',
44
+ source: local ? './atlas' : { source: 'npm', package: name, version },
45
+ }],
46
+ };
47
+ }
48
+
49
+ function defaultExec(command, args, { input } = {}) {
50
+ return execFileSync(command, args, { encoding: 'utf8', input, maxBuffer: 64 * 1024 * 1024, timeout: 600_000, stdio: ['pipe', 'pipe', 'pipe'] });
51
+ }
52
+
53
+ async function defaultAsk(question) {
54
+ if (!process.stdin.isTTY) return false;
55
+ const reader = createInterface({ input: process.stdin, output: process.stdout });
56
+ try { return /^y(es)?$/i.test((await reader.question(`${question} [y/N] `)).trim()); } finally { reader.close(); }
57
+ }
58
+
59
+ function claudePlugins(exec) {
60
+ try { return JSON.parse(exec('claude', ['plugin', 'list', '--json'])); } catch { return null; }
61
+ }
62
+ const pluginRows = (listed) => (Array.isArray(listed) ? listed : listed?.plugins ?? listed?.installed ?? []);
63
+ const findPlugin = (listed, id) => pluginRows(listed).find((row) => row.id === id || `${row.name}@${row.marketplace}` === id) ?? null;
64
+
65
+ // ---------------------------------------------------------------- install
66
+
67
+ export async function install({ packageRoot, options = {}, line = console.log, env = process.env, exec = defaultExec, ask = defaultAsk }) {
68
+ const { name, version } = packageJson(packageRoot);
69
+ const local = Boolean(options.local);
70
+ const from = options.from && options.from !== true ? String(options.from) : null;
71
+ const catalog = catalogFor({ name, version, local });
72
+ const file = catalogFile(env);
73
+ const before = existsSync(file) ? readFileSync(file, 'utf8') : null;
74
+ const after = `${JSON.stringify(catalog, null, 2)}\n`;
75
+
76
+ line(`Install Atlas ${version} for every folder on this Mac.`);
77
+ line('');
78
+ line('What this does, in order:');
79
+ const steps = [
80
+ options['skip-global'] ? null : `npm install -g --ignore-scripts ${from ?? `${name}@${version}`} (puts "atlas" on your terminal path)`,
81
+ `write ${file} (${before === null ? 'new file' : before === after ? 'unchanged' : 'changed'}; it pins ${local ? 'a local copy' : `exactly ${version}`})`,
82
+ `claude plugin marketplace add ${catalogDir(env)}`,
83
+ `claude plugin install ${PLUGIN_ID} --scope user`,
84
+ 'atlas doctor',
85
+ ].filter(Boolean);
86
+ steps.forEach((step, index) => line(` ${index + 1}. ${step}`));
87
+ line('');
88
+ line('Claude Code itself then writes two keys into ~/.claude/settings.json, and nothing else:');
89
+ line(` extraKnownMarketplaces.${CATALOG_NAME} (the catalog folder)`);
90
+ line(` enabledPlugins["${PLUGIN_ID}"] = true`);
91
+ line('This command writes no settings file of its own.');
92
+ if (before !== null && before !== after) { line(''); line('The catalog file changes from:'); line(before); line('to:'); line(after); }
93
+ if (!options.yes && !(await ask('Go ahead?'))) { line('Nothing was changed.'); return 1; }
94
+
95
+ if (!options['skip-global']) exec('npm', ['install', '-g', '--ignore-scripts', from ?? `${name}@${version}`]);
96
+ mkdirSync(join(catalogDir(env), '.claude-plugin'), { recursive: true });
97
+ if (local) {
98
+ const target = join(catalogDir(env), 'atlas');
99
+ rmSync(target, { recursive: true, force: true });
100
+ // Filter on the path inside the package: a global install itself sits under
101
+ // node_modules/, so a filter on the full path would copy nothing.
102
+ cpSync(packageRoot, target, { recursive: true, filter: (path) => !/(^|\/)(node_modules|\.claude)(\/|$)/.test(relative(packageRoot, path)) });
103
+ }
104
+ writeFileSync(file, after);
105
+ const known = (() => { try { return JSON.parse(exec('claude', ['plugin', 'marketplace', 'list', '--json'])); } catch { return []; } })();
106
+ const hasCatalog = (Array.isArray(known) ? known : known?.marketplaces ?? []).some((row) => row.name === CATALOG_NAME);
107
+ exec('claude', hasCatalog ? ['plugin', 'marketplace', 'update', CATALOG_NAME] : ['plugin', 'marketplace', 'add', catalogDir(env)]);
108
+ const installArgs = ['plugin', 'install', PLUGIN_ID, '--scope', 'user'];
109
+ exec('claude', installArgs);
110
+ line('');
111
+ line('Installed. Open Claude Code, run /plugin, choose atlas, and set the Engram connection:');
112
+ line(' https://<your Engram host> <your Engram key>');
113
+ line('');
114
+ return doctor({ packageRoot, options, line, env, exec });
115
+ }
116
+
117
+ // ---------------------------------------------------------------- upgrade
118
+
119
+ // A git blob id, as GitHub's tree API reports it.
120
+ export const blobSha = (bytes) => createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
121
+ export const sha512Integrity = (bytes) => `sha512-${createHash('sha512').update(bytes).digest('base64')}`;
122
+
123
+ function filesUnder(root) {
124
+ const out = new Map();
125
+ const walk = (dir) => {
126
+ for (const entry of readdirSync(dir)) {
127
+ const full = join(dir, entry);
128
+ if (statSync(full).isDirectory()) walk(full);
129
+ else out.set(relative(root, full).split('\\').join('/'), readFileSync(full));
130
+ }
131
+ };
132
+ walk(root);
133
+ return out;
134
+ }
135
+
136
+ // Is `sha` a commit of the master branch itself? A run's branch name is not
137
+ // enough: a tag named "master" gives a run the same name.
138
+ export function onMaster(gh, repo, sha) {
139
+ const compare = gh(['api', '--method', 'GET', `repos/${repo}/compare/${sha}...refs/heads/master`]);
140
+ return ['ahead', 'identical'].includes(compare.status);
141
+ }
142
+
143
+ // The master run of release.yml that built this version, or null.
144
+ export function findReleaseRun({ gh, repo, version }) {
145
+ const runs = gh(['api', '--method', 'GET', `repos/${repo}/actions/workflows/release.yml/runs?event=push&status=success&per_page=100`]).workflow_runs ?? [];
146
+ for (const run of runs) {
147
+ if (!/^[0-9a-f]{40}$/.test(run.head_sha ?? '')) continue;
148
+ let named;
149
+ try {
150
+ const file = gh(['api', '--method', 'GET', `repos/${repo}/contents/packages/atlas/package.json?ref=${run.head_sha}`]);
151
+ named = JSON.parse(Buffer.from(file.content, 'base64').toString('utf8')).version;
152
+ } catch { continue; }
153
+ if (named !== version) continue;
154
+ if (!onMaster(gh, repo, run.head_sha)) continue;
155
+ return run;
156
+ }
157
+ return null;
158
+ }
159
+
160
+ // Every file of the tarball must be the file at that commit, and every file of
161
+ // packages/atlas at that commit must be in the tarball.
162
+ export function matchTree({ tree, files }) {
163
+ const wanted = new Map((tree ?? []).filter((row) => row.type === 'blob' && row.path.startsWith('packages/atlas/')).map((row) => [row.path.slice('packages/atlas/'.length), row.sha]));
164
+ const problems = [];
165
+ for (const [path, bytes] of files) {
166
+ if (!wanted.has(path)) problems.push(`${path} is in the tarball and not in the commit`);
167
+ else if (wanted.get(path) !== blobSha(bytes)) problems.push(`${path} differs from the commit`);
168
+ }
169
+ for (const path of wanted.keys()) if (!files.has(path)) problems.push(`${path} is in the commit and not in the tarball`);
170
+ return problems;
171
+ }
172
+
173
+ export async function upgrade({ packageRoot, options = {}, line = console.log, env = process.env, exec = defaultExec, ask = defaultAsk }) {
174
+ const { name, version: installed } = packageJson(packageRoot);
175
+ const repo = repoOf(packageRoot);
176
+ const gh = (args) => JSON.parse(exec('gh', args));
177
+ const target = options.version && options.version !== true ? String(options.version) : exec('npm', ['view', name, 'version']).trim();
178
+ if (!EXACT_VERSION.test(target)) throw new Error(`npm answered "${target}", which is not a version`);
179
+ if (target === installed) { line(`Atlas ${installed} is the newest release. Nothing to do.`); return 0; }
180
+ const integrity = exec('npm', ['view', `${name}@${target}`, 'dist.integrity']).trim();
181
+
182
+ line(`Atlas ${installed} → ${target}`);
183
+ const run = findReleaseRun({ gh, repo, version: target });
184
+ if (!run) { line(`Refused: no successful release run on a commit of master built ${target}. It may have been published from a branch.`); return 1; }
185
+ line(` built by release run ${run.id}, on master commit ${run.head_sha.slice(0, 12)}`);
186
+
187
+ const work = mkdtempSync(join(tmpdir(), 'atlas-upgrade-'));
188
+ try {
189
+ exec('npm', ['pack', `${name}@${target}`, '--pack-destination', work]);
190
+ const tarball = readdirSync(work).find((entry) => entry.endsWith('.tgz'));
191
+ if (!tarball) throw new Error('npm pack wrote no tarball');
192
+ const bytes = readFileSync(join(work, tarball));
193
+ if (sha512Integrity(bytes) !== integrity) { line('Refused: the tarball does not match the integrity npm records.'); return 1; }
194
+ const unpacked = join(work, 'unpacked');
195
+ mkdirSync(unpacked);
196
+ exec('tar', ['-xzf', join(work, tarball), '-C', unpacked]);
197
+ const files = filesUnder(join(unpacked, 'package'));
198
+ const tree = gh(['api', '--method', 'GET', `repos/${repo}/git/trees/${run.head_sha}?recursive=1`]).tree;
199
+ const problems = matchTree({ tree, files });
200
+ if (problems.length) {
201
+ line('Refused: the tarball is not the commit that run built.');
202
+ for (const problem of problems.slice(0, 20)) line(` ${problem}`);
203
+ return 1;
204
+ }
205
+ line(` every one of ${files.size} files matches that commit`);
206
+
207
+ const current = existsSync(packageRoot) ? filesUnder(packageRoot) : new Map();
208
+ const watched = /^(\.mcp\.json|\.claude-plugin\/|bin\/|door\/|work\/)/;
209
+ const changed = [...new Set([...files.keys(), ...current.keys()])].filter((path) => watched.test(path) && Buffer.compare(files.get(path) ?? Buffer.alloc(0), current.get(path) ?? Buffer.alloc(0)) !== 0).sort();
210
+ line('');
211
+ line(changed.length ? `Changed in the parts that run code or hold the key (${changed.length}):` : 'No change in the parts that run code or hold the key.');
212
+ for (const path of changed) line(` ${current.has(path) ? (files.has(path) ? 'changed' : 'removed') : 'added '} ${path}`);
213
+ line('');
214
+ if (!options.yes && !(await ask(`Install Atlas ${target}?`))) { line('Nothing was changed.'); return 1; }
215
+
216
+ exec('npm', ['install', '-g', '--ignore-scripts', join(work, tarball)]);
217
+ writeFileSync(catalogFile(env), `${JSON.stringify(catalogFor({ name, version: target }), null, 2)}\n`);
218
+ exec('claude', ['plugin', 'marketplace', 'update', CATALOG_NAME]);
219
+ exec('claude', ['plugin', 'update', PLUGIN_ID]);
220
+ line(`Installed Atlas ${target}. Restart Claude Code sessions to load it.`);
221
+ return 0;
222
+ } finally {
223
+ rmSync(work, { recursive: true, force: true });
224
+ }
225
+ }
226
+
227
+ // ---------------------------------------------------------------- doctor
228
+
229
+ export function doctor({ packageRoot, options = {}, line = console.log, env = process.env, exec = defaultExec }) {
230
+ const { version } = packageJson(packageRoot);
231
+ const rows = [];
232
+ const check = (name, pass, detail) => rows.push({ name, pass, detail });
233
+
234
+ const listed = claudePlugins(exec);
235
+ const plugin = findPlugin(listed, PLUGIN_ID);
236
+ const dev = findPlugin(listed, DEV_PLUGIN_ID);
237
+ check('plugin', Boolean(plugin), plugin ? `${PLUGIN_ID} ${plugin.version ?? '?'} is installed, scope ${plugin.scope ?? '?'}` : `${PLUGIN_ID} is not installed. Run "atlas install".`);
238
+
239
+ let pinned = null;
240
+ try { pinned = JSON.parse(readFileSync(catalogFile(env), 'utf8')).plugins?.[0]?.source ?? null; } catch { /* missing */ }
241
+ const local = typeof pinned === 'string';
242
+ check('catalog', local || (pinned?.version === version && EXACT_VERSION.test(pinned.version)),
243
+ local ? 'the catalog holds a local copy' : pinned ? `the catalog pins exactly ${pinned.version}${pinned.version === version ? '' : `, and this command is ${version}`}` : `${catalogFile(env)} is missing. Run "atlas install".`);
244
+
245
+ // Only where the dev copy is on, which is the Atlas repo, may the two differ.
246
+ if (plugin && !dev?.enabled) {
247
+ check('one version', !plugin.version || plugin.version === version, `the plugin is ${plugin.version ?? '?'} and the terminal command is ${version}`);
248
+ }
249
+
250
+ let health = null;
251
+ try { health = JSON.parse(readFileSync(join(env.HOME, '.atlas', 'health.json'), 'utf8')); } catch { /* never started */ }
252
+ check('tools server', Boolean(health), health ? `last started ${health.started_at}, Atlas ${health.package_version}` : 'the Atlas tools server has not started yet. Open a Claude Code session, then run "atlas doctor" again.');
253
+ if (health) {
254
+ check('engram', health.engram === 'answered', health.engram === 'answered' ? `Engram answered; the key expires ${health.connection_expires}` : `Engram: ${health.engram}`);
255
+ check('gh', Boolean(health.gh), health.gh ? `the server finds gh at ${health.gh}` : 'the tools server cannot find gh on its path. Install gh, or put it where the desktop app looks: /usr/local/bin.');
256
+ }
257
+
258
+ for (const row of rows) line(`${row.pass ? 'PASS' : 'FAIL'} ${row.name.padEnd(12)} ${row.detail}`);
259
+ return rows.every((row) => row.pass) ? 0 : 1;
260
+ }
@@ -0,0 +1,76 @@
1
+ // The facts helper's kind drift report.
2
+ //
3
+ // Milestone M2, slice L2 stage one: "The facts helper compares the `kind` in
4
+ // `atlas.yaml` with the `kind` on the repo record and reports drift."
5
+ //
6
+ // The kind decides which of the seven files a repository needs, so the two
7
+ // copies disagreeing is not a detail. A repo record that says `service` while
8
+ // atlas.yaml says `library` means four required parts read "not applicable" in
9
+ // CI and "required" everywhere else.
10
+ //
11
+ // Which of the two is the source is open question O-9, and the owner settles it
12
+ // before slice L2 ends. This report is neutral on that: it names both values
13
+ // and says they differ. It never picks a winner, and it never writes.
14
+ //
15
+ // Where the record comes from. Reading it live needs the Atlas work verbs and
16
+ // an Engram owner token, which CI does not have and this module does not hold.
17
+ // So the record's kind is an input. Pass one repo's kind, or the JSON the
18
+ // `registry_list` verb returns.
19
+ import { readFileSync, existsSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { parseYaml, SPECS } from '../../shape/check.mjs';
22
+
23
+ export const KINDS = Object.freeze(['service', 'library', 'schema']);
24
+
25
+ // The kind a repository's own file declares, with the repo id beside it.
26
+ export function fileKind(root) {
27
+ const path = join(root, 'atlas.yaml');
28
+ if (!existsSync(path)) return { kind: null, repo: null, error: 'atlas.yaml does not exist' };
29
+ try {
30
+ const facts = parseYaml(readFileSync(path, 'utf8'));
31
+ return { kind: facts?.kind ?? null, repo: facts?.repo ?? null, spec: facts?.spec ?? null, error: null };
32
+ } catch (error) {
33
+ // The parse error names a line and a column and never the content. That is
34
+ // the rule the whole check follows (amendment A1.6, section 4.9).
35
+ return { kind: null, repo: null, error: `atlas.yaml cannot be read: ${error.message}` };
36
+ }
37
+ }
38
+
39
+ // One repository, one verdict.
40
+ export function driftOne({ root, recordKind = null, recordRepo = null }) {
41
+ const file = fileKind(root);
42
+ const known = (kind) => kind === null || KINDS.includes(kind);
43
+ const row = {
44
+ root,
45
+ repo: file.repo ?? recordRepo ?? null,
46
+ file_kind: file.kind,
47
+ record_kind: recordKind,
48
+ spec: file.spec ?? null,
49
+ state: 'unknown',
50
+ why: '',
51
+ };
52
+ if (file.error) { row.state = 'unreadable'; row.why = file.error; return row; }
53
+ if (!known(file.kind)) { row.state = 'unreadable'; row.why = `atlas.yaml names kind "${file.kind}"; a kind is one of ${KINDS.join(', ')}`; return row; }
54
+ if (recordKind === null) { row.state = 'unknown'; row.why = 'no repo record kind was given, so there is nothing to compare with. Read it with the registry_list verb and pass it in.'; return row; }
55
+ if (!known(recordKind)) { row.state = 'unreadable'; row.why = `the repo record names kind "${recordKind}"; a kind is one of ${KINDS.join(', ')}`; return row; }
56
+ if (file.kind === null) { row.state = 'drift'; row.why = `the repo record says "${recordKind}" and atlas.yaml names no kind at all, so CI cannot tell which files this repo needs`; return row; }
57
+ if (file.kind === recordKind) { row.state = 'agree'; row.why = `both say "${file.kind}"`; return row; }
58
+ row.state = 'drift';
59
+ row.why = `atlas.yaml says "${file.kind}" and the repo record says "${recordKind}". The kind decides which parts are required, so the check in CI and the work verbs are scoring two different repos. Open question O-9 says which one is the source.`;
60
+ return row;
61
+ }
62
+
63
+ // The registry the `registry_list` verb returns, as { repoId: kind }.
64
+ export function kindsFromRegistry(registry) {
65
+ const out = {};
66
+ for (const product of registry?.products ?? []) {
67
+ for (const repo of product?.repos ?? []) if (repo?.registry_id) out[repo.registry_id] = repo.kind ?? null;
68
+ }
69
+ for (const repo of registry?.repos ?? []) if (repo?.registry_id) out[repo.registry_id] = repo.kind ?? null;
70
+ return out;
71
+ }
72
+
73
+ export function drift(rows) {
74
+ const out = rows.map(driftOne);
75
+ return { rows: out, drifting: out.filter((row) => row.state === 'drift').length, specs_read: Object.keys(SPECS).map(Number) };
76
+ }
@@ -0,0 +1,130 @@
1
+ // `atlas test onboarding`: the answer key, and the fixed script that compares.
2
+ //
3
+ // Milestone M2, slice L2 stage one, done line 5: "The fresh-agent test scores
4
+ // 6 of 6 under the isolation rule."
5
+ //
6
+ // Why this exists. The M2 criterion said a fresh agent "completes the six-step
7
+ // task list from the files alone, scored 6 of 6 by the answer key", and no
8
+ // slice built the key or the scorer. Six of six was therefore a self-grade
9
+ // (panel finding P4). This is the key, and this is the scorer.
10
+ //
11
+ // The scorer runs OUTSIDE the isolated session. The isolated session has the
12
+ // Atlas package off, so it cannot score itself even if it wanted to. It writes
13
+ // its six answers to a file; this reads that file.
14
+ import { readFileSync, existsSync } from 'node:fs';
15
+ import { resolve } from 'node:path';
16
+ import { parseYaml, secretPatterns } from '../../shape/check.mjs';
17
+
18
+
19
+ // The isolation rule, in the words of closure R3. The scorer prints it, so the
20
+ // person who runs the test sets the session up the same way every time.
21
+ export const ISOLATION = Object.freeze([
22
+ 'no memory directory',
23
+ 'no global instructions',
24
+ 'the Engram MCP disconnected',
25
+ 'the Atlas package off',
26
+ 'the repo at HEAD',
27
+ ]);
28
+
29
+ // The answer key is read from a path. It never ships in the package and never
30
+ // sits in the repository under test, because the agent under test must not be
31
+ // able to read it (amendment A1.9, the L2 stage one clause).
32
+ export function loadKey(path) {
33
+ if (!path || path === true) throw new Error('give the answer key with --key <path>. Keys stay outside the package and outside the repo under test.');
34
+ const full = resolve(String(path));
35
+ if (!existsSync(full)) throw new Error(`there is no answer key at ${full}`);
36
+ const key = parseYaml(readFileSync(full, 'utf8'));
37
+ if (!Array.isArray(key?.tasks) || !key.tasks.length) throw new Error(`${full} carries no tasks`);
38
+ return { ...key, path: full };
39
+ }
40
+
41
+ // The task list as the isolated session sees it: the questions, and nothing
42
+ // else. No sources, no expected strings.
43
+ export function questions(key) {
44
+ return key.tasks.map((task) => ({ id: task.id, number: task.number, ask: task.ask }));
45
+ }
46
+
47
+ const normalise = (text) => String(text ?? '').toLowerCase().replace(/\s+/g, ' ').trim();
48
+
49
+ // A phrase must appear as a whole word, not inside another one. Plain substring
50
+ // matching passed "the dependencies file is nonexistent" for the phrase "none",
51
+ // because "none" sits inside "nonexistent" (acceptance review of batch 16,
52
+ // finding S2-5).
53
+ //
54
+ // What this still cannot do: it compares text, not meaning. "do not run
55
+ // scripts/stack.mjs" carries the right words in the wrong order and passes.
56
+ // The frozen text asks for "a fixed script that compares", so that limit is the
57
+ // design, and it is written down here rather than left to be discovered.
58
+ const says = (said, phrase) => {
59
+ const wanted = normalise(phrase);
60
+ if (!wanted) return false;
61
+ // The boundary is a letter, a digit or an underscore. Dots, slashes, commas
62
+ // and hyphens are boundaries, so "atlas/deploy.yaml." at the end of a
63
+ // sentence still matches, and "none" inside "nonexistent" still does not.
64
+ const edge = '[^a-z0-9_]';
65
+ return new RegExp(`(?:^|${edge})${wanted.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:$|${edge})`).test(said);
66
+ };
67
+
68
+ // One answer against one task.
69
+ export function scoreTask(task, answer, patterns) {
70
+ const said = normalise(answer);
71
+ if (!said) return { id: task.id, number: task.number, pass: false, source: task.source, missing: task.must_say ?? [], why: 'no answer was given' };
72
+
73
+ // An answer that carries a credential value fails, whatever else it says.
74
+ // The file it read holds names only, so a value can only have come from
75
+ // somewhere it should not have been read from.
76
+ for (const pattern of patterns) {
77
+ if (pattern.test(String(answer ?? ''))) {
78
+ return { id: task.id, number: task.number, pass: false, source: task.source, missing: [], why: 'the answer holds something that looks like a credential value; these files carry names only' };
79
+ }
80
+ }
81
+
82
+ // The answer must cite a file. The test is whether the seven files answer on
83
+ // their own, so an answer that names no file proves nothing about them
84
+ // (acceptance review of batch 16, finding S2-5).
85
+ const cites = (task.cites ?? [task.source]).some((path) => says(said, path));
86
+ if (!cites) {
87
+ return { id: task.id, number: task.number, pass: false, source: task.source, missing: [task.source], why: `the answer names no source file; it should name ${task.source}` };
88
+ }
89
+
90
+ const anyOf = task.accept_any_of ?? [];
91
+ if (anyOf.length && anyOf.some((phrase) => says(said, phrase))) {
92
+ return { id: task.id, number: task.number, pass: true, source: task.source, missing: [], why: `the answer says what ${task.source} says, and names it` };
93
+ }
94
+ const missing = (task.must_say ?? []).filter((phrase) => !says(said, phrase));
95
+ return {
96
+ id: task.id,
97
+ number: task.number,
98
+ pass: !missing.length,
99
+ source: task.source,
100
+ missing,
101
+ why: missing.length ? `the answer does not name ${missing.join(', ')}; the file that says it is ${task.source}` : `the answer names what ${task.source} says, and names the file`,
102
+ };
103
+ }
104
+
105
+ // The whole run. `answers` is a map of task id to the answer text the isolated
106
+ // session wrote.
107
+ export function score(key, answers, { patterns = secretPatterns() } = {}) {
108
+ const rows = key.tasks.map((task) => scoreTask(task, answers?.[task.id] ?? answers?.[String(task.number)] ?? null, patterns));
109
+ const passed = rows.filter((row) => row.pass).length;
110
+ return {
111
+ repo: key.repo,
112
+ key: key.path,
113
+ isolation: ISOLATION,
114
+ rows,
115
+ passed,
116
+ of: key.tasks.length,
117
+ pass: passed >= (key.pass ?? key.tasks.length),
118
+ };
119
+ }
120
+
121
+ // The answers file the isolated session writes. YAML or JSON, one entry per
122
+ // task id. Anything the key does not name is ignored, and a task with no entry
123
+ // scores zero.
124
+ export function readAnswers(path) {
125
+ const text = readFileSync(path, 'utf8');
126
+ const data = text.trimStart().startsWith('{') ? JSON.parse(text) : parseYaml(text);
127
+ const rows = data?.answers ?? data;
128
+ if (!rows || typeof rows !== 'object' || Array.isArray(rows)) throw new Error(`${path} is not a map of task id to answer`);
129
+ return rows;
130
+ }