simt-dev 0.2.1 → 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/CHANGELOG.md +239 -0
- package/bin/simt-dev.mjs +21 -2
- package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
- package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
- package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
- package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
- package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
- package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
- package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
- package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
- package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
- package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
- package/docs/decisions/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
- package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
- package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
- package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
- package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
- package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
- package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
- package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
- package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
- package/package.json +2 -1
- package/rules/core.md +1 -0
- package/rules/stacks/android.md +2 -0
- package/rules/stacks/cloudflare-workers.md +2 -0
- package/rules/stacks/connectiq.md +1 -0
- package/rules/stacks/container.md +2 -0
- package/rules/stacks/dart.md +2 -0
- package/rules/stacks/deno.md +2 -0
- package/rules/stacks/dotnet.md +2 -0
- package/rules/stacks/elixir.md +2 -0
- package/rules/stacks/go.md +2 -0
- package/rules/stacks/jvm.md +2 -0
- package/rules/stacks/node.md +2 -0
- package/rules/stacks/python.md +2 -0
- package/rules/stacks/react-native.md +2 -0
- package/rules/stacks/react-web.md +2 -0
- package/rules/stacks/ruby.md +2 -0
- package/rules/stacks/rust.md +2 -0
- package/rules/stacks/swift.md +2 -0
- package/rules/stacks/typescript.md +2 -0
- package/rules/stacks/uv.md +2 -0
- package/src/agenthooks/decide.mjs +123 -0
- package/src/agenthooks/policy.mjs +78 -0
- package/src/agenthooks/render.mjs +240 -0
- package/src/argv.mjs +55 -1
- package/src/bench/ablate.mjs +630 -0
- package/src/bench/adapters.json +149 -0
- package/src/bench/grade.mjs +229 -0
- package/src/bench/mine.mjs +811 -0
- package/src/bench/probes.mjs +1350 -0
- package/src/bench/report.mjs +899 -0
- package/src/bench/run.mjs +1002 -0
- package/src/commands/agenthook.mjs +282 -0
- package/src/commands/bench.mjs +359 -0
- package/src/commands/compare.mjs +15 -0
- package/src/commands/graph.mjs +45 -21
- package/src/commands/harness.mjs +301 -0
- package/src/commands/help.mjs +73 -2
- package/src/commands/index.mjs +67 -25
- package/src/commands/init.mjs +12 -8
- package/src/commands/mcp.mjs +20 -5
- package/src/commands/plan.mjs +99 -0
- package/src/commands/rules.mjs +42 -57
- package/src/commands/skills.mjs +67 -92
- package/src/commands/skillsinstall.mjs +17 -2
- package/src/commands/sync.mjs +62 -6
- package/src/commands/tokens.mjs +169 -0
- package/src/compare/row.mjs +73 -2
- package/src/decisions/index.mjs +23 -5
- package/src/graph/spec.mjs +227 -0
- package/src/graph/speccheck.mjs +68 -0
- package/src/hooks/dispatcher.mjs +35 -3
- package/src/kernel/card.mjs +254 -0
- package/src/kernel/inferability.mjs +188 -0
- package/src/mcp/resources.mjs +157 -0
- package/src/mcp/schema.mjs +139 -0
- package/src/mcp/server.mjs +228 -20
- package/src/mcp/tools.mjs +372 -1
- package/src/measure/context.mjs +542 -0
- package/src/measure/loaderView.mjs +435 -0
- package/src/measure/session.mjs +267 -0
- package/src/monorepo.mjs +339 -0
- package/src/output/index.mjs +78 -2
- package/src/packs.mjs +182 -0
- package/src/plan/index.mjs +650 -0
- package/src/plan/template.mjs +331 -0
- package/src/projection/loaders.mjs +689 -0
- package/src/projection/manifest.mjs +114 -3
- package/src/projection/project.mjs +140 -9
- package/src/projection/rules.mjs +365 -22
- package/src/projection/surfaces.mjs +155 -23
- package/src/projection/targets.mjs +37 -0
- package/src/toolwords.mjs +69 -0
- package/templates/plan/tracker.json +36 -0
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
// Plan and tracker index (plan 0003 R31).
|
|
2
|
+
//
|
|
3
|
+
// An agent resuming a long plan should read one slice, not the document. Which
|
|
4
|
+
// directories are read, which columns count, and which words count as done are
|
|
5
|
+
// the repository's template (src/plan/template.mjs). The tracker template
|
|
6
|
+
// delivered with the tool reads docs/plans and docs/reviews and treats `done`
|
|
7
|
+
// as complete.
|
|
8
|
+
// A dependency may name another document (`review 0002 S41`) or one of its
|
|
9
|
+
// waves (`review 0002 Wave 0`). Ids that follow a document prefix stay in that
|
|
10
|
+
// document: `review 0002 S41, S50` is two slices of the review. A parenthetical
|
|
11
|
+
// is a note, not a dependency — `S47 (optional)` waits on S47, and the pack
|
|
12
|
+
// test named beside S01 is not a ninth wave member. The words `review` and
|
|
13
|
+
// `wave` are the delivered template's; another template names its own.
|
|
14
|
+
//
|
|
15
|
+
// Only a state the template marks as satisfying a dependency unblocks anything.
|
|
16
|
+
// In the delivered template that is `complete`, which the older tracker wrote
|
|
17
|
+
// as `done`. `accepted` means the slice was left open on purpose.
|
|
18
|
+
//
|
|
19
|
+
// The section is located when a slice is chosen, from headings recorded while
|
|
20
|
+
// the file was read. Status and dependency checks never touch it: a 1 MB
|
|
21
|
+
// section that belongs to a later slice is not copied into the index.
|
|
22
|
+
|
|
23
|
+
import { readdir, readFile, writeFile } from 'node:fs/promises';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
|
|
26
|
+
import { CLI } from '../name.mjs';
|
|
27
|
+
import { loadFormat } from './template.mjs';
|
|
28
|
+
|
|
29
|
+
function clean(cell) {
|
|
30
|
+
return String(cell ?? '').replace(/\*\*/g, '').trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normHeader(cell) {
|
|
34
|
+
return clean(cell).toLowerCase().replace(/\s+/g, ' ');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function escapeRegExp(value) {
|
|
38
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Drop parenthetical notes, repeatedly, so a note that itself contains a note goes too. */
|
|
42
|
+
function stripNotes(value) {
|
|
43
|
+
let text = clean(value);
|
|
44
|
+
let previous;
|
|
45
|
+
do {
|
|
46
|
+
previous = text;
|
|
47
|
+
text = text.replace(/\s*\([^()]*\)/g, ' ');
|
|
48
|
+
} while (text !== previous);
|
|
49
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function splitList(text, separators) {
|
|
53
|
+
const parts = [];
|
|
54
|
+
let current = '';
|
|
55
|
+
for (const char of text) {
|
|
56
|
+
if (separators.includes(char)) {
|
|
57
|
+
if (current.trim()) parts.push(current.trim());
|
|
58
|
+
current = '';
|
|
59
|
+
} else {
|
|
60
|
+
current += char;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (current.trim()) parts.push(current.trim());
|
|
64
|
+
return parts;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function tokensOf(fmt, cell) {
|
|
68
|
+
const stripped = fmt.notes ? stripNotes(cell) : clean(cell);
|
|
69
|
+
if (stripped === '' || fmt.empty.has(stripped.toLowerCase())) return [];
|
|
70
|
+
return splitList(stripped, fmt.separators);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function keysOf(label) {
|
|
74
|
+
const plain = clean(label).toLowerCase();
|
|
75
|
+
const keys = new Set();
|
|
76
|
+
for (const digits of plain.match(/\d+/g) ?? []) keys.add(digits);
|
|
77
|
+
const flat = plain.replace(/[^a-z0-9]+/g, '');
|
|
78
|
+
if (flat) keys.add(flat);
|
|
79
|
+
return [...keys];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function idsOf(fmt, cell) {
|
|
83
|
+
const stripped = fmt.notes ? stripNotes(cell) : clean(cell);
|
|
84
|
+
return splitList(stripped, fmt.separators).filter((part) => fmt.idRe.test(part));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function docNumber(name) {
|
|
88
|
+
const match = name.match(/(\d+)/);
|
|
89
|
+
return match ? Number(match[1]) : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function sliceKey(path, id) {
|
|
93
|
+
return `${path}\0${id}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Split a markdown table row.
|
|
98
|
+
*
|
|
99
|
+
* A leading empty cell is the pipe that opens the row. Exactly one trailing
|
|
100
|
+
* empty cell is the pipe that closes it. A further empty cell is real: the
|
|
101
|
+
* evidence column is often blank, and stripping every trailing empty would
|
|
102
|
+
* delete it and shift the state into the wrong column.
|
|
103
|
+
*/
|
|
104
|
+
function splitRow(line) {
|
|
105
|
+
const cells = [];
|
|
106
|
+
let current = '';
|
|
107
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
108
|
+
const char = line[i];
|
|
109
|
+
if (char === '\\' && i + 1 < line.length) {
|
|
110
|
+
current += char + line[i + 1];
|
|
111
|
+
i += 1;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (char === '|') {
|
|
115
|
+
cells.push(current);
|
|
116
|
+
current = '';
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
current += char;
|
|
120
|
+
}
|
|
121
|
+
if (line.trim()) cells.push(current);
|
|
122
|
+
let out = cells.map((cell) => cell.trim());
|
|
123
|
+
if (out[0] === '') out = out.slice(1);
|
|
124
|
+
if (out.length > 1 && out[out.length - 1] === '') out = out.slice(0, -1);
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isSeparator(line) {
|
|
129
|
+
const trimmed = line.trim();
|
|
130
|
+
return trimmed.startsWith('|') && trimmed.endsWith('|') && /^[\s|:-]+$/.test(trimmed) && trimmed.includes('---');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function findColumn(columns, names, fallback) {
|
|
134
|
+
for (const name of names) {
|
|
135
|
+
const index = columns.indexOf(name);
|
|
136
|
+
if (index >= 0) return index;
|
|
137
|
+
}
|
|
138
|
+
if (!fallback) return -1;
|
|
139
|
+
return columns.findIndex((column) => fallback.test(column));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function buildTable(pending) {
|
|
143
|
+
const columns = splitRow(pending[0].line).map(normHeader);
|
|
144
|
+
const rows = [];
|
|
145
|
+
for (let i = 1; i < pending.length; i += 1) {
|
|
146
|
+
if (isSeparator(pending[i].line)) continue;
|
|
147
|
+
const cells = splitRow(pending[i].line);
|
|
148
|
+
if (cells.every((cell) => cell === '')) continue;
|
|
149
|
+
rows.push({ cells, line: pending[i].no });
|
|
150
|
+
}
|
|
151
|
+
return { columns, rows };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isTracker(fmt, table) {
|
|
155
|
+
const idIdx = findColumn(table.columns, fmt.idHeaders);
|
|
156
|
+
const stateIdx = findColumn(table.columns, fmt.stateHeaders);
|
|
157
|
+
if (idIdx < 0 || stateIdx < 0 || table.rows.length === 0) return false;
|
|
158
|
+
let ids = 0;
|
|
159
|
+
let states = 0;
|
|
160
|
+
for (const row of table.rows) {
|
|
161
|
+
if (fmt.idRe.test(clean(row.cells[idIdx] ?? ''))) ids += 1;
|
|
162
|
+
if (fmt.known.has(fmt.readState(row.cells[stateIdx] ?? ''))) states += 1;
|
|
163
|
+
}
|
|
164
|
+
const need = Math.min(2, table.rows.length);
|
|
165
|
+
// Both thresholds, so a prose table that happens to contain one id-shaped
|
|
166
|
+
// cell and the word "done" is not an index. One row is a tracker only when
|
|
167
|
+
// that row is an id in a known state.
|
|
168
|
+
return ids >= need && states >= need;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function trackerRows(fmt, table) {
|
|
172
|
+
const columns = table.columns;
|
|
173
|
+
const idIdx = findColumn(columns, fmt.idHeaders);
|
|
174
|
+
const stateIdx = findColumn(columns, fmt.stateHeaders);
|
|
175
|
+
const depsIdx = findColumn(columns, fmt.dependsHeaders);
|
|
176
|
+
const titleIdx = findColumn(columns, fmt.titleHeaders);
|
|
177
|
+
const evidenceIdx = findColumn(columns, fmt.evidenceHeaders);
|
|
178
|
+
const rows = [];
|
|
179
|
+
for (const row of table.rows) {
|
|
180
|
+
const id = clean(row.cells[idIdx] ?? '');
|
|
181
|
+
if (!fmt.idRe.test(id)) continue;
|
|
182
|
+
rows.push({
|
|
183
|
+
id,
|
|
184
|
+
line: row.line,
|
|
185
|
+
idIdx,
|
|
186
|
+
stateIdx,
|
|
187
|
+
depsIdx,
|
|
188
|
+
titleIdx: titleIdx === idIdx ? -1 : titleIdx,
|
|
189
|
+
evidenceIdx,
|
|
190
|
+
columnCount: columns.length,
|
|
191
|
+
cells: row.cells,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return rows;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function waveRows(fmt, table) {
|
|
198
|
+
const waveIdx = findColumn(table.columns, fmt.waveLabel);
|
|
199
|
+
const slicesIdx = findColumn(table.columns, fmt.waveMembers);
|
|
200
|
+
if (waveIdx < 0 || slicesIdx < 0) return [];
|
|
201
|
+
return table.rows.map((row) => ({
|
|
202
|
+
keys: keysOf(row.cells[waveIdx] ?? ''),
|
|
203
|
+
ids: idsOf(fmt, row.cells[slicesIdx] ?? ''),
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function parseDocument(fmt, text) {
|
|
208
|
+
const lines = text.split('\n');
|
|
209
|
+
const headings = [];
|
|
210
|
+
const tables = [];
|
|
211
|
+
let offset = 0;
|
|
212
|
+
let fence = false;
|
|
213
|
+
let pending = null;
|
|
214
|
+
const flush = () => {
|
|
215
|
+
if (pending?.length) tables.push(buildTable(pending));
|
|
216
|
+
pending = null;
|
|
217
|
+
};
|
|
218
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
219
|
+
const line = lines[i];
|
|
220
|
+
const trimmed = line.trim();
|
|
221
|
+
if (trimmed.startsWith('```')) {
|
|
222
|
+
fence = !fence;
|
|
223
|
+
flush();
|
|
224
|
+
} else if (!fence && trimmed.startsWith('|')) {
|
|
225
|
+
(pending ??= []).push({ line, no: i + 1 });
|
|
226
|
+
} else if (!fence) {
|
|
227
|
+
flush();
|
|
228
|
+
const heading = fmt.headingRe.exec(line);
|
|
229
|
+
if (heading) headings.push({ depth: heading[1].length, text: heading[2], pos: offset });
|
|
230
|
+
}
|
|
231
|
+
offset += line.length + 1;
|
|
232
|
+
}
|
|
233
|
+
flush();
|
|
234
|
+
const rows = [];
|
|
235
|
+
const waves = [];
|
|
236
|
+
for (const table of tables) {
|
|
237
|
+
if (isTracker(fmt, table)) rows.push(...trackerRows(fmt, table));
|
|
238
|
+
else waves.push(...waveRows(fmt, table));
|
|
239
|
+
}
|
|
240
|
+
return { headings, rows, waves };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function sectionFor(doc, id) {
|
|
244
|
+
// A heading that starts with the id is the slice. A later mention of the same
|
|
245
|
+
// id (`Phase 1` while the id is `1`) is only a fallback, and only when nothing
|
|
246
|
+
// starts with it — otherwise the earlier heading wins and the section is the
|
|
247
|
+
// wrong one.
|
|
248
|
+
const word = new RegExp(`\\b${escapeRegExp(id)}\\b`);
|
|
249
|
+
let best = null;
|
|
250
|
+
for (const heading of doc.headings) {
|
|
251
|
+
const stripped = heading.text.replace(/^\d+[.)]\s+/, '');
|
|
252
|
+
const starts = stripped === id
|
|
253
|
+
|| stripped.startsWith(`${id} `)
|
|
254
|
+
|| stripped.startsWith(`${id}—`)
|
|
255
|
+
|| stripped.startsWith(`${id}–`)
|
|
256
|
+
|| stripped.startsWith(`${id}-`)
|
|
257
|
+
|| stripped.startsWith(`${id}:`);
|
|
258
|
+
const rank = starts ? 0 : word.test(heading.text) ? 1 : 2;
|
|
259
|
+
if (rank === 2) continue;
|
|
260
|
+
if (!best || rank < best.rank || (rank === best.rank && heading.pos < best.pos)) {
|
|
261
|
+
best = { ...heading, rank };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (!best) return null;
|
|
265
|
+
const next = doc.headings.find((heading) => heading.pos > best.pos && heading.depth <= best.depth);
|
|
266
|
+
return doc.text.slice(best.pos, next ? next.pos : doc.text.length).replace(/\s+$/, '');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function readKind(root, dir, fmt) {
|
|
270
|
+
let names;
|
|
271
|
+
try {
|
|
272
|
+
names = (await readdir(join(root, dir.path))).filter((name) => name.endsWith('.md')).sort();
|
|
273
|
+
} catch {
|
|
274
|
+
return [];
|
|
275
|
+
}
|
|
276
|
+
const loaded = await Promise.all(names.map(async (name) => {
|
|
277
|
+
try {
|
|
278
|
+
return [name, await readFile(join(root, dir.path, name), 'utf8')];
|
|
279
|
+
} catch {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
}));
|
|
283
|
+
const docs = [];
|
|
284
|
+
for (const entry of loaded) {
|
|
285
|
+
if (!entry) continue;
|
|
286
|
+
const [name, text] = entry;
|
|
287
|
+
const parsed = parseDocument(fmt, text);
|
|
288
|
+
if (parsed.rows.length === 0) continue;
|
|
289
|
+
docs.push({
|
|
290
|
+
kind: dir.kind,
|
|
291
|
+
name,
|
|
292
|
+
path: join(root, dir.path, name),
|
|
293
|
+
number: docNumber(name),
|
|
294
|
+
text,
|
|
295
|
+
...parsed,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
return docs;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function readDocs(root, fmt) {
|
|
302
|
+
const groups = await Promise.all(fmt.directories.map((dir) => readKind(root, dir, fmt)));
|
|
303
|
+
return groups.flat();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function findDoc(docs, word, number, fmt) {
|
|
307
|
+
const kind = fmt.byWord.get(word.toLowerCase());
|
|
308
|
+
if (!kind) return null;
|
|
309
|
+
const hits = docs.filter((doc) => doc.kind === kind && doc.number === number);
|
|
310
|
+
// Two documents with the same number would make `review 0002` a guess.
|
|
311
|
+
// Missing is an unknown dependency, which blocks, and a guess would unblock
|
|
312
|
+
// a slice against the wrong tracker.
|
|
313
|
+
return hits.length === 1 ? hits[0] : null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function wavesByLabel(doc, label) {
|
|
317
|
+
const want = new Set(keysOf(label));
|
|
318
|
+
const ids = [];
|
|
319
|
+
const seen = new Set();
|
|
320
|
+
let hit = false;
|
|
321
|
+
for (const wave of doc.waves) {
|
|
322
|
+
if (!wave.keys.some((key) => want.has(key))) continue;
|
|
323
|
+
hit = true;
|
|
324
|
+
for (const id of wave.ids) {
|
|
325
|
+
if (seen.has(id)) continue;
|
|
326
|
+
seen.add(id);
|
|
327
|
+
ids.push(id);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return hit ? ids : null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function pushWave(ids, doc, byKey, targets, unknown) {
|
|
334
|
+
for (const id of ids) {
|
|
335
|
+
if (byKey.has(sliceKey(doc.path, id))) targets.push({ id, doc: doc.name, path: doc.path });
|
|
336
|
+
else unknown.push(id);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function resolveDeps(doc, cell, docs, byKey, fmt) {
|
|
341
|
+
const targets = [];
|
|
342
|
+
const unknown = [];
|
|
343
|
+
let scope = null;
|
|
344
|
+
for (const token of tokensOf(fmt, cell)) {
|
|
345
|
+
const cross = fmt.crossRe.exec(token);
|
|
346
|
+
if (cross) {
|
|
347
|
+
const target = findDoc(docs, cross[1], Number(cross[2]), fmt);
|
|
348
|
+
const rest = cross[3].trim();
|
|
349
|
+
if (!target) {
|
|
350
|
+
unknown.push(token);
|
|
351
|
+
scope = null;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
scope = target;
|
|
355
|
+
const wave = fmt.groupRe.exec(rest);
|
|
356
|
+
if (wave) {
|
|
357
|
+
const ids = wavesByLabel(target, wave[1]);
|
|
358
|
+
if (ids) pushWave(ids, target, byKey, targets, unknown);
|
|
359
|
+
else unknown.push(token);
|
|
360
|
+
} else if (byKey.has(sliceKey(target.path, rest))) {
|
|
361
|
+
targets.push({ id: rest, doc: target.name, path: target.path });
|
|
362
|
+
} else {
|
|
363
|
+
unknown.push(rest);
|
|
364
|
+
}
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
const wave = fmt.groupRe.exec(token);
|
|
368
|
+
const home = scope ?? doc;
|
|
369
|
+
if (wave) {
|
|
370
|
+
const ids = wavesByLabel(home, wave[1]);
|
|
371
|
+
if (ids) pushWave(ids, home, byKey, targets, unknown);
|
|
372
|
+
else unknown.push(token);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (byKey.has(sliceKey(home.path, token))) {
|
|
376
|
+
targets.push({ id: token, doc: home.name, path: home.path });
|
|
377
|
+
} else if (scope && byKey.has(sliceKey(doc.path, token))) {
|
|
378
|
+
targets.push({ id: token, doc: doc.name, path: doc.path });
|
|
379
|
+
} else {
|
|
380
|
+
unknown.push(token);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return { targets, unknown };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function linkSlices(docs, fmt) {
|
|
387
|
+
const slices = [];
|
|
388
|
+
const byKey = new Map();
|
|
389
|
+
for (const doc of docs) {
|
|
390
|
+
for (const row of doc.rows) {
|
|
391
|
+
const stateRaw = row.stateIdx >= 0 ? clean(row.cells[row.stateIdx] ?? '') : '';
|
|
392
|
+
const slice = {
|
|
393
|
+
id: row.id,
|
|
394
|
+
docName: doc.name,
|
|
395
|
+
docPath: doc.path,
|
|
396
|
+
line: row.line,
|
|
397
|
+
title: row.titleIdx >= 0 ? clean(row.cells[row.titleIdx] ?? '') : '',
|
|
398
|
+
stateRaw,
|
|
399
|
+
state: fmt.readState(stateRaw),
|
|
400
|
+
dependsOn: row.depsIdx >= 0 ? clean(row.cells[row.depsIdx] ?? '') : '',
|
|
401
|
+
idIdx: row.idIdx,
|
|
402
|
+
stateIdx: row.stateIdx,
|
|
403
|
+
evidenceIdx: row.evidenceIdx,
|
|
404
|
+
columnCount: row.columnCount,
|
|
405
|
+
prefix: (row.id.match(/^[A-Za-z]+/) ?? [''])[0],
|
|
406
|
+
number: Number((row.id.match(/\d+/) ?? ['0'])[0]),
|
|
407
|
+
doc,
|
|
408
|
+
};
|
|
409
|
+
slices.push(slice);
|
|
410
|
+
const key = sliceKey(doc.path, slice.id);
|
|
411
|
+
if (!byKey.has(key)) byKey.set(key, slice);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
for (const slice of slices) {
|
|
415
|
+
const { targets, unknown } = resolveDeps(slice.doc, slice.dependsOn, docs, byKey, fmt);
|
|
416
|
+
slice.resolvedDeps = targets;
|
|
417
|
+
slice.unknownDeps = unknown;
|
|
418
|
+
slice.depsComplete = unknown.length === 0 && targets.every((dep) => fmt.satisfies.has(byKey.get(sliceKey(dep.path, dep.id))?.state));
|
|
419
|
+
slice.ready = slice.state === fmt.ready && slice.depsComplete;
|
|
420
|
+
}
|
|
421
|
+
return slices;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function compareSlice(a, b) {
|
|
425
|
+
if (a.docName !== b.docName) return a.docName < b.docName ? -1 : 1;
|
|
426
|
+
if (a.prefix !== b.prefix) return a.prefix < b.prefix ? -1 : 1;
|
|
427
|
+
if (a.number !== b.number) return a.number - b.number;
|
|
428
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function rankCounts(counts, order) {
|
|
432
|
+
return Object.keys(counts).sort((a, b) => {
|
|
433
|
+
const ai = order.indexOf(a);
|
|
434
|
+
const bi = order.indexOf(b);
|
|
435
|
+
if (ai !== -1 || bi !== -1) return (ai === -1 ? order.length : ai) - (bi === -1 ? order.length : bi);
|
|
436
|
+
return a < b ? -1 : 1;
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export async function loadPlanState(root) {
|
|
441
|
+
const loaded = await loadFormat(root);
|
|
442
|
+
if (loaded.error) return { root, error: loaded.error, remedy: loaded.remedy };
|
|
443
|
+
const docs = await readDocs(root, loaded.format);
|
|
444
|
+
const slices = linkSlices(docs, loaded.format);
|
|
445
|
+
return { root, docs, slices, format: loaded.format };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function hitsFor(slices, id) {
|
|
449
|
+
return slices.filter((slice) => slice.id === id);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function missing(id) {
|
|
453
|
+
return { error: `no tracked slice named ${id}`, remedy: `run: ${CLI} plan status` };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function ambiguous(id, hits) {
|
|
457
|
+
const names = hits.map((hit) => hit.docName).join(', ');
|
|
458
|
+
return {
|
|
459
|
+
error: `${id} appears in ${hits.length} documents: ${names}`,
|
|
460
|
+
remedy: `run: ${CLI} plan status`,
|
|
461
|
+
candidates: hits.map((hit) => hit.docName),
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export async function planStatus(root) {
|
|
466
|
+
const loaded = await loadPlanState(root);
|
|
467
|
+
if (loaded.error) return loaded;
|
|
468
|
+
const { slices, docs, format } = loaded;
|
|
469
|
+
const documents = docs.map((doc) => {
|
|
470
|
+
const own = slices.filter((slice) => slice.docPath === doc.path);
|
|
471
|
+
const counts = {};
|
|
472
|
+
for (const slice of own) counts[slice.state] = (counts[slice.state] ?? 0) + 1;
|
|
473
|
+
return { name: doc.name, path: doc.path, kind: doc.kind, number: doc.number, slices: own.length, counts };
|
|
474
|
+
});
|
|
475
|
+
const totals = {};
|
|
476
|
+
for (const doc of documents) {
|
|
477
|
+
for (const [state, count] of Object.entries(doc.counts)) totals[state] = (totals[state] ?? 0) + count;
|
|
478
|
+
}
|
|
479
|
+
return {
|
|
480
|
+
root,
|
|
481
|
+
template: format.name,
|
|
482
|
+
directories: format.directories.map((dir) => dir.path),
|
|
483
|
+
stateOrder: format.order,
|
|
484
|
+
documents,
|
|
485
|
+
totals,
|
|
486
|
+
ready: slices.filter((slice) => slice.ready).length,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function publicSlice(slice) {
|
|
491
|
+
const section = sectionFor(slice.doc, slice.id);
|
|
492
|
+
return {
|
|
493
|
+
id: slice.id,
|
|
494
|
+
doc: slice.docName,
|
|
495
|
+
path: slice.docPath,
|
|
496
|
+
title: slice.title,
|
|
497
|
+
state: slice.state,
|
|
498
|
+
dependsOn: slice.dependsOn,
|
|
499
|
+
unknownDependencies: slice.unknownDeps,
|
|
500
|
+
section,
|
|
501
|
+
sectionBytes: Buffer.byteLength(section ?? '', 'utf8'),
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export async function planNext(root, id = null) {
|
|
506
|
+
const loaded = await loadPlanState(root);
|
|
507
|
+
if (loaded.error) return loaded;
|
|
508
|
+
const { slices, format } = loaded;
|
|
509
|
+
if (id) {
|
|
510
|
+
const hits = hitsFor(slices, id);
|
|
511
|
+
if (hits.length === 0) return { root, ...missing(id) };
|
|
512
|
+
if (hits.length > 1) return { root, ...ambiguous(id, hits) };
|
|
513
|
+
return { root, next: publicSlice(hits[0]), reason: null };
|
|
514
|
+
}
|
|
515
|
+
const ready = slices.filter((slice) => slice.ready).sort(compareSlice);
|
|
516
|
+
if (ready.length > 0) return { root, next: publicSlice(ready[0]), reason: null };
|
|
517
|
+
const waiting = slices.filter((slice) => slice.state === format.ready).sort(compareSlice);
|
|
518
|
+
const label = (format.stateNames[format.order.indexOf(format.ready)] ?? format.ready).toLowerCase();
|
|
519
|
+
const reason = waiting.length === 0 ? `no ${label} slices` : `no ${label} slice has its dependencies complete`;
|
|
520
|
+
return {
|
|
521
|
+
root,
|
|
522
|
+
next: null,
|
|
523
|
+
reason,
|
|
524
|
+
blocked: waiting.map((slice) => ({
|
|
525
|
+
id: slice.id,
|
|
526
|
+
doc: slice.docName,
|
|
527
|
+
dependsOn: slice.dependsOn,
|
|
528
|
+
unknownDependencies: slice.unknownDeps,
|
|
529
|
+
})),
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function replaceState(cell, display) {
|
|
534
|
+
const trimmed = String(cell ?? '').trim();
|
|
535
|
+
if (trimmed === '') return display;
|
|
536
|
+
const paren = trimmed.indexOf(' (');
|
|
537
|
+
const head = paren >= 0 ? trimmed.slice(0, paren) : trimmed;
|
|
538
|
+
const tail = paren >= 0 ? trimmed.slice(paren) : '';
|
|
539
|
+
const bold = head.startsWith('**');
|
|
540
|
+
return `${bold ? '**' : ''}${display}${bold ? '**' : ''}${tail}`;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function evidenceValue(fmt, existing, sha, test) {
|
|
544
|
+
if (existing.includes(sha)) return { value: existing, recorded: false, note: 'already recorded' };
|
|
545
|
+
const addition = test ? `${sha} · test: ${test}` : sha;
|
|
546
|
+
const plain = clean(existing).toLowerCase();
|
|
547
|
+
const blank = plain === '' || fmt.empty.has(plain);
|
|
548
|
+
return { value: blank ? addition : `${existing} — ${addition}`, recorded: true, note: null };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function badCell(value, name) {
|
|
552
|
+
if (value == null || value === '') return null;
|
|
553
|
+
if (/[|\n\r]/.test(value)) return `${name} cannot contain a pipe or a newline`;
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function locate(text, slice) {
|
|
558
|
+
const lines = text.split('\n');
|
|
559
|
+
const idAt = (index) => {
|
|
560
|
+
const line = lines[index];
|
|
561
|
+
if (!line?.trim().startsWith('|')) return null;
|
|
562
|
+
return clean(splitRow(line)[slice.idIdx] ?? '');
|
|
563
|
+
};
|
|
564
|
+
if (idAt(slice.line - 1) === slice.id) return { lines, index: slice.line - 1 };
|
|
565
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
566
|
+
if (idAt(i) === slice.id) return { lines, index: i };
|
|
567
|
+
}
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export async function planDone(root, id, { sha = null, test = null, state = null, dryRun = false } = {}) {
|
|
572
|
+
if (!id) return { root, error: 'plan done needs a slice id', remedy: `run: ${CLI} plan next` };
|
|
573
|
+
const shaError = badCell(sha, 'sha');
|
|
574
|
+
if (shaError) return { root, id, error: shaError, remedy: `run: ${CLI} plan done ${id} --yes` };
|
|
575
|
+
const testError = badCell(test, 'test');
|
|
576
|
+
if (testError) return { root, id, error: testError, remedy: `run: ${CLI} plan done ${id} --yes` };
|
|
577
|
+
|
|
578
|
+
const loaded = await loadPlanState(root);
|
|
579
|
+
if (loaded.error) return { root, id, error: loaded.error, remedy: loaded.remedy };
|
|
580
|
+
const { slices, format } = loaded;
|
|
581
|
+
const display = state == null || state === '' ? format.doneWrite : format.canonical(state);
|
|
582
|
+
if (!display) {
|
|
583
|
+
return {
|
|
584
|
+
root,
|
|
585
|
+
id,
|
|
586
|
+
error: `unknown state ${state}`,
|
|
587
|
+
remedy: `states: ${format.stateNames.join(', ')}`,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
const hits = hitsFor(slices, id);
|
|
591
|
+
if (hits.length === 0) return { root, id, ...missing(id) };
|
|
592
|
+
if (hits.length > 1) return { root, id, ...ambiguous(id, hits) };
|
|
593
|
+
const slice = hits[0];
|
|
594
|
+
const found = locate(slice.doc.text, slice);
|
|
595
|
+
if (!found) {
|
|
596
|
+
return { root, id, error: `${id} is no longer on line ${slice.line} of ${slice.docName}`, remedy: `run: ${CLI} plan status` };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const cells = splitRow(found.lines[found.index]);
|
|
600
|
+
const width = Math.max(slice.columnCount, slice.stateIdx + 1, slice.evidenceIdx + 1, cells.length);
|
|
601
|
+
while (cells.length < width) cells.push('');
|
|
602
|
+
const from = cells[slice.stateIdx] ?? '';
|
|
603
|
+
cells[slice.stateIdx] = replaceState(from, display);
|
|
604
|
+
|
|
605
|
+
let evidenceRecorded = false;
|
|
606
|
+
let evidenceNote = null;
|
|
607
|
+
if (sha && slice.evidenceIdx >= 0) {
|
|
608
|
+
const next = evidenceValue(format, cells[slice.evidenceIdx] ?? '', sha, test);
|
|
609
|
+
cells[slice.evidenceIdx] = next.value;
|
|
610
|
+
evidenceRecorded = next.recorded;
|
|
611
|
+
evidenceNote = next.note;
|
|
612
|
+
} else if (sha) {
|
|
613
|
+
evidenceNote = 'no evidence column in this tracker; the sha was not recorded';
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const rebuilt = `| ${cells.join(' | ')} |`;
|
|
617
|
+
const original = found.lines[found.index];
|
|
618
|
+
const changedCells = splitRow(original).join('\0') !== cells.join('\0');
|
|
619
|
+
const newLine = changedCells ? rebuilt : original;
|
|
620
|
+
const wouldChange = newLine !== original;
|
|
621
|
+
if (wouldChange && !dryRun) {
|
|
622
|
+
found.lines[found.index] = newLine;
|
|
623
|
+
await writeFile(slice.docPath, found.lines.join('\n'), 'utf8');
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const apply = `run: ${CLI} plan done ${id}${sha ? ` ${sha}` : ''}${display === format.doneWrite ? '' : ` --state ${JSON.stringify(display)}`}${test ? ` --test ${JSON.stringify(test)}` : ''} --yes`;
|
|
627
|
+
return {
|
|
628
|
+
root,
|
|
629
|
+
id,
|
|
630
|
+
doc: slice.docName,
|
|
631
|
+
file: slice.docPath,
|
|
632
|
+
line: found.index + 1,
|
|
633
|
+
from,
|
|
634
|
+
to: cells[slice.stateIdx],
|
|
635
|
+
state: format.readState(display),
|
|
636
|
+
wouldChange,
|
|
637
|
+
changed: wouldChange && !dryRun,
|
|
638
|
+
dryRun,
|
|
639
|
+
evidenceRecorded,
|
|
640
|
+
evidenceNote,
|
|
641
|
+
sha: sha || null,
|
|
642
|
+
test: test || null,
|
|
643
|
+
remedy: wouldChange && dryRun ? apply : null,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export function formatCounts(counts, order = []) {
|
|
648
|
+
const keys = rankCounts(counts, order);
|
|
649
|
+
return keys.map((key) => `${key}=${counts[key]}`).join(' ');
|
|
650
|
+
}
|