@renzrollon/interlock 1.4.2 → 1.5.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.
Files changed (39) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/CHANGELOG.md +41 -0
  3. package/README.md +28 -3
  4. package/bin/interlock +16 -5
  5. package/lib/graph/cli.mjs +93 -15
  6. package/lib/graph/extract/bicep.mjs +67 -0
  7. package/lib/graph/extract/create-extractor.mjs +15 -0
  8. package/lib/graph/extract/csharp.mjs +206 -0
  9. package/lib/graph/extract/csproj.mjs +42 -0
  10. package/lib/graph/extract/memory.mjs +9 -4
  11. package/lib/graph/extract/openspec.mjs +9 -5
  12. package/lib/graph/extract/walk.mjs +37 -12
  13. package/lib/graph/graph/build.mjs +77 -13
  14. package/lib/graph/graph/link-csharp.mjs +210 -0
  15. package/lib/graph/graph/query.mjs +2 -0
  16. package/lib/graph/graph/report.mjs +11 -1
  17. package/lib/graph/graph/update.mjs +56 -16
  18. package/lib/graph/paths.mjs +56 -4
  19. package/lib/graph/retrieve/context.mjs +2 -0
  20. package/lib/graph/retrieve/docs-index.mjs +9 -10
  21. package/lib/graph/retrieve/markdown.mjs +8 -8
  22. package/lib/limits.mjs +11 -3
  23. package/lib/outcomes.mjs +8 -1
  24. package/lib/prompts/implementer.mjs +28 -3
  25. package/lib/prompts/index.mjs +37 -1
  26. package/lib/prompts/rejected.mjs +24 -0
  27. package/lib/prompts/review.mjs +38 -1
  28. package/lib/receipt.mjs +21 -1
  29. package/lib/resume-card.mjs +19 -0
  30. package/lib/run-log.mjs +30 -2
  31. package/lib/run.mjs +198 -25
  32. package/lib/verify.mjs +32 -1
  33. package/lib/waves.mjs +161 -19
  34. package/package.json +2 -2
  35. package/shared/TEST-PROFILE.md +1 -1
  36. package/skills/bootstrap/SKILL.md +11 -4
  37. package/skills/fix-tests/SKILL.md +1 -0
  38. package/skills/graph/SKILL.md +12 -1
  39. package/workflows/ship.js +2 -1
