@arjunkhera/atlas 0.0.0 → 0.2.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 +16 -0
- package/.mcp.json +18 -0
- package/README.md +32 -1
- package/agents/artifact-format/example.html +71 -0
- package/agents/artifact-format/system.css +273 -0
- package/agents/artifact-renderer.md +132 -0
- package/agents/capability-reader.md +100 -0
- package/agents/code-explorer.md +108 -0
- package/agents/reviewer-architect.md +78 -0
- package/agents/reviewer-pm.md +81 -0
- package/agents/reviewer-security.md +82 -0
- package/bin/atlas +5 -0
- package/door/cli.mjs +280 -0
- package/door/lib/helpers.mjs +139 -0
- package/door/lib/install.mjs +260 -0
- package/door/lib/kind-drift.mjs +76 -0
- package/door/lib/onboarding.mjs +130 -0
- package/door/lib/pull-request.mjs +115 -0
- package/door/lib/releases.mjs +42 -0
- package/door/lib/status.mjs +47 -0
- package/door/lib/tooling.mjs +119 -0
- package/package.json +24 -3
- package/shape/check.mjs +1797 -0
- package/shape/releases.json +15 -0
- package/skills/sdlc-task/SKILL.md +174 -0
- package/skills/sdlc-task/lifecycle.yaml +27 -0
- package/skills/sdlc-task/templates/design-doc.md +118 -0
- package/skills/sdlc-task/templates/resume-anchor.md +25 -0
- package/work/code-read.mjs +65 -0
- package/work/lib/action-store.mjs +84 -0
- package/work/lib/capability.mjs +487 -0
- package/work/lib/child-env.mjs +11 -0
- package/work/lib/client.mjs +214 -0
- package/work/lib/code-read.mjs +238 -0
- package/work/lib/delivery.mjs +624 -0
- package/work/lib/mcp-stdio.mjs +68 -0
- package/work/lib/questions.mjs +278 -0
- package/work/lib/sweep.mjs +189 -0
- package/work/lib/verb-fields.mjs +53 -0
- package/work/lib/verbs.mjs +511 -0
- package/work/manifest-0.5.0.json +609 -0
- package/work/mcp.mjs +216 -0
- package/work/protected-paths.json +38 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
const DEFAULT_PAGE_SIZE = 200;
|
|
2
|
+
const SEARCH_LIMIT = 50;
|
|
3
|
+
const MAX_PAGES = 100;
|
|
4
|
+
const MAX_ROWS = 20_000;
|
|
5
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
6
|
+
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
|
|
7
|
+
const MAX_SEARCH_REQUESTS = 64;
|
|
8
|
+
const MAX_SEARCH_DEPTH = 24;
|
|
9
|
+
|
|
10
|
+
export class EngramHttpError extends Error {
|
|
11
|
+
constructor(message, { status, method, path, body } = {}) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'EngramHttpError';
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.method = method;
|
|
16
|
+
this.path = path;
|
|
17
|
+
this.body = body;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function cleanBaseUrl(value) {
|
|
22
|
+
const url = new URL(value);
|
|
23
|
+
const loopback = url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1';
|
|
24
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) throw new TypeError('baseUrl must use https, except for loopback development');
|
|
25
|
+
if (url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new TypeError('baseUrl must contain only the service origin');
|
|
26
|
+
return url.origin;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function redact(value, token) {
|
|
30
|
+
let text = String(value ?? '');
|
|
31
|
+
if (token) text = text.split(token).join('[REDACTED]');
|
|
32
|
+
return text.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function queryPath(path, query = {}) {
|
|
36
|
+
const search = new URLSearchParams();
|
|
37
|
+
for (const [key, value] of Object.entries(query)) {
|
|
38
|
+
if (value !== undefined && value !== null && value !== '') search.set(key, String(value));
|
|
39
|
+
}
|
|
40
|
+
const suffix = search.toString();
|
|
41
|
+
return suffix ? `${path}?${suffix}` : path;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createEngramClient({ baseUrl, token, fetchImpl = globalThis.fetch } = {}) {
|
|
45
|
+
if (typeof token !== 'string' || token.length === 0) throw new TypeError('token is required');
|
|
46
|
+
if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
|
|
47
|
+
const origin = cleanBaseUrl(baseUrl);
|
|
48
|
+
|
|
49
|
+
async function request(method, path, { body, query } = {}) {
|
|
50
|
+
const relative = queryPath(path, query);
|
|
51
|
+
let response;
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
54
|
+
try {
|
|
55
|
+
response = await fetchImpl(`${origin}${relative}`, {
|
|
56
|
+
method,
|
|
57
|
+
redirect: 'error',
|
|
58
|
+
signal: controller.signal,
|
|
59
|
+
headers: {
|
|
60
|
+
accept: 'application/json',
|
|
61
|
+
authorization: `Bearer ${token}`,
|
|
62
|
+
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
63
|
+
},
|
|
64
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
65
|
+
});
|
|
66
|
+
} catch (error) {
|
|
67
|
+
throw new EngramHttpError(`Engram ${method} ${relative} failed: ${redact(error?.message, token)}`, {
|
|
68
|
+
method, path: relative,
|
|
69
|
+
});
|
|
70
|
+
} finally {
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let bodyTimer;
|
|
75
|
+
let text;
|
|
76
|
+
try {
|
|
77
|
+
text = await Promise.race([
|
|
78
|
+
response.text(),
|
|
79
|
+
new Promise((_, reject) => {
|
|
80
|
+
bodyTimer = setTimeout(() => {
|
|
81
|
+
controller.abort();
|
|
82
|
+
reject(new EngramHttpError(`Engram ${method} ${relative} response timed out`, { status: response.status, method, path: relative }));
|
|
83
|
+
}, REQUEST_TIMEOUT_MS);
|
|
84
|
+
}),
|
|
85
|
+
]);
|
|
86
|
+
} finally {
|
|
87
|
+
clearTimeout(bodyTimer);
|
|
88
|
+
}
|
|
89
|
+
if (new TextEncoder().encode(text).byteLength > MAX_RESPONSE_BYTES) {
|
|
90
|
+
throw new EngramHttpError(`Engram ${method} ${relative} response exceeded ${MAX_RESPONSE_BYTES} bytes`, { status: response.status, method, path: relative });
|
|
91
|
+
}
|
|
92
|
+
let payload = null;
|
|
93
|
+
if (text) {
|
|
94
|
+
try { payload = JSON.parse(text); } catch { payload = text; }
|
|
95
|
+
}
|
|
96
|
+
if (!response.ok) {
|
|
97
|
+
const serverMessage = payload && typeof payload === 'object'
|
|
98
|
+
? payload.message ?? payload.error ?? response.statusText
|
|
99
|
+
: payload ?? response.statusText;
|
|
100
|
+
throw new EngramHttpError(
|
|
101
|
+
`Engram ${method} ${relative} returned ${response.status}: ${redact(serverMessage, token)}`,
|
|
102
|
+
{ status: response.status, method, path: relative },
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return payload;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const getEntity = (id) => request('GET', `/v1/entities/${encodeURIComponent(id)}`);
|
|
109
|
+
const listEntities = (query = {}) => request('GET', '/v1/entities', { query });
|
|
110
|
+
const searchEntities = (query) => request('GET', '/v1/search', { query });
|
|
111
|
+
const listEdges = (id, query = {}) => request('GET', `/v1/entities/${encodeURIComponent(id)}/edges`, { query });
|
|
112
|
+
|
|
113
|
+
async function collectOffsetPages(load, key, query, maximum) {
|
|
114
|
+
const result = [];
|
|
115
|
+
let offset = 0;
|
|
116
|
+
for (let pageNumber = 0; pageNumber < MAX_PAGES; pageNumber += 1) {
|
|
117
|
+
const page = await load({ ...query, limit: maximum, offset });
|
|
118
|
+
if (!page || typeof page !== 'object' || !Array.isArray(page[key]) || !Number.isFinite(Number(page.total))) {
|
|
119
|
+
throw new Error(`Engram returned an invalid ${key} page`);
|
|
120
|
+
}
|
|
121
|
+
const rows = page[key];
|
|
122
|
+
result.push(...rows);
|
|
123
|
+
if (result.length > MAX_ROWS) throw new Error(`${key} pagination exceeded the ${MAX_ROWS}-row safety bound`);
|
|
124
|
+
const total = Number(page?.total);
|
|
125
|
+
if (!Number.isFinite(total) || result.length >= total) return result;
|
|
126
|
+
if (rows.length === 0) throw new Error(`${key} pagination stopped before reported total`);
|
|
127
|
+
offset += rows.length;
|
|
128
|
+
}
|
|
129
|
+
throw new Error(`${key} pagination exceeded the ${MAX_PAGES}-page safety bound`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const listAllEntities = (query = {}) => collectOffsetPages(listEntities, 'entities', query, DEFAULT_PAGE_SIZE);
|
|
133
|
+
const listAllEdges = (id, query = {}) => collectOffsetPages((q) => listEdges(id, q), 'edges', query, 100);
|
|
134
|
+
|
|
135
|
+
async function searchAllEntities(query) {
|
|
136
|
+
const normalized = { ...query };
|
|
137
|
+
if (normalized.q === undefined && normalized.query !== undefined) normalized.q = normalized.query;
|
|
138
|
+
delete normalized.query;
|
|
139
|
+
let searchRequests = 0;
|
|
140
|
+
const hitById = new Map();
|
|
141
|
+
|
|
142
|
+
function readPage(page) {
|
|
143
|
+
if (!page || typeof page !== 'object' || !Array.isArray(page.results)
|
|
144
|
+
|| !Number.isFinite(Number(page.total)) || !Number.isFinite(Number(page.total_candidates))) {
|
|
145
|
+
throw new Error('Engram returned an invalid search page');
|
|
146
|
+
}
|
|
147
|
+
for (const hit of page.results) {
|
|
148
|
+
if (!hit || typeof hit.id !== 'string' || !hit.id) throw new Error('Engram returned a search hit without an id');
|
|
149
|
+
hitById.set(hit.id, hit);
|
|
150
|
+
}
|
|
151
|
+
return page.results.length < SEARCH_LIMIT && Number(page.total_candidates) <= page.results.length;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function searchWindow(window, inventory, depth) {
|
|
155
|
+
if (depth > MAX_SEARCH_DEPTH || ++searchRequests > MAX_SEARCH_REQUESTS) {
|
|
156
|
+
throw new Error('Engram search partition exceeded its safety bound');
|
|
157
|
+
}
|
|
158
|
+
const page = await searchEntities({ ...normalized, ...window, limit: SEARCH_LIMIT });
|
|
159
|
+
if (readPage(page)) return;
|
|
160
|
+
if (inventory.length < 2) throw new Error('Engram search result is incomplete and its date window cannot be split');
|
|
161
|
+
|
|
162
|
+
const times = inventory.map((entity) => Date.parse(entity.created_at)).sort((a, b) => a - b);
|
|
163
|
+
if (times.some((time) => !Number.isFinite(time))) throw new Error('Engram search inventory contains an invalid created_at');
|
|
164
|
+
const candidates = [...new Set(times)].map((pivot) => {
|
|
165
|
+
const left = inventory.filter((entity) => Date.parse(entity.created_at) < pivot + 1);
|
|
166
|
+
const right = inventory.filter((entity) => Date.parse(entity.created_at) > pivot - 1);
|
|
167
|
+
return { pivot, left, right, size: Math.max(left.length, right.length) };
|
|
168
|
+
}).filter(({ left, right }) => left.length < inventory.length && right.length < inventory.length);
|
|
169
|
+
candidates.sort((a, b) => a.size - b.size);
|
|
170
|
+
const split = candidates[0];
|
|
171
|
+
if (!split) throw new Error('Engram search result is incomplete and entities share an unsplittable timestamp');
|
|
172
|
+
|
|
173
|
+
const leftBefore = new Date(split.pivot + 1).toISOString();
|
|
174
|
+
const rightAfter = new Date(split.pivot - 1).toISOString();
|
|
175
|
+
await searchWindow({ ...window, before: leftBefore }, split.left, depth + 1);
|
|
176
|
+
await searchWindow({ ...window, after: rightAfter }, split.right, depth + 1);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const first = await searchEntities({ ...normalized, limit: SEARCH_LIMIT });
|
|
180
|
+
searchRequests += 1;
|
|
181
|
+
if (!readPage(first)) {
|
|
182
|
+
const inventoryQuery = { type: normalized.type, state: normalized.state ?? 'active' };
|
|
183
|
+
const inventory = (await listAllEntities(inventoryQuery)).filter((entity) => {
|
|
184
|
+
const created = Date.parse(entity.created_at);
|
|
185
|
+
return Number.isFinite(created)
|
|
186
|
+
&& (normalized.after === undefined || created > Date.parse(normalized.after))
|
|
187
|
+
&& (normalized.before === undefined || created < Date.parse(normalized.before));
|
|
188
|
+
});
|
|
189
|
+
await searchWindow({}, inventory, 1);
|
|
190
|
+
}
|
|
191
|
+
return Promise.all([...hitById.keys()].map((id) => getEntity(id)));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return Object.freeze({
|
|
195
|
+
request,
|
|
196
|
+
getEntity,
|
|
197
|
+
listEntities,
|
|
198
|
+
listAllEntities,
|
|
199
|
+
searchEntities,
|
|
200
|
+
searchAllEntities,
|
|
201
|
+
listEdges,
|
|
202
|
+
listAllEdges,
|
|
203
|
+
listTypes: () => request('GET', '/v1/types'),
|
|
204
|
+
getPublisher: () => request('GET', '/v1/forge/publisher'),
|
|
205
|
+
getForgeArtifact: (handle, name) => request('GET', `/v1/forge/artifacts/${encodeURIComponent(handle)}/${encodeURIComponent(name)}`),
|
|
206
|
+
getForgeVersion: (handle, name, version) => request('GET', `/v1/forge/artifacts/${encodeURIComponent(handle)}/${encodeURIComponent(name)}/versions/${encodeURIComponent(version)}`),
|
|
207
|
+
createForgeArtifact: (body) => request('POST', '/v1/forge/artifacts', { body }),
|
|
208
|
+
publishForgeVersion: (handle, name, body) => request('POST', `/v1/forge/artifacts/${encodeURIComponent(handle)}/${encodeURIComponent(name)}/versions`, { body }),
|
|
209
|
+
registerTypes: (handle, name, version) => request('POST', `/v1/forge/artifacts/${encodeURIComponent(handle)}/${encodeURIComponent(name)}/versions/${encodeURIComponent(version)}/register-types`),
|
|
210
|
+
createEntity: (body) => request('POST', '/v1/entities', { body }),
|
|
211
|
+
updateEntity: (id, body) => request('PATCH', `/v1/entities/${encodeURIComponent(id)}`, { body }),
|
|
212
|
+
createEdge: (body) => request('POST', '/v1/edges', { body }),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// Atlas M1 slice L4: the code read by sub-agents.
|
|
2
|
+
// Design: the Atlas M1 study, page four (the code half
|
|
3
|
+
// of "where are we with X"), D3 rule 4, D6 finding R12, and the frozen L4
|
|
4
|
+
// paragraph of the lock block.
|
|
5
|
+
//
|
|
6
|
+
// Three rules hold this file together:
|
|
7
|
+
// 1. Every claim cites a path and a line. A finding with no citation is
|
|
8
|
+
// refused, so no claim can enter an answer without a place to check it.
|
|
9
|
+
// 2. The never-read paths are refused wherever this code can see them: in
|
|
10
|
+
// the brief the sub-agent is given, in the digest it returns, and in the
|
|
11
|
+
// resolver that opens files afterwards.
|
|
12
|
+
// 3. Nothing from a never-quoted path is ever pasted, and every string is
|
|
13
|
+
// scrubbed by pattern before it reaches a record, a view or an artifact.
|
|
14
|
+
import protectedPaths from '../protected-paths.json' with { type: 'json' };
|
|
15
|
+
import { refuse, text, stringList, sha256 } from './verb-fields.mjs';
|
|
16
|
+
import { scrubSecrets } from './questions.mjs';
|
|
17
|
+
|
|
18
|
+
export const NEVER_READ = Object.freeze([...protectedPaths.never_read]);
|
|
19
|
+
export const NEVER_QUOTE = Object.freeze([...protectedPaths.never_quote]);
|
|
20
|
+
export const LIMIT_KINDS = Object.freeze(['cap', 'flag', 'skipped', 'untested', 'other']);
|
|
21
|
+
|
|
22
|
+
const MAX_FILES_READ = 400;
|
|
23
|
+
const MAX_FINDINGS = 64;
|
|
24
|
+
const MAX_UNKNOWNS = 32;
|
|
25
|
+
const MAX_STATEMENT = 1000;
|
|
26
|
+
const MAX_QUOTE = 400;
|
|
27
|
+
const MAX_LINE = 2_000_000;
|
|
28
|
+
const COMMIT = /^[0-9a-f]{7,40}$/;
|
|
29
|
+
|
|
30
|
+
// One repo-relative path, as a citation may spell it. An absolute path, a
|
|
31
|
+
// parent step or a URL is refused: a citation must name a file inside the repo
|
|
32
|
+
// it claims to have read, or a reviewer cannot resolve it.
|
|
33
|
+
export function repoPath(value, name = 'path') {
|
|
34
|
+
const raw = text(value, name, { max: 400 }).trim().replace(/\\/g, '/');
|
|
35
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(raw)) refuse(`${name} must be a path inside the repo, not a URL: ${raw}`);
|
|
36
|
+
if (raw.startsWith('/')) refuse(`${name} must be relative to the repo root, not absolute: ${raw}`);
|
|
37
|
+
const parts = raw.split('/').filter((part) => part !== '' && part !== '.');
|
|
38
|
+
if (!parts.length) refuse(`${name} must name a file in the repo`);
|
|
39
|
+
if (parts.includes('..')) refuse(`${name} must stay inside the repo: ${raw}`);
|
|
40
|
+
return parts.join('/');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const quote = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
44
|
+
const globSource = (part) => part.split('*').map(quote).join('[^/]*');
|
|
45
|
+
const globPattern = (rule) => new RegExp(`^${globSource(rule)}$`);
|
|
46
|
+
|
|
47
|
+
// A rule matches a path when it names the whole path, a directory on it, or a
|
|
48
|
+
// file on it. "deploy/" catches deploy/app.ts at any depth; ".env*" catches
|
|
49
|
+
// .env.local wherever it sits; "docs/references" catches the directory and
|
|
50
|
+
// everything under it; "test/*.integration.test.ts" catches one under test/ at
|
|
51
|
+
// any depth.
|
|
52
|
+
//
|
|
53
|
+
// The comparison is case-insensitive, and deliberately. This Mac's filesystem
|
|
54
|
+
// opens `.CLAUDE/settings.json` and `.claude/settings.json` as one file, so a
|
|
55
|
+
// case-exact rule would let a capital letter walk a never-read path past the
|
|
56
|
+
// door (review H1).
|
|
57
|
+
function ruleMatches(rule, path) {
|
|
58
|
+
const lower = path.toLowerCase();
|
|
59
|
+
const segments = lower.split('/');
|
|
60
|
+
const bare = (rule.endsWith('/') ? rule.slice(0, -1) : rule).toLowerCase();
|
|
61
|
+
if (!bare.includes('/') && bare.includes('*')) {
|
|
62
|
+
const pattern = globPattern(bare);
|
|
63
|
+
return pattern.test(lower) || segments.some((segment) => pattern.test(segment));
|
|
64
|
+
}
|
|
65
|
+
if (bare.includes('/')) {
|
|
66
|
+
// A multi-part rule matches from the repo root or from any directory on the
|
|
67
|
+
// path, so a nested package cannot hide a never-quoted file. Its last part
|
|
68
|
+
// may also sit deeper: test/sub/api.integration.test.ts is an integration
|
|
69
|
+
// test, whatever directory holds it (review H1).
|
|
70
|
+
const parts = bare.split('/');
|
|
71
|
+
const head = parts.slice(0, -1).map(globSource).join('/');
|
|
72
|
+
const tail = globSource(parts.at(-1));
|
|
73
|
+
return new RegExp(`(?:^|/)${head}/(?:[^/]+/)*${tail}(?:/|$)`).test(lower);
|
|
74
|
+
}
|
|
75
|
+
return segments.includes(bare);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const neverReadRule = (path) => NEVER_READ.find((rule) => ruleMatches(rule, path)) ?? null;
|
|
79
|
+
export const neverQuoteRule = (path) => NEVER_QUOTE.find((rule) => ruleMatches(rule, path)) ?? null;
|
|
80
|
+
|
|
81
|
+
export function requireReadable(path, name = 'path') {
|
|
82
|
+
const clean = repoPath(path, name);
|
|
83
|
+
const rule = neverReadRule(clean);
|
|
84
|
+
if (rule) refuse(`${name} ${clean} is a never-read path (rule "${rule}"); the code read must not open it, and no answer may cite it`);
|
|
85
|
+
return clean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The brief a sub-agent is given for one repo. The rules live here, not in each
|
|
89
|
+
// session's prose, so every code read is briefed the same way and the evidence
|
|
90
|
+
// shows exactly what the reader was told (D6 R3, R12).
|
|
91
|
+
export function codeReadBrief({ capability, repo, commit, root, focus = [], defaultBranch } = {}) {
|
|
92
|
+
const name = text(capability, 'capability', { max: 200 });
|
|
93
|
+
const repoId = text(repo, 'repo', { max: 200 });
|
|
94
|
+
const where = root ? `worktree: ${root}` : `checkout of ${repoId} at the tip of ${defaultBranch || 'the default branch'}`;
|
|
95
|
+
const lines = [
|
|
96
|
+
`# Code read — ${name} in ${repoId}`,
|
|
97
|
+
'',
|
|
98
|
+
where,
|
|
99
|
+
...(commit ? [`commit: ${commit}`] : []),
|
|
100
|
+
'',
|
|
101
|
+
'## What to return',
|
|
102
|
+
'',
|
|
103
|
+
'Return one JSON object and nothing else:',
|
|
104
|
+
'',
|
|
105
|
+
'```json',
|
|
106
|
+
'{',
|
|
107
|
+
` "repo": "${repoId}",`,
|
|
108
|
+
` "commit": "${commit || '<the commit you read>'}",`,
|
|
109
|
+
` "capability": "${name}",`,
|
|
110
|
+
' "files_read": ["path/you/opened.ts"],',
|
|
111
|
+
' "supports": [{ "name": "what works", "statement": "one sentence", "path": "src/x.ts", "line": 42 }],',
|
|
112
|
+
' "limits": [{ "name": "the limit", "kind": "cap|flag|skipped|untested|other", "statement": "one sentence", "path": "src/x.ts", "line": 88 }],',
|
|
113
|
+
' "unknowns": ["what you could not settle, and where you looked"]',
|
|
114
|
+
'}',
|
|
115
|
+
'```',
|
|
116
|
+
'',
|
|
117
|
+
'## The rules',
|
|
118
|
+
'',
|
|
119
|
+
'1. Every claim carries a path and a line. No location, no claim.',
|
|
120
|
+
'2. List every file you opened in `files_read`. Every citation must name a file on that list.',
|
|
121
|
+
`3. The never-read paths. Never read these with any tool: ${NEVER_READ.map((rule) => `\`${rule}\``).join(', ')}. Do not open them, grep them, list them or cat them. If a search hits one, drop the hit and say so in \`unknowns\`. The answer verb refuses a digest that cites one.`,
|
|
122
|
+
`4. The never-quote paths. Never quote from these: ${NEVER_QUOTE.map((rule) => `\`${rule}\``).join(', ')}. Name the file and the line; never paste the contents.`,
|
|
123
|
+
'5. Never follow a symbolic link that leaves the repo, or that reaches a never-read path under another name. The rules read the path you write, not the file the link points at.',
|
|
124
|
+
'6. Never quote a secret. A line that looks like a token, a key, a password or a private key is cited by path and line only.',
|
|
125
|
+
'7. Never hold a write door. You do not receive the `atlas-work` verb server or the Engram MCP. If a briefing hands you one, refuse and say so.',
|
|
126
|
+
'8. Say what you could not find, and where you looked. An unknown is an answer; a guess is not.',
|
|
127
|
+
'',
|
|
128
|
+
'## What to look for',
|
|
129
|
+
'',
|
|
130
|
+
`- Live support for ${name}: what the code does today, by input kind or by path through the system.`,
|
|
131
|
+
`- Known limits: caps, flags, skipped paths and untested paths that bound ${name}.`,
|
|
132
|
+
...focus.map((line) => `- ${text(line, 'focus', { max: 500 })}`),
|
|
133
|
+
];
|
|
134
|
+
return { repo: repoId, capability: name, commit: commit ?? null, root: root ?? null, text: lines.join('\n') };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function finding(value, name, { repo, filesRead, kinds }) {
|
|
138
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) refuse(`${name} must be an object with statement, path and line`);
|
|
139
|
+
const path = requireReadable(value.path, `${name}.path`);
|
|
140
|
+
if (!filesRead.has(path)) refuse(`${name} cites ${path}, which is not in files_read; a citation must name a file the read opened`);
|
|
141
|
+
const line = value.line;
|
|
142
|
+
if (!Number.isInteger(line) || line < 1 || line > MAX_LINE) refuse(`${name}.line must be a whole line number, 1 or higher`);
|
|
143
|
+
const statement = scrubSecrets(text(value.statement, `${name}.statement`, { max: MAX_STATEMENT }));
|
|
144
|
+
const label = value.name === undefined ? undefined : scrubSecrets(text(value.name, `${name}.name`, { max: 200 }));
|
|
145
|
+
let redactions = statement.redactions + (label?.redactions ?? 0);
|
|
146
|
+
const row = { name: label?.text ?? statement.text.slice(0, 80), statement: statement.text, repo, path, line };
|
|
147
|
+
if (kinds) row.kind = value.kind === undefined ? 'other' : (kinds.includes(value.kind) ? value.kind : refuse(`${name}.kind must be one of: ${kinds.join(', ')}`));
|
|
148
|
+
if (value.quote !== undefined) {
|
|
149
|
+
const rule = neverQuoteRule(path);
|
|
150
|
+
// The rule is not "strip the quote and carry on": a reader who pasted a
|
|
151
|
+
// never-quoted file must know the read was wrong, not have it silently
|
|
152
|
+
// half-accepted.
|
|
153
|
+
if (rule) refuse(`${name} quotes ${path}, which is a never-quote path (rule "${rule}"); cite the path and the line, never the contents`);
|
|
154
|
+
const quote = scrubSecrets(text(value.quote, `${name}.quote`, { max: MAX_QUOTE }).replace(/\s+/g, ' ').trim());
|
|
155
|
+
redactions += quote.redactions;
|
|
156
|
+
row.quote = quote.text;
|
|
157
|
+
}
|
|
158
|
+
return { row, redactions };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// One digest from one sub-agent, checked before any part of it reaches a view.
|
|
162
|
+
// The repo must be one the registry knows for this product: a read of a repo
|
|
163
|
+
// the owner never registered is not evidence about this product.
|
|
164
|
+
export function acceptCodeRead(value, { repos, name = 'code' } = {}) {
|
|
165
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) refuse(`${name} must be an object: the digest one sub-agent returned`);
|
|
166
|
+
const repo = text(value.repo, `${name}.repo`, { max: 200 });
|
|
167
|
+
if (repos && !repos.includes(repo)) refuse(`${name}.repo ${repo} is not a registered repo of this product. Known: ${repos.join(', ') || 'none'}`);
|
|
168
|
+
const commit = text(value.commit, `${name}.commit`, { max: 40 }).trim().toLowerCase();
|
|
169
|
+
if (!COMMIT.test(commit)) refuse(`${name}.commit must be the commit the read ran against, 7 to 40 hex characters`);
|
|
170
|
+
const files = stringList(value.files_read, `${name}.files_read`, { max: MAX_FILES_READ }).map((path) => requireReadable(path, `${name}.files_read`));
|
|
171
|
+
if (!files.length) refuse(`${name}.files_read must name at least one file the read opened`);
|
|
172
|
+
const filesRead = new Set(files);
|
|
173
|
+
const context = { repo, filesRead };
|
|
174
|
+
let redactions = 0;
|
|
175
|
+
const take = (list, key, kinds) => {
|
|
176
|
+
if (list === undefined) return [];
|
|
177
|
+
if (!Array.isArray(list)) refuse(`${name}.${key} must be an array`);
|
|
178
|
+
if (list.length > MAX_FINDINGS) refuse(`${name}.${key} holds more than ${MAX_FINDINGS} findings; narrow the read`);
|
|
179
|
+
return list.map((entry, index) => {
|
|
180
|
+
const checked = finding(entry, `${name}.${key}[${index}]`, { ...context, kinds });
|
|
181
|
+
redactions += checked.redactions;
|
|
182
|
+
return checked.row;
|
|
183
|
+
});
|
|
184
|
+
};
|
|
185
|
+
const supports = take(value.supports, 'supports');
|
|
186
|
+
const limits = take(value.limits, 'limits', LIMIT_KINDS);
|
|
187
|
+
if (!supports.length && !limits.length) refuse(`${name} carries no finding; a code read with nothing to say is not evidence`);
|
|
188
|
+
const unknowns = (value.unknowns === undefined ? [] : stringList(value.unknowns, `${name}.unknowns`, { max: MAX_UNKNOWNS })).map((line) => {
|
|
189
|
+
const clean = scrubSecrets(text(line, `${name}.unknowns`, { max: MAX_STATEMENT }));
|
|
190
|
+
redactions += clean.redactions;
|
|
191
|
+
return clean.text;
|
|
192
|
+
});
|
|
193
|
+
const readBy = value.read_by === undefined ? null : text(value.read_by, `${name}.read_by`, { max: 200 });
|
|
194
|
+
return {
|
|
195
|
+
digest: {
|
|
196
|
+
repo, commit, capability: value.capability === undefined ? null : text(value.capability, `${name}.capability`, { max: 200 }),
|
|
197
|
+
files_read: files, supports, limits, unknowns, read_by: readBy,
|
|
198
|
+
citation: { repo, commit, files: files.length },
|
|
199
|
+
},
|
|
200
|
+
redactions,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// The reviewer's half of the citation rule: every path and line the answer
|
|
205
|
+
// carries is opened in the named commit and checked. A never-read path is
|
|
206
|
+
// never opened, and a never-quoted path gives back a digest of its line, not
|
|
207
|
+
// its text, so resolving a citation can never leak what quoting may not.
|
|
208
|
+
export async function resolveCitations({ digest, readFileAt }) {
|
|
209
|
+
if (typeof readFileAt !== 'function') throw new TypeError('readFileAt(commit, path) is required');
|
|
210
|
+
const rows = [...(digest.supports ?? []).map((row) => ({ ...row, part: 'support' })), ...(digest.limits ?? []).map((row) => ({ ...row, part: 'limit' }))];
|
|
211
|
+
const cache = new Map();
|
|
212
|
+
const resolved = [];
|
|
213
|
+
for (const row of rows) {
|
|
214
|
+
const path = requireReadable(row.path, 'citation.path');
|
|
215
|
+
const key = `${digest.commit}:${path}`;
|
|
216
|
+
if (!cache.has(key)) cache.set(key, await readFileAt(digest.commit, path).catch((error) => ({ error: error?.message ?? String(error) })));
|
|
217
|
+
const file = await cache.get(key);
|
|
218
|
+
const base = { part: row.part, name: row.name, repo: digest.repo, commit: digest.commit, path, line: row.line };
|
|
219
|
+
if (!file || typeof file !== 'string') { resolved.push({ ...base, resolved: false, reason: `the file is not in commit ${digest.commit}: ${file?.error ?? 'unreadable'}` }); continue; }
|
|
220
|
+
const lines = file.split('\n');
|
|
221
|
+
if (row.line > lines.length) { resolved.push({ ...base, resolved: false, reason: `the file has ${lines.length} lines; the citation names line ${row.line}` }); continue; }
|
|
222
|
+
const content = lines[row.line - 1] ?? '';
|
|
223
|
+
const quotable = !neverQuoteRule(path);
|
|
224
|
+
const clean = scrubSecrets(content.trim());
|
|
225
|
+
resolved.push({
|
|
226
|
+
...base, resolved: true, lines: lines.length,
|
|
227
|
+
...(quotable ? { text: clean.text.slice(0, 200), redactions: clean.redactions } : { text: null, withheld: 'never-quote path; the line is proven by its digest', sha256: sha256(content), length: content.length }),
|
|
228
|
+
...(row.quote ? { quote_matches: content.replace(/\s+/g, ' ').trim().includes(row.quote.replace(/\s+/g, ' ').trim()) } : {}),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
const failed = resolved.filter((row) => !row.resolved);
|
|
232
|
+
const mismatched = resolved.filter((row) => row.quote_matches === false);
|
|
233
|
+
return {
|
|
234
|
+
repo: digest.repo, commit: digest.commit, citations: resolved.length,
|
|
235
|
+
resolved: resolved.length - failed.length, failed: failed.length, quote_mismatches: mismatched.length,
|
|
236
|
+
pass: failed.length === 0 && mismatched.length === 0, rows: resolved,
|
|
237
|
+
};
|
|
238
|
+
}
|