simt-dev 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/CHANGELOG.md +37 -0
- package/bin/simt-dev.mjs +21 -2
- 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/src/agenthooks/decide.mjs +123 -0
- package/src/agenthooks/policy.mjs +78 -0
- package/src/agenthooks/render.mjs +240 -0
- package/src/argv.mjs +36 -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/harness.mjs +156 -5
- package/src/commands/help.mjs +58 -1
- package/src/commands/index.mjs +25 -1
- package/src/commands/plan.mjs +99 -0
- package/src/commands/tokens.mjs +31 -0
- package/src/decisions/index.mjs +23 -5
- package/src/kernel/card.mjs +254 -0
- package/src/kernel/inferability.mjs +188 -0
- package/src/mcp/resources.mjs +157 -0
- package/src/mcp/server.mjs +37 -6
- package/src/mcp/tools.mjs +84 -0
- package/src/measure/context.mjs +177 -6
- package/src/measure/loaderView.mjs +435 -0
- package/src/measure/session.mjs +267 -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 +26 -3
- package/src/projection/rules.mjs +80 -5
- package/src/projection/surfaces.mjs +22 -8
- package/src/toolwords.mjs +69 -0
- package/templates/plan/tracker.json +36 -0
package/src/mcp/tools.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import { discover } from '../compare/discover.mjs';
|
|
|
27
27
|
import { buildRow, summarise } from '../compare/row.mjs';
|
|
28
28
|
import { GATES } from '../hooks/gates.mjs';
|
|
29
29
|
import { dirtyPaths, parsePathList, readBody } from '../commands/skills.mjs';
|
|
30
|
+
import { planNext, planDone } from '../plan/index.mjs';
|
|
30
31
|
import { resolveWorkspace } from '../workspace.mjs';
|
|
31
32
|
import { measureSkill } from '../graph/load.mjs';
|
|
32
33
|
import { CLI } from '../name.mjs';
|
|
@@ -308,6 +309,30 @@ const output = {
|
|
|
308
309
|
},
|
|
309
310
|
required: ['root', 'shape', 'expectedSha', 'summary', 'repos'],
|
|
310
311
|
},
|
|
312
|
+
plan_next: {
|
|
313
|
+
type: 'object',
|
|
314
|
+
properties: {
|
|
315
|
+
root: { type: 'string' },
|
|
316
|
+
next: { type: ['object', 'null'] },
|
|
317
|
+
reason: { type: ['string', 'null'] },
|
|
318
|
+
},
|
|
319
|
+
required: ['root', 'next', 'reason'],
|
|
320
|
+
},
|
|
321
|
+
plan_done: {
|
|
322
|
+
type: 'object',
|
|
323
|
+
properties: {
|
|
324
|
+
root: { type: 'string' },
|
|
325
|
+
id: { type: 'string' },
|
|
326
|
+
doc: { type: 'string' },
|
|
327
|
+
line: { type: 'number' },
|
|
328
|
+
from: { type: 'string' },
|
|
329
|
+
to: { type: 'string' },
|
|
330
|
+
wouldChange: { type: 'boolean' },
|
|
331
|
+
dryRun: { type: 'boolean' },
|
|
332
|
+
remedy: { type: ['string', 'null'] },
|
|
333
|
+
},
|
|
334
|
+
required: ['root', 'id', 'wouldChange', 'dryRun'],
|
|
335
|
+
},
|
|
311
336
|
/** The shape of every tool failure, which is a result and not a protocol error. */
|
|
312
337
|
error: ERROR_SHAPE,
|
|
313
338
|
};
|
|
@@ -688,6 +713,65 @@ export const TOOLS = [
|
|
|
688
713
|
};
|
|
689
714
|
},
|
|
690
715
|
},
|
|
716
|
+
{
|
|
717
|
+
outputSchema: output.plan_next,
|
|
718
|
+
name: 'plan_next',
|
|
719
|
+
description: 'The next queued slice whose dependencies are complete, and only that slice\'s section. Pass id to read one slice whether or not it is ready.',
|
|
720
|
+
inputSchema: {
|
|
721
|
+
type: 'object',
|
|
722
|
+
properties: {
|
|
723
|
+
root: { type: 'string', description: 'repository root; defaults to the editor cwd' },
|
|
724
|
+
id: { type: 'string', description: 'slice id; when given, return that slice' },
|
|
725
|
+
},
|
|
726
|
+
required: [],
|
|
727
|
+
},
|
|
728
|
+
async run(args) {
|
|
729
|
+
const root = rootOf(args);
|
|
730
|
+
const id = typeof args?.id === 'string' && args.id.length > 0 ? args.id : null;
|
|
731
|
+
const result = await planNext(root, id);
|
|
732
|
+
if (result.error) throw Object.assign(new Error(result.error), { remedy: result.remedy ?? null });
|
|
733
|
+
return { root: result.root, next: result.next, reason: result.reason };
|
|
734
|
+
},
|
|
735
|
+
},
|
|
736
|
+
{
|
|
737
|
+
outputSchema: output.plan_done,
|
|
738
|
+
name: 'plan_done',
|
|
739
|
+
// Same function as the command, and deliberately a preview. This surface does
|
|
740
|
+
// not write: the row change is a diff, and the command applies it behind --yes.
|
|
741
|
+
description: 'Preview the one-row rewrite for a slice. The write stays on the plan done command, behind --yes.',
|
|
742
|
+
inputSchema: {
|
|
743
|
+
type: 'object',
|
|
744
|
+
properties: {
|
|
745
|
+
root: { type: 'string', description: 'repository root; defaults to the editor cwd' },
|
|
746
|
+
id: { type: 'string', description: 'slice id' },
|
|
747
|
+
sha: { type: 'string', description: 'commit sha that closed the slice' },
|
|
748
|
+
test: { type: 'string', description: 'test command that closed it' },
|
|
749
|
+
state: { type: 'string', description: 'target state; omitted means the completed state of the repository template (Complete for tracker)' },
|
|
750
|
+
},
|
|
751
|
+
required: ['id'],
|
|
752
|
+
},
|
|
753
|
+
async run(args) {
|
|
754
|
+
const root = rootOf(args);
|
|
755
|
+
const result = await planDone(root, args?.id, {
|
|
756
|
+
sha: args?.sha ?? null,
|
|
757
|
+
test: args?.test ?? null,
|
|
758
|
+
state: args?.state ?? null,
|
|
759
|
+
dryRun: true,
|
|
760
|
+
});
|
|
761
|
+
if (result.error) throw Object.assign(new Error(result.error), { remedy: result.remedy ?? null });
|
|
762
|
+
return {
|
|
763
|
+
root: result.root,
|
|
764
|
+
id: result.id,
|
|
765
|
+
doc: result.doc,
|
|
766
|
+
line: result.line,
|
|
767
|
+
from: result.from,
|
|
768
|
+
to: result.to,
|
|
769
|
+
wouldChange: result.wouldChange,
|
|
770
|
+
dryRun: true,
|
|
771
|
+
remedy: result.remedy,
|
|
772
|
+
};
|
|
773
|
+
},
|
|
774
|
+
},
|
|
691
775
|
];
|
|
692
776
|
|
|
693
777
|
export function toolNames() {
|
package/src/measure/context.mjs
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// 4. Write anything. `compare` is read-only for the same reason - a measurement
|
|
22
22
|
// that can change what it measures is not a measurement.
|
|
23
23
|
|
|
24
|
-
import { readFile, readdir } from 'node:fs/promises';
|
|
24
|
+
import { lstat, readFile, readdir } from 'node:fs/promises';
|
|
25
25
|
import { basename, join } from 'node:path';
|
|
26
26
|
|
|
27
27
|
import { resolveSurfaces } from '../projection/surfaces.mjs';
|
|
@@ -29,6 +29,8 @@ import { parse } from '../projection/markers.mjs';
|
|
|
29
29
|
import { budgetFinding, DEFAULT_BUDGET_BYTES, readManifest } from '../projection/manifest.mjs';
|
|
30
30
|
import { extractFrontmatter } from '../projection/targets.mjs';
|
|
31
31
|
import { readLimitFindings } from '../projection/project.mjs';
|
|
32
|
+
import { loader } from '../projection/loaders.mjs';
|
|
33
|
+
import { expandClaudeImports, viewOf } from './loaderView.mjs';
|
|
32
34
|
import { TOOLS } from '../mcp/tools.mjs';
|
|
33
35
|
|
|
34
36
|
/**
|
|
@@ -98,7 +100,7 @@ async function readText(path) {
|
|
|
98
100
|
* guess about nesting; it is the bound that keeps a stray `node_modules` inside a
|
|
99
101
|
* tool-owned directory from turning a byte count into an unbounded walk.
|
|
100
102
|
*/
|
|
101
|
-
async function filesUnder(root, dir, suffix, depth = 0) {
|
|
103
|
+
async function filesUnder(root, dir, suffix, depth = 0, maxDepth = 6) {
|
|
102
104
|
let entries;
|
|
103
105
|
try {
|
|
104
106
|
entries = await readdir(join(root, dir), { withFileTypes: true });
|
|
@@ -112,8 +114,8 @@ async function filesUnder(root, dir, suffix, depth = 0) {
|
|
|
112
114
|
const entry = entries.find((e) => e.name === name);
|
|
113
115
|
const path = `${dir}/${name}`;
|
|
114
116
|
if (entry.isDirectory()) {
|
|
115
|
-
if (depth >=
|
|
116
|
-
out.push(...(await filesUnder(root, `${path}`, suffix, depth + 1)));
|
|
117
|
+
if (depth >= maxDepth) continue;
|
|
118
|
+
out.push(...(await filesUnder(root, `${path}`, suffix, depth + 1, maxDepth)));
|
|
117
119
|
} else if (entry.isFile() && (!suffix || name.endsWith(suffix))) {
|
|
118
120
|
out.push(path);
|
|
119
121
|
}
|
|
@@ -121,6 +123,46 @@ async function filesUnder(root, dir, suffix, depth = 0) {
|
|
|
121
123
|
return out;
|
|
122
124
|
}
|
|
123
125
|
|
|
126
|
+
/** True when `rel` under `root` is a regular file and not a symlink. */
|
|
127
|
+
async function isRegularFile(root, rel) {
|
|
128
|
+
try {
|
|
129
|
+
const st = await lstat(join(root, rel));
|
|
130
|
+
return st.isFile() && !st.isSymbolicLink();
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Generated span bytes of a document (0 when the file carries no valid region). */
|
|
137
|
+
function generatedSpanBytes(text) {
|
|
138
|
+
const state = parse(text);
|
|
139
|
+
if (state.status !== 'ok') return 0;
|
|
140
|
+
const open = state.before.length === 0 ? 0 : state.before.length + 1;
|
|
141
|
+
const close = state.after.length === 0 ? text.length : text.length - state.after.length - 1;
|
|
142
|
+
return bytes(text.slice(open, close));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Whether a rule file loads unconditionally at session start according to the
|
|
147
|
+
* reader's loader row (R14).
|
|
148
|
+
*
|
|
149
|
+
* A file with no frontmatter block at all is treated as unconditional (e.g. a
|
|
150
|
+
* `.claude/rules/*.md` file or a bare `.mdc`); a file that declares frontmatter is
|
|
151
|
+
* filtered against the loader row's `frontmatter` requirement so scoped rules
|
|
152
|
+
* (`alwaysApply: false`, `trigger: glob`, `trigger: model_decision`, path-specific
|
|
153
|
+
* `applyTo`, or Claude `paths:`) are not billed to session start.
|
|
154
|
+
*/
|
|
155
|
+
function isAlwaysOnRule(surfaceId, ruleEntry, text) {
|
|
156
|
+
const block = extractFrontmatter(text);
|
|
157
|
+
if (!block) return true;
|
|
158
|
+
const meta = frontmatter(text);
|
|
159
|
+
if (surfaceId === 'claude' && meta.paths) return false;
|
|
160
|
+
if (ruleEntry?.frontmatter) {
|
|
161
|
+
return Object.entries(ruleEntry.frontmatter).every(([k, v]) => meta[k] === v);
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
124
166
|
/** Frontmatter keys, as an object. One block, no nesting - which is all a SKILL.md has. */
|
|
125
167
|
function frontmatter(text) {
|
|
126
168
|
const block = extractFrontmatter(text);
|
|
@@ -176,13 +218,68 @@ async function documentsFor(root, surface) {
|
|
|
176
218
|
}
|
|
177
219
|
}
|
|
178
220
|
|
|
221
|
+
if (surface.id === 'claude') {
|
|
222
|
+
const claudeLoader = loader('claude');
|
|
223
|
+
const seenImports = new Set();
|
|
224
|
+
for (const candidate of ['CLAUDE.md', 'CLAUDE.local.md']) {
|
|
225
|
+
if (!(await isRegularFile(root, candidate))) continue;
|
|
226
|
+
const text = await readText(join(root, candidate));
|
|
227
|
+
if (text === null) continue;
|
|
228
|
+
seenImports.add(candidate);
|
|
229
|
+
out.push({
|
|
230
|
+
channel: 'rules',
|
|
231
|
+
file: candidate,
|
|
232
|
+
text,
|
|
233
|
+
generatedBytes: generatedSpanBytes(text),
|
|
234
|
+
readerBytes: bytes(text),
|
|
235
|
+
});
|
|
236
|
+
const depth = claudeLoader?.imports?.depth ?? 4;
|
|
237
|
+
for (const imp of await expandClaudeImports(root, candidate, text, depth, seenImports)) {
|
|
238
|
+
out.push({
|
|
239
|
+
channel: 'imports',
|
|
240
|
+
file: imp.file,
|
|
241
|
+
text: imp.text,
|
|
242
|
+
generatedBytes: generatedSpanBytes(imp.text),
|
|
243
|
+
readerBytes: bytes(imp.text),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (surface.id === 'copilot' && (await isRegularFile(root, '.github/copilot-instructions.md'))) {
|
|
250
|
+
const rel = '.github/copilot-instructions.md';
|
|
251
|
+
const text = await readText(join(root, rel));
|
|
252
|
+
if (text !== null) {
|
|
253
|
+
out.push({
|
|
254
|
+
channel: 'rules',
|
|
255
|
+
file: rel,
|
|
256
|
+
text,
|
|
257
|
+
generatedBytes: generatedSpanBytes(text),
|
|
258
|
+
readerBytes: bytes(text),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
179
263
|
if (surface.rule) {
|
|
180
264
|
const dir = surface.rule.slice(0, surface.rule.lastIndexOf('/'));
|
|
181
265
|
const suffix = surface.rule.slice(surface.rule.lastIndexOf('.') + 1);
|
|
182
|
-
|
|
266
|
+
const row = loader(surface.id);
|
|
267
|
+
const ruleEntry = row?.start.find((e) => e.dir === dir && e.atStart) ?? null;
|
|
268
|
+
// Antigravity scans `.agents/rules/` flat (`tree: 'flat'`) and ignores nested
|
|
269
|
+
// directories; Cursor, Claude and Copilot walk subdirectories (`tree: 'nested'`).
|
|
270
|
+
const maxDepth = ruleEntry?.tree === 'flat' ? 0 : 6;
|
|
271
|
+
for (const file of await filesUnder(root, dir, `.${suffix}`, 0, maxDepth)) {
|
|
183
272
|
const text = await readText(join(root, file));
|
|
184
273
|
if (text === null) continue;
|
|
185
|
-
|
|
274
|
+
if (!isAlwaysOnRule(surface.id, ruleEntry, text)) continue;
|
|
275
|
+
const isGenerated = parse(text).status === 'ok';
|
|
276
|
+
out.push({
|
|
277
|
+
channel: 'rules',
|
|
278
|
+
file,
|
|
279
|
+
text,
|
|
280
|
+
generatedBytes: isGenerated ? bytes(text) : 0,
|
|
281
|
+
readerBytes: bytes(text),
|
|
282
|
+
});
|
|
186
283
|
}
|
|
187
284
|
}
|
|
188
285
|
|
|
@@ -268,6 +365,52 @@ export async function measureRoot(root) {
|
|
|
268
365
|
}
|
|
269
366
|
}
|
|
270
367
|
|
|
368
|
+
// R14 chain and per-file caps: Codex's 32 KiB applies to the merged root-to-cwd
|
|
369
|
+
// chain, so a repository where every individual AGENTS.md sits under 32 KiB can still
|
|
370
|
+
// drop its deepest file once merged; Antigravity's 24,000 B cap applies per rule file.
|
|
371
|
+
const codexLoader = loader('codex');
|
|
372
|
+
if (codexLoader?.cap?.scope === 'chain') {
|
|
373
|
+
for (const dir of await instructionDirs(root)) {
|
|
374
|
+
const view = await viewOf(root, codexLoader, { cwd: join(root, dir) });
|
|
375
|
+
if (!view.cap?.over) continue;
|
|
376
|
+
// If a single root AGENTS.md already tripped `readLimitFindings` above, do not
|
|
377
|
+
// report the same 1-file root overflow twice.
|
|
378
|
+
if (view.files.length === 1 && readLimits.some((r) => r.file === view.files[0].file && r.reader === 'Codex')) {
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
const chainLabel = view.files.map((f) => f.file).join(' -> ');
|
|
382
|
+
const overBy = view.cap.totalBytes - view.cap.bytes;
|
|
383
|
+
readLimits.push({
|
|
384
|
+
file: chainLabel,
|
|
385
|
+
reader: 'Codex',
|
|
386
|
+
limit: view.cap.bytes,
|
|
387
|
+
endByte: view.cap.totalBytes,
|
|
388
|
+
overBy,
|
|
389
|
+
});
|
|
390
|
+
findings.push(
|
|
391
|
+
`${chainLabel}: BEYOND-READ-LIMIT — merged chain is ${view.cap.totalBytes} B, ${overBy} B past Codex's ${view.cap.bytes} B (${view.cap.config}) chain limit`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const antigravityCap = loader('antigravity')?.cap;
|
|
397
|
+
if (antigravityCap?.scope === 'file') {
|
|
398
|
+
for (const doc of documents) {
|
|
399
|
+
if (!doc.file.startsWith('.agents/rules/') || doc.readerBytes <= antigravityCap.bytes) continue;
|
|
400
|
+
const overBy = doc.readerBytes - antigravityCap.bytes;
|
|
401
|
+
readLimits.push({
|
|
402
|
+
file: doc.file,
|
|
403
|
+
reader: 'Antigravity',
|
|
404
|
+
limit: antigravityCap.bytes,
|
|
405
|
+
endByte: doc.readerBytes,
|
|
406
|
+
overBy,
|
|
407
|
+
});
|
|
408
|
+
findings.push(
|
|
409
|
+
`${doc.file}: BEYOND-READ-LIMIT — rule file is ${doc.readerBytes} B, ${overBy} B past Antigravity's ${antigravityCap.bytes} B per-file limit`,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
271
414
|
const name = root.split('/').filter(Boolean).pop() ?? root;
|
|
272
415
|
return {
|
|
273
416
|
name,
|
|
@@ -368,4 +511,32 @@ export function totals(rows) {
|
|
|
368
511
|
repeatBytes,
|
|
369
512
|
overBudget: rows.filter((r) => r.budget.over).map((r) => r.name),
|
|
370
513
|
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Directories under `root` that carry an `AGENTS.md` or `AGENTS.override.md` beyond
|
|
518
|
+
* a lone root `AGENTS.md`, sorted for determinism.
|
|
519
|
+
*/
|
|
520
|
+
async function instructionDirs(root, rel = '', depth = 0, out = []) {
|
|
521
|
+
let entries;
|
|
522
|
+
try {
|
|
523
|
+
entries = await readdir(join(root, rel), { withFileTypes: true });
|
|
524
|
+
} catch {
|
|
525
|
+
return out;
|
|
526
|
+
}
|
|
527
|
+
const names = entries.map((e) => e.name).sort();
|
|
528
|
+
const hasOverride = names.includes('AGENTS.override.md');
|
|
529
|
+
const hasAgents = names.includes('AGENTS.md');
|
|
530
|
+
if ((rel !== '' && (hasAgents || hasOverride)) || (rel === '' && hasOverride)) {
|
|
531
|
+
out.push(rel);
|
|
532
|
+
}
|
|
533
|
+
if (depth >= 4) return out;
|
|
534
|
+
for (const name of names) {
|
|
535
|
+
if (name.startsWith('.') || name === 'node_modules' || name === 'dist' || name === 'build') continue;
|
|
536
|
+
const entry = entries.find((e) => e.name === name);
|
|
537
|
+
if (entry?.isDirectory()) {
|
|
538
|
+
await instructionDirs(root, rel === '' ? name : `${rel}/${name}`, depth + 1, out);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return out;
|
|
371
542
|
}
|