@@ -0,0 +1,210 @@
1
+ // C# is linked in a pass after the per-file loop: a `using` names a namespace, and a
2
+ // namespace is declared across many files, so it cannot be resolved against a set of
3
+ // paths. This module is pure (no `node:` import) -- it takes every `extractCsharp`
4
+ // record and returns `{ nodes, edges }`, the shape the OpenSpec and memory overlays
5
+ // already return.
6
+
7
+ const INFERRED_REFERENCE_WEIGHT = 0.5;
8
+
9
+ function nameOf(entry) {
10
+ return typeof entry === 'string' ? entry : entry?.name;
11
+ }
12
+
13
+ function lineOf(entry, fallback = 1) {
14
+ const line = entry && typeof entry === 'object' ? entry.line : undefined;
15
+ return Number.isInteger(line) && line > 0 ? line : fallback;
16
+ }
17
+
18
+ function ascending(lines) {
19
+ if (!Array.isArray(lines)) return [];
20
+ return lines.filter((n) => Number.isInteger(n)).sort((a, b) => a - b);
21
+ }
22
+
23
+ /** `A.B.C` -> ['A.B.C', 'A.B', 'A'] */
24
+ function dotPrefixes(name) {
25
+ const parts = name.split('.');
26
+ const out = [];
27
+ for (let i = parts.length; i > 0; i -= 1) out.push(parts.slice(0, i).join('.'));
28
+ return out;
29
+ }
30
+
31
+ function compare(a, b) {
32
+ return a < b ? -1 : a > b ? 1 : 0;
33
+ }
34
+
35
+ /**
36
+ * Link C# records into namespace nodes, test nodes and their edges.
37
+ *
38
+ * @param {Array<{filePath: string, csharp?: object}>} records `extractCsharp` results
39
+ * @returns {{ nodes: object[], edges: object[] }}
40
+ */
41
+ export function linkCsharp(records) {
42
+ const files = (Array.isArray(records) ? records : [])
43
+ .filter((r) => r && typeof r.filePath === 'string' && r.csharp)
44
+ .sort((a, b) => compare(a.filePath, b.filePath));
45
+
46
+ const nodes = [];
47
+ const edges = [];
48
+ const nodeIds = new Set();
49
+ const edgeKeys = new Set();
50
+
51
+ function addNode(node) {
52
+ if (nodeIds.has(node.id)) return;
53
+ nodeIds.add(node.id);
54
+ nodes.push(node);
55
+ }
56
+
57
+ function addEdge(edge) {
58
+ const key = `${edge.source}\u0000${edge.target}\u0000${edge.relation}\u0000${edge.source_location}`;
59
+ if (edgeKeys.has(key)) return;
60
+ edgeKeys.add(key);
61
+ edges.push(edge);
62
+ }
63
+
64
+ // Pass 1: every namespace the repository declares, and the types each one holds.
65
+ const declared = new Set();
66
+ const typesByNamespace = new Map(); // namespace -> Map(typeName -> [declFile])
67
+ for (const rec of files) {
68
+ for (const ns of rec.csharp.namespaces || []) {
69
+ const name = nameOf(ns);
70
+ if (!name) continue;
71
+ declared.add(name);
72
+ const id = `namespace:${name}`;
73
+ if (!nodeIds.has(id)) {
74
+ // First declaring file in path order, its first declaration.
75
+ const first = (rec.csharp.namespaces || [])
76
+ .filter((n) => nameOf(n) === name)
77
+ .reduce((best, n) => Math.min(best, lineOf(n)), Infinity);
78
+ addNode({
79
+ id,
80
+ type: 'namespace',
81
+ label: name,
82
+ source_file: rec.filePath,
83
+ source_location: `L${first}`,
84
+ });
85
+ }
86
+ }
87
+ for (const type of rec.csharp.types || []) {
88
+ const name = nameOf(type);
89
+ if (!name) continue;
90
+ const ns = type && typeof type === 'object' && typeof type.namespace === 'string' ? type.namespace : '';
91
+ if (!typesByNamespace.has(ns)) typesByNamespace.set(ns, new Map());
92
+ const byName = typesByNamespace.get(ns);
93
+ if (!byName.has(name)) byName.set(name, []);
94
+ if (!byName.get(name).includes(rec.filePath)) byName.get(name).push(rec.filePath);
95
+ }
96
+ }
97
+
98
+ const declaredByLength = [...declared].sort((a, b) => b.length - a.length || compare(a, b));
99
+ function resolveNamespace(name) {
100
+ return declaredByLength.find((d) => name === d || name.startsWith(`${d}.`)) || null;
101
+ }
102
+
103
+ // Pass 2: edges, per file in path order.
104
+ for (const rec of files) {
105
+ const fileId = `file:${rec.filePath}`;
106
+ const cs = rec.csharp;
107
+ const visible = new Set();
108
+
109
+ for (const ns of cs.namespaces || []) {
110
+ const name = nameOf(ns);
111
+ if (!name) continue;
112
+ addEdge({
113
+ source: fileId,
114
+ target: `namespace:${name}`,
115
+ relation: 'declares',
116
+ confidence: 'EXTRACTED',
117
+ source_file: rec.filePath,
118
+ source_location: `L${lineOf(ns)}`,
119
+ weight: 1,
120
+ });
121
+ for (const p of dotPrefixes(name)) visible.add(p);
122
+ }
123
+
124
+ for (const using of cs.usings || []) {
125
+ const name = nameOf(using);
126
+ if (!name) continue;
127
+ const hit = resolveNamespace(name);
128
+ if (hit) visible.add(hit);
129
+ addEdge({
130
+ source: fileId,
131
+ target: hit ? `namespace:${hit}` : `external:${name}`,
132
+ relation: 'imports',
133
+ confidence: 'EXTRACTED',
134
+ source_file: rec.filePath,
135
+ source_location: `L${lineOf(using)}`,
136
+ weight: 1,
137
+ });
138
+ }
139
+
140
+ const tests = [];
141
+ for (const t of cs.tests || []) {
142
+ if (!t || !t.name) continue;
143
+ const label = `${t.type}.${t.name}`;
144
+ const id = `test:${rec.filePath}#${label}`;
145
+ addNode({
146
+ id,
147
+ type: 'test',
148
+ label,
149
+ source_file: rec.filePath,
150
+ source_location: `L${lineOf(t)}`,
151
+ });
152
+ addEdge({
153
+ source: fileId,
154
+ target: id,
155
+ relation: 'exports',
156
+ confidence: 'EXTRACTED',
157
+ source_file: rec.filePath,
158
+ source_location: `L${lineOf(t)}`,
159
+ weight: 1,
160
+ });
161
+ tests.push({
162
+ id,
163
+ start: Number.isInteger(t.start) ? t.start : lineOf(t),
164
+ end: Number.isInteger(t.end) ? t.end : Infinity,
165
+ });
166
+ }
167
+
168
+ const ownTypes = new Set((cs.types || []).map(nameOf).filter(Boolean));
169
+ const identifiers = cs.identifiers || {};
170
+ for (const ident of Object.keys(identifiers).sort()) {
171
+ if (ownTypes.has(ident)) continue;
172
+ const lines = ascending(identifiers[ident]);
173
+ if (lines.length === 0) continue;
174
+ const targets = new Set();
175
+ for (const ns of visible) {
176
+ for (const declFile of typesByNamespace.get(ns)?.get(ident) || []) {
177
+ targets.add(`symbol:${declFile}#${ident}`);
178
+ }
179
+ }
180
+ for (const target of [...targets].sort()) {
181
+ addEdge({
182
+ source: fileId,
183
+ target,
184
+ relation: 'references',
185
+ confidence: 'INFERRED',
186
+ source_file: rec.filePath,
187
+ source_location: `L${lines[0]}`,
188
+ weight: INFERRED_REFERENCE_WEIGHT,
189
+ name: ident,
190
+ });
191
+ for (const test of tests) {
192
+ const at = lines.find((n) => n >= test.start && n <= test.end);
193
+ if (at === undefined) continue;
194
+ addEdge({
195
+ source: test.id,
196
+ target,
197
+ relation: 'references',
198
+ confidence: 'INFERRED',
199
+ source_file: rec.filePath,
200
+ source_location: `L${at}`,
201
+ weight: INFERRED_REFERENCE_WEIGHT,
202
+ name: ident,
203
+ });
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ return { nodes, edges };
210
+ }
@@ -134,6 +134,8 @@ export function consumers(graph, target) {
134
134
  );
135
135
  if (src?.type === 'file') {
136
136
  lines.push(` consumer_file: ${src.source_file}`);
137
+ } else if (src?.type === 'test') {
138
+ lines.push(` consumer_test: ${src.source_file}:${src.source_location} ${src.label}`);
137
139
  }
138
140
  }
139
141
  }
@@ -1,7 +1,11 @@
1
1
  /**
2
2
  * Generate GRAPH_REPORT.md content from a graph.
3
+ *
4
+ * `skipped` is the build's list of paths it could not read (`[{ path, code }]`).
5
+ * They are in no node, so the report names them in a section of their own; with
6
+ * none, the report is what it was before the section existed.
3
7
  */
4
- export function writeReport(_root, graph, stats) {
8
+ export function writeReport(_root, graph, stats, skipped = []) {
5
9
  const degree = new Map();
6
10
  for (const n of graph.nodes) degree.set(n.id, { in: 0, out: 0, node: n });
7
11
  for (const e of graph.links) {
@@ -60,6 +64,12 @@ export function writeReport(_root, graph, stats) {
60
64
  lines.push(`- Symbols: ${stats.symbols}`);
61
65
  lines.push(`- Modules: ${stats.modules}`);
62
66
  lines.push('');
67
+ if (Array.isArray(skipped) && skipped.length > 0) {
68
+ lines.push('## Not indexed (unreadable)');
69
+ lines.push('');
70
+ for (const entry of skipped) lines.push(`- ${entry.path} (${entry.code})`);
71
+ lines.push('');
72
+ }
63
73
  lines.push('## God nodes (highest degree)');
64
74
  lines.push('');
65
75
  if (hubs.length === 0) {
@@ -1,6 +1,7 @@
1
- import fs from 'node:fs';
1
+ import { extractMemory } from '../extract/memory.mjs';
2
+ import { extractOpenspec } from '../extract/openspec.mjs';
2
3
  import { walkSourceFiles } from '../extract/walk.mjs';
3
- import { loadJson, manifestPath, rel, sha256File } from '../paths.mjs';
4
+ import { createSkips, loadJson, manifestPath, readIndexed, rel } from '../paths.mjs';
4
5
  import { buildGraph, writeGraphArtifacts } from './build.mjs';
5
6
 
6
7
  /**
@@ -8,40 +9,79 @@ import { buildGraph, writeGraphArtifacts } from './build.mjs';
8
9
  * (v1 keeps it simple — detect staleness, then full rebuild of affected set + dependents
9
10
  * is complex; we do full rebuild when any hash differs, which is still fast for mid-size repos).
10
11
  *
11
- * Returns { updated: boolean, stats, reason }.
12
+ * Readability is compared as well as content. A path that was skipped by the
13
+ * recorded build and is still unreadable is not a change: it can never be
14
+ * hashed, so without the manifest's record it would look changed on every run.
15
+ * A path that was indexed and can no longer be read, and one that was skipped
16
+ * and can now be read, each rebuild.
17
+ *
18
+ * `skips` is the command's collector (`createSkips`). It holds the currently
19
+ * unreadable paths when this returns, whether or not the graph was rebuilt.
20
+ *
21
+ * Returns { updated: boolean, stats, reason, skipped }.
12
22
  */
13
- export function updateGraph(root) {
23
+ export function updateGraph(root, { skips = createSkips(root) } = {}) {
14
24
  const manifest = loadJson(manifestPath(root), null);
15
25
  if (!manifest?.files) {
16
- const result = buildGraph(root);
26
+ const result = buildGraph(root, { skips });
17
27
  writeGraphArtifacts(root, result);
18
- return { updated: true, stats: result.stats, reason: 'no-manifest' };
28
+ return { updated: true, stats: result.stats, reason: 'no-manifest', skipped: result.skipped };
19
29
  }
20
30
 
21
- const currentFiles = walkSourceFiles(root).map((f) => rel(root, f));
22
31
  const prevFiles = new Set(Object.keys(manifest.files));
23
32
  const changed = [];
24
33
 
25
- for (const f of currentFiles) {
26
- const abs = `${root}/${f}`;
27
- if (!fs.existsSync(abs)) continue;
28
- const hash = sha256File(abs);
29
- if (manifest.files[f] !== hash) changed.push(f);
34
+ for (const abs of walkSourceFiles(root, { skips })) {
35
+ const f = rel(root, abs);
36
+ const got = readIndexed(abs);
37
+ if (!got.ok) {
38
+ // Unreadable now, not removed. Whether that is news is decided below,
39
+ // against the recorded skips.
40
+ skips.add(abs, got.code);
41
+ prevFiles.delete(f);
42
+ continue;
43
+ }
44
+ if (manifest.files[f] !== got.sha256) changed.push(f);
30
45
  prevFiles.delete(f);
31
46
  }
32
47
  const removed = [...prevFiles];
33
48
 
34
- if (changed.length === 0 && removed.length === 0) {
35
- return { updated: false, stats: manifest.stats, reason: 'up-to-date' };
49
+ // The overlays read prose the hashes above do not cover, and a build records
50
+ // what they could not read. Run the same readers so the two sets are compared
51
+ // like for like; what they extract is not used here.
52
+ extractOpenspec(root, new Set(), new Set(), { skips });
53
+ extractMemory(root, new Set(), { skips });
54
+
55
+ // A manifest written before skips were recorded reads as one with none.
56
+ const recorded = new Set(
57
+ (Array.isArray(manifest.skipped) ? manifest.skipped : []).map((entry) => entry?.path).filter(Boolean),
58
+ );
59
+ const current = skips.list();
60
+ const unreadableNow = current.filter((entry) => !recorded.has(entry.path)).map((entry) => entry.path);
61
+ const currentPaths = new Set(current.map((entry) => entry.path));
62
+ const readableNow = [...recorded].filter((p) => !currentPaths.has(p));
63
+
64
+ if (
65
+ changed.length === 0 &&
66
+ removed.length === 0 &&
67
+ unreadableNow.length === 0 &&
68
+ readableNow.length === 0
69
+ ) {
70
+ return { updated: false, stats: manifest.stats, reason: 'up-to-date', skipped: current };
36
71
  }
37
72
 
38
- const result = buildGraph(root);
73
+ const result = buildGraph(root, { skips });
39
74
  writeGraphArtifacts(root, result);
75
+ const readability =
76
+ unreadableNow.length || readableNow.length
77
+ ? ` unreadable=${unreadableNow.length} readable=${readableNow.length}`
78
+ : '';
40
79
  return {
41
80
  updated: true,
42
81
  stats: result.stats,
43
- reason: `changed=${changed.length} removed=${removed.length}`,
82
+ reason: `changed=${changed.length} removed=${removed.length}${readability}`,
44
83
  changed,
45
84
  removed,
85
+ skipped: result.skipped,
46
86
  };
47
87
  }
@@ -49,10 +49,62 @@ export function abs(root, relPath) {
49
49
  return path.join(root, ...relPath.split('/'));
50
50
  }
51
51
 
52
- export function sha256File(filePath) {
53
- const hash = createHash('sha256');
54
- hash.update(fs.readFileSync(filePath));
55
- return hash.digest('hex');
52
+ /** Marker on every stderr line naming a path an indexing command could not read. */
53
+ export const SKIP_MARKER = 'GRAPH SKIPPED UNREADABLE';
54
+
55
+ /** Marker on the one stderr line a query prints when its graph was built past skipped paths. */
56
+ export const PARTIAL_MARKER = 'GRAPH PARTIAL';
57
+
58
+ function codeOf(errorOrCode) {
59
+ if (typeof errorOrCode === 'string' && errorOrCode) return errorOrCode;
60
+ const code = errorOrCode && typeof errorOrCode === 'object' ? errorOrCode.code : undefined;
61
+ return typeof code === 'string' && code ? code : 'UNKNOWN';
62
+ }
63
+
64
+ /**
65
+ * Read a file once. Returns its text and the sha256 of the same bytes, or the
66
+ * error code the read failed with. Never throws and never prints: the caller
67
+ * decides what a failed read means (usually: `skips.add`).
68
+ */
69
+ export function readIndexed(absPath) {
70
+ let bytes;
71
+ try {
72
+ bytes = fs.readFileSync(absPath);
73
+ } catch (error) {
74
+ return { ok: false, code: codeOf(error) };
75
+ }
76
+ return {
77
+ ok: true,
78
+ text: bytes.toString('utf8'),
79
+ sha256: createHash('sha256').update(bytes).digest('hex'),
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Collector for paths that could not be read. One entry per path, root-relative
85
+ * with forward slashes, listed sorted by path.
86
+ */
87
+ export function createSkips(root) {
88
+ const byPath = new Map();
89
+ return {
90
+ add(absPath, errorOrCode, note) {
91
+ const p = rel(root, absPath);
92
+ const entry = { path: p, code: codeOf(errorOrCode) };
93
+ if (note) entry.note = note;
94
+ const prior = byPath.get(p);
95
+ if (prior && (prior.note || !entry.note)) return;
96
+ byPath.set(p, entry);
97
+ },
98
+ list() {
99
+ return [...byPath.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
100
+ },
101
+ };
102
+ }
103
+
104
+ /** `GRAPH SKIPPED UNREADABLE: <path> (<code>)`, plus ` — <note>` when there is one. */
105
+ export function formatSkip(entry) {
106
+ const note = entry.note ? ` \u2014 ${entry.note}` : '';
107
+ return `${SKIP_MARKER}: ${entry.path} (${entry.code})${note}`;
56
108
  }
57
109
 
58
110
  export function loadJson(filePath, fallback = null) {
@@ -21,6 +21,7 @@ export function retrieveContext(root, {
21
21
  graphBudget = null,
22
22
  docsBudget = null,
23
23
  docsDirs = null,
24
+ skips,
24
25
  } = {}) {
25
26
  const terms = deriveTerms({ query, changedFiles, commitSubject });
26
27
  const searchQuery = query || terms.slice(0, 8).join(' ');
@@ -64,6 +65,7 @@ export function retrieveContext(root, {
64
65
  changedFiles,
65
66
  dirs: docsDirs ?? undefined,
66
67
  budget: docsShare,
68
+ skips,
67
69
  });
68
70
 
69
71
  parts.push(docsResult.text.trimEnd());
@@ -1,10 +1,9 @@
1
- import fs from 'node:fs';
2
1
  import {
3
2
  docsDigestPath,
4
3
  docsIndexPath,
5
4
  ensureGraphDir,
5
+ readIndexed,
6
6
  rel,
7
- sha256File,
8
7
  writeJson,
9
8
  } from '../paths.mjs';
10
9
  import { walkMarkdown, resolveDocsDirs } from '../extract/walk.mjs';
@@ -41,21 +40,21 @@ export function extractBlurb(content, blurbTokens = DEFAULT_BLURB_TOKENS) {
41
40
  export function buildDocsIndex(root, {
42
41
  dirs: requestedDirs,
43
42
  blurbTokens = DEFAULT_BLURB_TOKENS,
43
+ skips,
44
44
  } = {}) {
45
45
  const dirs = resolveDocsDirs(root, requestedDirs);
46
46
  const files = [];
47
47
  let totalSections = 0;
48
48
  let totalTokens = 0;
49
49
 
50
- for (const absPath of walkMarkdown(root, dirs)) {
50
+ for (const absPath of walkMarkdown(root, dirs, { skips })) {
51
51
  const filePath = rel(root, absPath);
52
- let text;
53
- try {
54
- text = fs.readFileSync(absPath, 'utf8');
55
- } catch {
52
+ const read = readIndexed(absPath);
53
+ if (!read.ok) {
54
+ if (skips) skips.add(absPath, read.code);
56
55
  continue;
57
56
  }
58
- const sha256 = sha256File(absPath);
57
+ const { text, sha256 } = read;
59
58
  const sections = parseMarkdownSections(filePath, text).map((s) => {
60
59
  const tokens_est = estimateTokens(s.content);
61
60
  return {
@@ -97,8 +96,8 @@ export function buildDocsIndex(root, {
97
96
  * Returns { index, path, written }.
98
97
  */
99
98
  export function writeDocsIndex(root, options = {}) {
100
- const { write = true, dirs, blurbTokens } = options;
101
- const index = buildDocsIndex(root, { dirs, blurbTokens });
99
+ const { write = true, dirs, blurbTokens, skips } = options;
100
+ const index = buildDocsIndex(root, { dirs, blurbTokens, skips });
102
101
  const outPath = docsIndexPath(root);
103
102
  if (write) {
104
103
  ensureGraphDir(root);
@@ -1,6 +1,5 @@
1
- import fs from 'node:fs';
2
1
  import path from 'node:path';
3
- import { rel } from '../paths.mjs';
2
+ import { readIndexed, rel } from '../paths.mjs';
4
3
  import { walkMarkdown, resolveDocsDirs } from '../extract/walk.mjs';
5
4
  import { estimateTokens } from '../format.mjs';
6
5
 
@@ -150,6 +149,7 @@ export function retrieveMarkdownSections(root, {
150
149
  perSectionLines = DEFAULT_PER_SECTION_LINES,
151
150
  perSectionTokens = DEFAULT_PER_SECTION_TOKENS,
152
151
  minScore = 1,
152
+ skips,
153
153
  } = {}) {
154
154
  const terms = explicitTerms ?? deriveTerms({ query, changedFiles });
155
155
  const changedPaths = changedFiles.map((f) => String(f).replace(/\\/g, '/'));
@@ -157,19 +157,19 @@ export function retrieveMarkdownSections(root, {
157
157
  // Fall back to probing only when the caller did not name dirs AND the
158
158
  // preferred roots are empty on this repo.
159
159
  let dirs = requestedDirs ?? DEFAULT_DIRS;
160
- if (!requestedDirs && walkMarkdown(root, DEFAULT_DIRS).length === 0) {
160
+ if (!requestedDirs && walkMarkdown(root, DEFAULT_DIRS, { skips }).length === 0) {
161
161
  dirs = resolveDocsDirs(root, null);
162
162
  }
163
163
 
164
164
  const allSections = [];
165
- for (const absPath of walkMarkdown(root, dirs)) {
165
+ for (const absPath of walkMarkdown(root, dirs, { skips })) {
166
166
  const filePath = rel(root, absPath);
167
- let text;
168
- try {
169
- text = fs.readFileSync(absPath, 'utf8');
170
- } catch {
167
+ const read = readIndexed(absPath);
168
+ if (!read.ok) {
169
+ if (skips) skips.add(absPath, read.code);
171
170
  continue;
172
171
  }
172
+ const text = read.text;
173
173
  for (const section of parseMarkdownSections(filePath, text)) {
174
174
  const s = scoreSection(section, terms, changedPaths);
175
175
  if (s >= minScore) {
package/lib/limits.mjs CHANGED
@@ -87,6 +87,12 @@ export const LIMITS = {
87
87
  /**
88
88
  * Soft budget for inter-wave verification. Past this, drop to typecheck only
89
89
  * rather than letting the checks outweigh the work they guard.
90
+ *
91
+ * This is a floor, not the budget every run gets: a test profile whose
92
+ * `unit.timeout_ms` is a positive integer raises it to `interWaveVerifications`
93
+ * times that allowance (`interWaveVerifyBudget` in `lib/verify.mjs`), and
94
+ * nothing a profile says can lower it. A caller's explicit `--budget-ms` still
95
+ * overrides both.
90
96
  */
91
97
  interWaveVerifyBudgetMs: 60_000,
92
98
 
@@ -115,9 +121,11 @@ export const LIMITS = {
115
121
  /**
116
122
  * Character budget for one task's wave handoff — `summary`, `next`,
117
123
  * `blocker` and the joined `evidence` locators added together. A packet over
118
- * this fails its task rather than being truncated: silent truncation is the
124
+ * this is rejected whole rather than truncated: silent truncation is the
119
125
  * degradation the schema exists to forbid, and a next wave reading half a
120
- * sentence is worse off than one told the report was rejected.
126
+ * sentence is worse off than one told the report was rejected. The task that
127
+ * filed it stays done; the paths a rejected report hands on are held to this
128
+ * same cap.
121
129
  * See add-wave-handoff-and-prompt-snapshots design.md §2.
122
130
  */
123
131
  maxHandoffChars: 2000,
@@ -559,7 +567,7 @@ export function formatLimits({ runtimeObserved } = {}) {
559
567
  ['root-cause iterations (per run)', LIMITS.rootCauseIterations],
560
568
  ['task failures tolerated', LIMITS.taskFailureHalt],
561
569
  ['run steps (per run)', LIMITS.maxRunSteps],
562
- ['inter-wave verify budget', `${LIMITS.interWaveVerifyBudgetMs / 1000}s`],
570
+ ['inter-wave verify budget (floor)', `${LIMITS.interWaveVerifyBudgetMs / 1000}s`],
563
571
  ['inter-wave verifications (per run)', LIMITS.interWaveVerifications],
564
572
  ['verify spill threshold (bytes)', LIMITS.verifySpillBytes],
565
573
  ['verify preview budget (chars)', LIMITS.verifyPreviewChars],
package/lib/outcomes.mjs CHANGED
@@ -177,7 +177,14 @@ function waveTallies(value) {
177
177
  wave: nullableCount(w.wave),
178
178
  ok: nullableCount(w.ok),
179
179
  failed: nullableCount(w.failed),
180
- notAttempted: nullableStringArray(w.notAttempted, MAX_NAME) || []
180
+ notAttempted: nullableStringArray(w.notAttempted, MAX_NAME) || [],
181
+ // Task ids whose handoff packet was rejected though the task reported success.
182
+ // Carried only when the row holds the key: a row from before the field
183
+ // existed keeps the shape it had, and an absent key reads as unobserved --
184
+ // never `[]`, which would say the run looked and found none.
185
+ ...(w.reportRejected !== undefined
186
+ ? { reportRejected: nullableStringArray(w.reportRejected, MAX_NAME) }
187
+ : {})
181
188
  }
182
189
  })
183
190
  }
@@ -11,7 +11,17 @@
11
11
  // assembled text has to be a deliberate act that updates a file, not a side
12
12
  // effect of editing nearby control flow.
13
13
 
14
- export function assembleImplementerPrompt({ change, lane, task, previousHandoffs, isolateWaves, solo }) {
14
+ import { renderRejectedReport } from './rejected.mjs'
15
+
16
+ export function assembleImplementerPrompt({
17
+ change,
18
+ lane,
19
+ task,
20
+ previousHandoffs,
21
+ previousRejectedReports,
22
+ isolateWaves,
23
+ solo
24
+ }) {
15
25
  // A lane is the unit now; a bare `task` is still accepted and means a lane of
16
26
  // one. That is not politeness to old callers — a one-task lane MUST assemble
17
27
  // byte-identically to the pre-lane prompt, and sharing one code path is the
@@ -54,6 +64,19 @@ export function assembleImplementerPrompt({ change, lane, task, previousHandoffs
54
64
  `\n`
55
65
  : ''
56
66
 
67
+ // A previous-wave task that said it succeeded but whose packet did not validate.
68
+ // It is not a failure and has no packet to quote, so it gets its own block: the
69
+ // reason the packet was rejected and the paths it claimed, to be read, not trusted.
70
+ // Empty or absent renders nothing, so every existing prompt stays byte-identical.
71
+ const rejectedEntries = (Array.isArray(previousRejectedReports) ? previousRejectedReports : []).filter(
72
+ r => r && typeof r === 'object'
73
+ )
74
+ const rejected = rejectedEntries.length
75
+ ? `\nNO VALIDATED REPORT (the packet was rejected; read the paths, assume nothing):\n` +
76
+ rejectedEntries.map(renderRejectedReport).join('\n') +
77
+ `\n`
78
+ : ''
79
+
57
80
  // A lane of one renders exactly the text it rendered before lanes existed —
58
81
  // the single-task fixtures are the pin that keeps that honest.
59
82
  const single = tasks.length === 1
@@ -137,12 +160,14 @@ export function assembleImplementerPrompt({ change, lane, task, previousHandoffs
137
160
  ? `- If your tier is 1 or 2: after typecheck/lint pass, stop. Do not refactor or polish.\n`
138
161
  : '') +
139
162
  previous +
163
+ rejected +
140
164
  isolation +
141
165
  handoff +
142
166
  `- status "ok" means blocker is null; "blocked" and "partial" need a non-empty blocker.\n` +
143
167
  `- evidence is at most 8 locators (path, path:line, path:start-end) — never file bodies.\n` +
144
- `- Keep it terse. A packet over the character cap \`interlock limits\` publishes fails the ` +
145
- `task, and it is never truncated for you.\n\n` +
168
+ `- Keep it terse. A packet over the character cap \`interlock limits\` publishes is rejected ` +
169
+ `whole — it is never truncated for you, and your task is recorded as having filed no usable ` +
170
+ `report.\n\n` +
146
171
  (single
147
172
  ? `Report ok:false if you could not complete the task, with what blocked you.`
148
173
  : `Report outcome "failed" for the task that blocked you, with what blocked you.`)