@arjunkhera/atlas 0.2.2 → 0.3.0
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/README.md +15 -9
- package/agents/artifact-renderer.md +7 -2
- package/agents/capability-reader.md +1 -1
- package/agents/code-explorer.md +3 -2
- package/agents/reviewer-architect.md +3 -3
- package/agents/reviewer-pm.md +4 -4
- package/agents/reviewer-security.md +11 -8
- package/agents/verifier.md +97 -0
- package/door/cli.mjs +33 -108
- package/door/lib/kind-drift.mjs +16 -6
- package/door/lib/privacy.mjs +61 -0
- package/door/lib/pull-request.mjs +55 -56
- package/door/lib/ste-words.json +51 -0
- package/door/lib/ste.mjs +199 -0
- package/door/lib/tooling.mjs +2 -2
- package/package.json +1 -1
- package/shape/check.mjs +33 -33
- package/shape/releases.json +5 -0
- package/skills/feedback/SKILL.md +39 -0
- package/skills/lead/SKILL.md +103 -0
- package/skills/learnings/SKILL.md +48 -0
- package/skills/repo-skills/SKILL.md +64 -0
- package/skills/sdlc-task/SKILL.md +62 -69
- package/skills/sdlc-task/lifecycle.yaml +8 -4
- package/skills/sdlc-task/templates/design-doc.md +23 -18
- package/skills/sdlc-task/templates/resume-anchor.md +9 -7
- package/work/lib/capability.mjs +34 -23
- package/work/lib/delivery.mjs +26 -12
- package/work/lib/questions.mjs +54 -10
- package/work/lib/verb-fields.mjs +34 -0
- package/work/lib/verbs.mjs +100 -19
- package/work/{manifest-0.5.0.json → manifest-0.6.0.json} +9 -3
- package/work/mcp.mjs +13 -10
- package/work/protected-paths.json +4 -2
- package/door/lib/helpers.mjs +0 -139
- package/door/lib/onboarding.mjs +0 -130
package/door/lib/helpers.mjs
DELETED
|
@@ -1,139 +0,0 @@
|
|
|
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
|
-
}
|
package/door/lib/onboarding.mjs
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
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
|
-
}
|