simt-dev 0.2.2 → 0.3.1
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 +100 -0
- package/README.md +129 -75
- package/bin/simt-dev.mjs +21 -2
- package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
- 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/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
- package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
- package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
- package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
- package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
- package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
- package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
- package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
- package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
- package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
- package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
- package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
- package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
- package/package.json +2 -1
- package/rules/core.md +1 -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 +54 -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/ci/workflow.mjs +11 -1
- package/src/commands/agenthook.mjs +282 -0
- package/src/commands/bench.mjs +371 -0
- package/src/commands/evidence.mjs +96 -0
- package/src/commands/gate.mjs +165 -0
- package/src/commands/harness.mjs +156 -5
- package/src/commands/help.mjs +89 -1
- package/src/commands/hooks.mjs +45 -5
- package/src/commands/impact.mjs +205 -0
- package/src/commands/index.mjs +60 -1
- package/src/commands/map.mjs +91 -0
- package/src/commands/plan.mjs +99 -0
- package/src/commands/repoindex.mjs +52 -0
- package/src/commands/shared.mjs +398 -0
- package/src/commands/sync.mjs +72 -3
- package/src/commands/tests.mjs +192 -0
- package/src/commands/tokens.mjs +61 -1
- package/src/commands/why.mjs +177 -0
- package/src/decisions/index.mjs +23 -5
- package/src/graph/spec.mjs +1 -1
- package/src/hooks/dispatcher.mjs +15 -0
- package/src/hooks/shim.mjs +31 -0
- package/src/index/compact.mjs +228 -0
- package/src/index/extract.mjs +331 -0
- package/src/index/gate.mjs +101 -0
- package/src/index/govern.mjs +222 -0
- package/src/index/impact.mjs +186 -0
- package/src/index/map.mjs +393 -0
- package/src/index/persist.mjs +172 -0
- package/src/index/store.mjs +301 -0
- package/src/index/tests.mjs +294 -0
- package/src/kernel/budget.mjs +100 -0
- package/src/kernel/card.mjs +254 -0
- package/src/kernel/checks.mjs +378 -0
- package/src/kernel/inferability.mjs +188 -0
- package/src/ledger/index.mjs +112 -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 +184 -11
- 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/disclosure.mjs +92 -0
- package/src/projection/loaders.mjs +689 -0
- package/src/projection/lock.mjs +93 -0
- package/src/projection/manifest.mjs +78 -3
- package/src/projection/markers.mjs +56 -17
- package/src/projection/project.mjs +24 -17
- package/src/projection/rules.mjs +452 -43
- package/src/projection/shared.mjs +428 -0
- package/src/projection/surfaces.mjs +22 -8
- package/src/projection/targets.mjs +2 -1
- package/src/toolwords.mjs +71 -0
- package/templates/plan/tracker.json +36 -0
package/src/commands/hooks.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import { promisify } from 'node:util';
|
|
|
19
19
|
|
|
20
20
|
import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
|
|
21
21
|
import { MANIFEST, readManifest } from '../projection/manifest.mjs';
|
|
22
|
-
import { EVENTS, HOOKS_DIR, HOOKS_PATH_KEY, fileFor, renderChainedShim, renderShim } from '../hooks/shim.mjs';
|
|
22
|
+
import { EVENTS, HOOKS_DIR, HOOKS_PATH_KEY, SHIM_MARKER, fileFor, renderChainedShim, renderEvidenceShim, renderShim } from '../hooks/shim.mjs';
|
|
23
23
|
import { GATE_KEY, gateCount, readHooksPath, readHooksState, wantsShim } from '../compare/hooksstate.mjs';
|
|
24
24
|
import { GATES } from '../hooks/gates.mjs';
|
|
25
25
|
import { knownGates } from '../hooks/resolve.mjs';
|
|
@@ -222,11 +222,29 @@ async function installHooks(root, flags) {
|
|
|
222
222
|
(i) => i.present && i.ours && !wanted.includes(i.event) && !chained.some((c) => c.event === i.event),
|
|
223
223
|
);
|
|
224
224
|
|
|
225
|
+
// The claim check is not one of EVENTS. Those shims do not forward git's
|
|
226
|
+
// arguments, and commit-msg's only argument is the message. It is installed
|
|
227
|
+
// when the repository sets gates.evidence, and removed when that setting goes,
|
|
228
|
+
// because a shim that stays after the setting left still replaces git's own hook.
|
|
229
|
+
const evidenceDeclared = manifest?.gates?.evidence === 'warn' || manifest?.gates?.evidence === 'block';
|
|
230
|
+
const evidenceFile = fileFor('commit-msg');
|
|
231
|
+
const evidenceExisting = await readIfExists(join(root, evidenceFile));
|
|
232
|
+
const evidenceSource = evidenceDeclared ? renderEvidenceShim({ npm, version }) : null;
|
|
233
|
+
const evidenceOurs = evidenceExisting !== null && evidenceExisting.includes(SHIM_MARKER);
|
|
234
|
+
if (!evidenceDeclared && evidenceOurs) obsolete.push({ event: 'commit-msg' });
|
|
235
|
+
// A foreign commit-msg would otherwise be chained onto the same path. The chain
|
|
236
|
+
// is written after this shim, so it would replace the claim check and the check
|
|
237
|
+
// would never run. The repository's hook stays on disk and the note says it stopped.
|
|
238
|
+
if (evidenceDeclared) {
|
|
239
|
+
const at = chained.findIndex((c) => c.event === 'commit-msg');
|
|
240
|
+
if (at !== -1) chained.splice(at, 1);
|
|
241
|
+
}
|
|
242
|
+
|
|
225
243
|
// Nothing to write and nothing to take away is the only refusal. It sits after
|
|
226
244
|
// `obsolete` because a repository whose last gate just left the manifest has
|
|
227
245
|
// something to take away, and blocking that one left no way out except undoing
|
|
228
246
|
// the change by hand - see the note on `obsolete`.
|
|
229
|
-
if (wanted.length === 0 && obsolete.length === 0) {
|
|
247
|
+
if (wanted.length === 0 && obsolete.length === 0 && !evidenceDeclared) {
|
|
230
248
|
if (isJson(flags)) {
|
|
231
249
|
emitJson({ ok: false, command: 'hooks install', root, nothingToEnforce: true, filesWritten: false });
|
|
232
250
|
return EXIT.FAIL;
|
|
@@ -276,10 +294,20 @@ async function installHooks(root, flags) {
|
|
|
276
294
|
// Named in the plan because the reader approving this write is about to lose a
|
|
277
295
|
// file, and "REMOVED pre-push" does not say why - which is the difference between
|
|
278
296
|
// agreeing with it and discovering later that a gate had been dropped by accident.
|
|
279
|
-
note:
|
|
297
|
+
note:
|
|
298
|
+
h.event === 'commit-msg'
|
|
299
|
+
? `gates.evidence is unset, so the claim check stopped`
|
|
300
|
+
: `no gate for ${h.event} in ${MANIFEST_FILE}; the shim enforced nothing`,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
if (evidenceSource) {
|
|
304
|
+
plan.push({
|
|
305
|
+
file: evidenceFile,
|
|
306
|
+
action: evidenceExisting === evidenceSource ? 'unchanged' : evidenceExisting === null ? 'created' : 'updated',
|
|
307
|
+
note: 'commit-msg checks a verification claim against the ledger',
|
|
280
308
|
});
|
|
281
309
|
}
|
|
282
|
-
const routed = wanted.length > 0 || chained.length > 0;
|
|
310
|
+
const routed = wanted.length > 0 || chained.length > 0 || evidenceDeclared;
|
|
283
311
|
|
|
284
312
|
// The routing this install leaves behind, which is not always `wants`. A repository
|
|
285
313
|
// that removed its last gate has no shims to route to, and leaving `core.hooksPath`
|
|
@@ -362,6 +390,12 @@ async function installHooks(root, flags) {
|
|
|
362
390
|
for (const h of obsolete) {
|
|
363
391
|
await rm(join(root, fileFor(h.event)));
|
|
364
392
|
}
|
|
393
|
+
// After the chain loop, so a chained commit-msg cannot replace the claim check.
|
|
394
|
+
if (evidenceSource) {
|
|
395
|
+
const path = join(root, evidenceFile);
|
|
396
|
+
await writeFile(path, evidenceSource);
|
|
397
|
+
await chmod(path, 0o755);
|
|
398
|
+
}
|
|
365
399
|
|
|
366
400
|
// `core.hooksPath` tells git to read ONE directory for EVERY event, so leaving it
|
|
367
401
|
// pointing at a directory of no shims stops the repository's own hooks - the ones in
|
|
@@ -385,6 +419,10 @@ async function installHooks(root, flags) {
|
|
|
385
419
|
notes.push(
|
|
386
420
|
`chained ${f.event}: ${fileFor(f.event)} runs ${f.path} (${f.bytes} bytes), so it keeps running`,
|
|
387
421
|
);
|
|
422
|
+
} else if (f.event === 'commit-msg' && evidenceDeclared) {
|
|
423
|
+
notes.push(
|
|
424
|
+
`stopped running commit-msg at ${f.path} (${f.bytes} bytes); the claim check replaced it, and the file is still on disk, untouched`,
|
|
425
|
+
);
|
|
388
426
|
} else if (leftRunning.some((c) => c.event === f.event)) {
|
|
389
427
|
// A third case, and the one that was misreported: a hook this tool could not chain
|
|
390
428
|
// because the shim would have had to run itself. Nothing overwrote it, so it still
|
|
@@ -403,7 +441,9 @@ async function installHooks(root, flags) {
|
|
|
403
441
|
}
|
|
404
442
|
for (const h of obsolete) {
|
|
405
443
|
notes.push(
|
|
406
|
-
|
|
444
|
+
h.event === 'commit-msg'
|
|
445
|
+
? `removed ${fileFor(h.event)}: ${MANIFEST_FILE} no longer sets gates.evidence, so the claim check stopped replacing git's own commit-msg`
|
|
446
|
+
: `removed ${fileFor(h.event)}: ${MANIFEST_FILE} declares no ${h.event} gate, so the shim enforced nothing and still replaced git's own hook`,
|
|
407
447
|
);
|
|
408
448
|
}
|
|
409
449
|
if (leaves === null && current !== null) {
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// `simt impact` reports who a file or an export reaches (R23, ADR-096).
|
|
2
|
+
//
|
|
3
|
+
// Importers, the tests that cover them, the decisions and rules that govern
|
|
4
|
+
// the file, the CODEOWNERS line, and — when a workspace is configured —
|
|
5
|
+
// another repository that references a contract this change touches.
|
|
6
|
+
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
import { readFile } from 'node:fs/promises';
|
|
9
|
+
import { basename, join, resolve } from 'node:path';
|
|
10
|
+
import { promisify } from 'node:util';
|
|
11
|
+
|
|
12
|
+
import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
|
|
13
|
+
import { CLI } from '../name.mjs';
|
|
14
|
+
import { discover } from '../compare/discover.mjs';
|
|
15
|
+
import { extract } from '../index/extract.mjs';
|
|
16
|
+
import { importEdges, readEntries } from '../index/gate.mjs';
|
|
17
|
+
import {
|
|
18
|
+
contractReferences,
|
|
19
|
+
dependents,
|
|
20
|
+
filesExporting,
|
|
21
|
+
isContractPath,
|
|
22
|
+
ownersOf,
|
|
23
|
+
relatedContracts,
|
|
24
|
+
} from '../index/impact.mjs';
|
|
25
|
+
import { decisionHits, scopeHits, sortHits, sourceRoots } from '../index/govern.mjs';
|
|
26
|
+
import { decisionRecords, ruleScopes } from './why.mjs';
|
|
27
|
+
import { resolveWorkspace } from '../workspace.mjs';
|
|
28
|
+
|
|
29
|
+
const exec = promisify(execFile);
|
|
30
|
+
const TEXT_EXT = new Set(['js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'json', 'yaml', 'yml', 'md', 'toml', 'xml', 'py', 'go', 'kt', 'java', 'rs']);
|
|
31
|
+
const READ_CAP = 256 * 1024;
|
|
32
|
+
const FILE_CAP = 2000;
|
|
33
|
+
|
|
34
|
+
export async function impactCommand(args, flags, commands = []) {
|
|
35
|
+
if (args.length > 1) return fail(`unknown impact argument ${args[1]}`, EXIT.USAGE, `run: ${CLI} impact <file|symbol>`);
|
|
36
|
+
const target = args[0];
|
|
37
|
+
if (!target) return fail(`a file or a symbol is required`, EXIT.USAGE, `run: ${CLI} impact <file|symbol>`);
|
|
38
|
+
const root = flags.root ?? process.cwd();
|
|
39
|
+
const listed = await tracked(root);
|
|
40
|
+
if (listed.error) return listed.error;
|
|
41
|
+
const files = new Set(listed.files);
|
|
42
|
+
const entries = await readEntries(root, files);
|
|
43
|
+
const imports = importEdges(entries);
|
|
44
|
+
const symbols = entries.flatMap((entry) => (entry.lang ? extract(entry.path, entry.text, entry.lang).symbols : []));
|
|
45
|
+
const resolved = resolveTarget(target, files, symbols);
|
|
46
|
+
if (resolved.error) return resolved.error;
|
|
47
|
+
const reach = dependents(resolved.file, imports);
|
|
48
|
+
const governs = await governance(root, resolved.file, files, commands);
|
|
49
|
+
const owners = await readOwners(root, resolved.file);
|
|
50
|
+
const contracts = await contractsFor(root, listed.files, resolved, reach.importers);
|
|
51
|
+
const consumers = await consumersFor(root, flags, basename(root), contracts);
|
|
52
|
+
const truncated = reach.truncated || consumers.truncated;
|
|
53
|
+
const payload = {
|
|
54
|
+
ok: true,
|
|
55
|
+
command: 'impact',
|
|
56
|
+
root,
|
|
57
|
+
file: resolved.file,
|
|
58
|
+
symbol: resolved.symbol,
|
|
59
|
+
importers: reach.importers,
|
|
60
|
+
tests: reach.tests,
|
|
61
|
+
governs,
|
|
62
|
+
owners,
|
|
63
|
+
consumers: consumers.references,
|
|
64
|
+
truncated,
|
|
65
|
+
};
|
|
66
|
+
if (isJson(flags)) {
|
|
67
|
+
emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
|
|
68
|
+
return EXIT.OK;
|
|
69
|
+
}
|
|
70
|
+
emit(render(payload), { maxBytes: flags.maxBytes ?? 0 });
|
|
71
|
+
return EXIT.OK;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resolveTarget(target, files, symbols) {
|
|
75
|
+
if (files.has(target)) return { file: target, symbol: '' };
|
|
76
|
+
const exported = filesExporting(target, symbols);
|
|
77
|
+
if (exported.length === 1) return { file: exported[0], symbol: target };
|
|
78
|
+
if (exported.length === 0) {
|
|
79
|
+
return { error: fail(`not a file or an export: ${target}`, EXIT.USAGE, `pass a path git tracks, or the name of one export`) };
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
error: fail(
|
|
83
|
+
`${target} is exported by ${exported.join(', ')}`,
|
|
84
|
+
EXIT.USAGE,
|
|
85
|
+
`pass the path of the export you mean`,
|
|
86
|
+
),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function governance(root, file, files, commands) {
|
|
91
|
+
const records = await decisionRecords(root);
|
|
92
|
+
const rules = await ruleScopes(root);
|
|
93
|
+
const ctx = { files, commands: new Set(commands), roots: sourceRoots(files) };
|
|
94
|
+
const { hits } = decisionHits(records, file, ctx);
|
|
95
|
+
return sortHits([...hits, ...scopeHits(file, { rules })]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function readOwners(root, file) {
|
|
99
|
+
for (const path of ['.github/CODEOWNERS', 'CODEOWNERS', 'docs/CODEOWNERS']) {
|
|
100
|
+
try {
|
|
101
|
+
return ownersOf(await readFile(join(root, path), 'utf8'), file);
|
|
102
|
+
} catch {
|
|
103
|
+
// The next location may hold the file. GitHub reads the first that exists.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function contractsFor(root, files, resolved, importers) {
|
|
110
|
+
const contracts = [];
|
|
111
|
+
for (const path of files) {
|
|
112
|
+
if (!isContractPath(path) && !mightSniff(path)) continue;
|
|
113
|
+
let text = '';
|
|
114
|
+
try {
|
|
115
|
+
text = await readFile(join(root, path), 'utf8');
|
|
116
|
+
} catch {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (!isContractPath(path) && !sniffs(text)) continue;
|
|
120
|
+
contracts.push({ path, text });
|
|
121
|
+
}
|
|
122
|
+
return relatedContracts(contracts, { file: resolved.file, symbol: resolved.symbol, importers });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function consumersFor(root, flags, repoName, contracts) {
|
|
126
|
+
if (contracts.length === 0) return { references: [], truncated: false };
|
|
127
|
+
const workspace = await resolveWorkspace(flags);
|
|
128
|
+
if (!workspace.root) return { references: [], truncated: false };
|
|
129
|
+
const found = await discover(workspace.root);
|
|
130
|
+
if (found.shape !== 'workspace') return { references: [], truncated: false };
|
|
131
|
+
const here = resolve(root);
|
|
132
|
+
const files = [];
|
|
133
|
+
let truncated = false;
|
|
134
|
+
for (const repo of found.repos) {
|
|
135
|
+
if (resolve(repo) === here) continue;
|
|
136
|
+
const listed = await tracked(repo);
|
|
137
|
+
if (listed.error) continue;
|
|
138
|
+
const name = basename(repo);
|
|
139
|
+
let read = 0;
|
|
140
|
+
for (const path of listed.files) {
|
|
141
|
+
if (!textExt(path)) continue;
|
|
142
|
+
if (read >= FILE_CAP) {
|
|
143
|
+
truncated = true;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
read += 1;
|
|
147
|
+
try {
|
|
148
|
+
const text = await readFile(join(repo, path), 'utf8');
|
|
149
|
+
if (text.length > READ_CAP) continue;
|
|
150
|
+
files.push({ repo: name, path, text });
|
|
151
|
+
} catch {
|
|
152
|
+
// A file git lists and the worktree no longer has is not a consumer.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const refs = contractReferences(contracts, repoName, files);
|
|
157
|
+
return { references: refs.references, truncated: truncated || refs.truncated };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function render(payload) {
|
|
161
|
+
const lines = [`file ${payload.file}`];
|
|
162
|
+
if (payload.symbol) lines.push(`symbol ${payload.symbol}`);
|
|
163
|
+
for (const path of payload.importers) lines.push(`importer ${path}`);
|
|
164
|
+
for (const path of payload.tests) lines.push(`test ${path}`);
|
|
165
|
+
for (const hit of payload.governs) lines.push(`${hit.kind.padEnd(10)}${hit.id} ${hit.detail}`);
|
|
166
|
+
for (const owner of payload.owners) lines.push(`owner ${owner}`);
|
|
167
|
+
for (const consumer of payload.consumers) lines.push(`consumer ${consumer.repo}/${consumer.path} ${consumer.contract}`);
|
|
168
|
+
if (payload.truncated) lines.push('truncated 1');
|
|
169
|
+
return lines.join('\n');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function mightSniff(file) {
|
|
173
|
+
const base = file.slice(file.lastIndexOf('/') + 1);
|
|
174
|
+
const dot = base.lastIndexOf('.');
|
|
175
|
+
if (dot <= 0) return false;
|
|
176
|
+
const ext = base.slice(dot + 1).toLowerCase();
|
|
177
|
+
return ext === 'json' || ext === 'yaml' || ext === 'yml';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function sniffs(text) {
|
|
181
|
+
const head = text.slice(0, 500);
|
|
182
|
+
return head.includes('"openapi"') || head.includes('"$schema"');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function textExt(file) {
|
|
186
|
+
const base = file.slice(file.lastIndexOf('/') + 1);
|
|
187
|
+
const dot = base.lastIndexOf('.');
|
|
188
|
+
if (dot <= 0) return false;
|
|
189
|
+
return TEXT_EXT.has(base.slice(dot + 1).toLowerCase());
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function tracked(root) {
|
|
193
|
+
try {
|
|
194
|
+
const { stdout } = await exec('git', ['ls-files'], { cwd: root, maxBuffer: 16 << 20 });
|
|
195
|
+
return { files: stdout.split('\n').filter(Boolean) };
|
|
196
|
+
} catch (error) {
|
|
197
|
+
return {
|
|
198
|
+
error: fail(
|
|
199
|
+
`could not read the tree (${error.message})`,
|
|
200
|
+
EXIT.UNSUPPORTED,
|
|
201
|
+
'run this inside a git repository',
|
|
202
|
+
),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|
package/src/commands/index.mjs
CHANGED
|
@@ -29,13 +29,30 @@ import { syncCommand, checkCommand } from './sync.mjs';
|
|
|
29
29
|
import { initCommand } from './init.mjs';
|
|
30
30
|
import { ciCommand } from './ci.mjs';
|
|
31
31
|
import { hooksCommand } from './hooks.mjs';
|
|
32
|
+
import { agentHookCommand, agentHooksCommand } from './agenthook.mjs';
|
|
32
33
|
import { compareCommand } from './compare.mjs';
|
|
33
34
|
import { upgradeCommand } from './upgrade.mjs';
|
|
34
35
|
import { mcpCommand } from './mcp.mjs';
|
|
35
36
|
import { installCommand } from './skillsinstall.mjs';
|
|
36
37
|
import { listCommand as rulesList, showCommand as rulesShow, whyCommand as rulesWhy } from './rules.mjs';
|
|
37
38
|
import { tokensCommand } from './tokens.mjs';
|
|
38
|
-
import { harnessCommand } from './harness.mjs';
|
|
39
|
+
import { harnessCommand, harnessLoadersCommand } from './harness.mjs';
|
|
40
|
+
import {
|
|
41
|
+
benchMineCommand,
|
|
42
|
+
benchRunCommand,
|
|
43
|
+
benchReportCommand,
|
|
44
|
+
benchAblateCommand,
|
|
45
|
+
benchProbesCommand,
|
|
46
|
+
} from './bench.mjs';
|
|
47
|
+
import { planCommand } from './plan.mjs';
|
|
48
|
+
import { sharedPlanCommand, sharedInstallCommand, sharedWarning } from './shared.mjs';
|
|
49
|
+
import { evidenceCommand } from './evidence.mjs';
|
|
50
|
+
import { indexCommand } from './repoindex.mjs';
|
|
51
|
+
import { mapCommand } from './map.mjs';
|
|
52
|
+
import { testsCommand } from './tests.mjs';
|
|
53
|
+
import { whyCommand } from './why.mjs';
|
|
54
|
+
import { gateCommand } from './gate.mjs';
|
|
55
|
+
import { impactCommand } from './impact.mjs';
|
|
39
56
|
import { parse } from '../argv.mjs';
|
|
40
57
|
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
41
58
|
import { resolveWorkspace } from '../workspace.mjs';
|
|
@@ -53,7 +70,15 @@ const GROUPS = new Map([
|
|
|
53
70
|
['graph', 'lint'],
|
|
54
71
|
['rules', 'list'],
|
|
55
72
|
['hooks', 'status'],
|
|
73
|
+
['plan', 'status'],
|
|
74
|
+
['agent-hooks', 'status'],
|
|
56
75
|
['harness', 'detect'],
|
|
76
|
+
['bench', 'mine'],
|
|
77
|
+
// ADR-084. `shared` is the one group whose default writes nothing, and it is `plan` for
|
|
78
|
+
// the same reason `skills`'s is `index`: the read answers "what would this do, and
|
|
79
|
+
// where", and `install` — the subcommand that touches a reader's home directory — is the
|
|
80
|
+
// one that needs `--yes` before it does anything.
|
|
81
|
+
['shared', 'plan'],
|
|
57
82
|
]);
|
|
58
83
|
|
|
59
84
|
/**
|
|
@@ -84,14 +109,36 @@ export const IMPLEMENTED = {
|
|
|
84
109
|
'rules why': rulesWhy,
|
|
85
110
|
sync: syncCommand,
|
|
86
111
|
check: checkCommand,
|
|
112
|
+
'plan status': (args, flags) => planCommand('status', args, flags),
|
|
113
|
+
'plan next': (args, flags) => planCommand('next', args, flags),
|
|
114
|
+
'plan done': (args, flags) => planCommand('done', args, flags),
|
|
115
|
+
'shared plan': sharedPlanCommand,
|
|
116
|
+
'shared install': sharedInstallCommand,
|
|
117
|
+
evidence: evidenceCommand,
|
|
118
|
+
index: indexCommand,
|
|
119
|
+
map: mapCommand,
|
|
120
|
+
tests: testsCommand,
|
|
121
|
+
why: (args, flags) => whyCommand(args, flags, COMMANDS),
|
|
122
|
+
gate: gateCommand,
|
|
123
|
+
impact: (args, flags) => impactCommand(args, flags, COMMANDS),
|
|
124
|
+
'evidence check': (args, flags) => evidenceCommand(['check', ...args], flags),
|
|
87
125
|
init: initCommand,
|
|
88
126
|
ci: ciCommand,
|
|
89
127
|
'hooks install': (args, flags) => hooksCommand('install', args, flags),
|
|
90
128
|
'hooks status': (args, flags) => hooksCommand('status', args, flags),
|
|
91
129
|
'hooks run': (args, flags) => hooksCommand('run', args, flags),
|
|
130
|
+
'agent-hook': agentHookCommand,
|
|
131
|
+
'agent-hooks install': (args, flags) => agentHooksCommand('install', args, flags),
|
|
132
|
+
'agent-hooks status': (args, flags) => agentHooksCommand('status', args, flags),
|
|
92
133
|
compare: compareCommand,
|
|
93
134
|
tokens: tokensCommand,
|
|
94
135
|
'harness detect': harnessCommand,
|
|
136
|
+
'harness loaders': harnessLoadersCommand,
|
|
137
|
+
'bench mine': benchMineCommand,
|
|
138
|
+
'bench run': benchRunCommand,
|
|
139
|
+
'bench report': benchReportCommand,
|
|
140
|
+
'bench ablate': benchAblateCommand,
|
|
141
|
+
'bench probes': benchProbesCommand,
|
|
95
142
|
upgrade: upgradeCommand,
|
|
96
143
|
mcp: mcpCommand,
|
|
97
144
|
};
|
|
@@ -196,6 +243,13 @@ async function runDoctor(positional, flags) {
|
|
|
196
243
|
// answer confidently wrong, so the answer is reported rather than inferred
|
|
197
244
|
// (ADR-014).
|
|
198
245
|
const ws = await resolveWorkspace(flags);
|
|
246
|
+
// The shared file this repository hands rules to (ADR-084). Asked here and nowhere else
|
|
247
|
+
// because it is the one thing about a repository that is true of the reader's machine
|
|
248
|
+
// rather than of the tree: `check` must not read `$HOME`, so `doctor` is the only command
|
|
249
|
+
// that can notice a region that dropped rules into a file nobody has. Reported, never
|
|
250
|
+
// blocking — a stale copy costs a reader some of their rules, which is worth knowing and
|
|
251
|
+
// is not this repository's gate failing.
|
|
252
|
+
const shared = await sharedWarning(root, result.manifest ?? null, flags);
|
|
199
253
|
|
|
200
254
|
// Under `--json` the document is the whole report, so it has to carry both
|
|
201
255
|
// failures the text path reports AND the workspace answer the text path prints.
|
|
@@ -230,6 +284,7 @@ async function runDoctor(positional, flags) {
|
|
|
230
284
|
detail: ws.detail,
|
|
231
285
|
error: ws.error ?? null,
|
|
232
286
|
},
|
|
287
|
+
shared,
|
|
233
288
|
...(failure ?? {}),
|
|
234
289
|
});
|
|
235
290
|
return code;
|
|
@@ -252,6 +307,10 @@ async function runDoctor(positional, flags) {
|
|
|
252
307
|
}`,
|
|
253
308
|
`manifest ${result.manifest ? (result.manifest.error ?? result.manifest.id ?? 'present') : '(absent)'}`,
|
|
254
309
|
`workspace ${ws.root ?? '(none)'} (${ws.source}: ${ws.detail})`,
|
|
310
|
+
// Printed only when this repository handed rules to a workspace copy: a line in
|
|
311
|
+
// every `doctor` is a line in every reader's terminal, and the reader who has
|
|
312
|
+
// nothing to install is not served by one (ADR-084).
|
|
313
|
+
...(shared.declared ? [`shared ${shared.state} ${shared.detail}`] : []),
|
|
255
314
|
].join('\n'),
|
|
256
315
|
{ maxBytes: flags.maxBytes ?? 0 },
|
|
257
316
|
);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// `simt map` ranks the files a task is most likely to touch (R22, ADR-091).
|
|
2
|
+
// The cache is read, and rebuilt once when it is empty, because a map of no
|
|
3
|
+
// files is not an answer. The rebuild writes the gitignored index, which is
|
|
4
|
+
// the same cache `simt index` writes.
|
|
5
|
+
|
|
6
|
+
import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
|
|
7
|
+
import { CLI } from '../name.mjs';
|
|
8
|
+
import { readEdges, readFiles, readSymbols, reindex } from '../index/store.mjs';
|
|
9
|
+
import { DEFAULT_BUDGET, formatRecall, measureRecall, rankFiles, renderMap } from '../index/map.mjs';
|
|
10
|
+
|
|
11
|
+
const LEVELS = new Set(['outline', 'names', 'signatures']);
|
|
12
|
+
|
|
13
|
+
export async function mapCommand(args, flags) {
|
|
14
|
+
if (args.length > 0) return fail(`unknown map argument ${args[0]}`, EXIT.USAGE, `run: ${CLI} map`);
|
|
15
|
+
const root = flags.root ?? process.cwd();
|
|
16
|
+
if (flags.recall === true) return recall(root, flags);
|
|
17
|
+
const level = flags.level ?? 'outline';
|
|
18
|
+
if (!LEVELS.has(level)) {
|
|
19
|
+
return fail(`unknown level ${level}`, EXIT.USAGE, 'use outline, names or signatures');
|
|
20
|
+
}
|
|
21
|
+
const budget = flags.budget === undefined ? DEFAULT_BUDGET : Number(flags.budget);
|
|
22
|
+
if (!Number.isInteger(budget) || budget < 0) {
|
|
23
|
+
return fail(`budget must be a non-negative integer`, EXIT.USAGE, `run: ${CLI} map --budget 2048`);
|
|
24
|
+
}
|
|
25
|
+
const loaded = await loadGraph(root);
|
|
26
|
+
if (loaded.error) return loaded.error;
|
|
27
|
+
const task = typeof flags.task === 'string' ? flags.task : '';
|
|
28
|
+
const ranked = rankFiles({ ...loaded, task });
|
|
29
|
+
const rendered = renderMap(ranked, loaded.symbols, { level, budget });
|
|
30
|
+
const payload = {
|
|
31
|
+
ok: true,
|
|
32
|
+
command: 'map',
|
|
33
|
+
root,
|
|
34
|
+
level,
|
|
35
|
+
budget,
|
|
36
|
+
shown: rendered.shown,
|
|
37
|
+
total: rendered.total,
|
|
38
|
+
truncated: rendered.truncated,
|
|
39
|
+
files: rendered.files,
|
|
40
|
+
text: rendered.text,
|
|
41
|
+
};
|
|
42
|
+
if (isJson(flags)) {
|
|
43
|
+
emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
|
|
44
|
+
return EXIT.OK;
|
|
45
|
+
}
|
|
46
|
+
const lines = [
|
|
47
|
+
`level ${level}`,
|
|
48
|
+
`shown ${rendered.shown}`,
|
|
49
|
+
`total ${rendered.total}`,
|
|
50
|
+
`budget ${budget}`,
|
|
51
|
+
];
|
|
52
|
+
if (rendered.text) lines.push(rendered.text);
|
|
53
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
54
|
+
return EXIT.OK;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function recall(root, flags) {
|
|
58
|
+
let result;
|
|
59
|
+
try {
|
|
60
|
+
result = await measureRecall(root);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return fail(`could not measure recall (${error.message})`, EXIT.UNSUPPORTED, `run ${CLI} index in a git repository`);
|
|
63
|
+
}
|
|
64
|
+
const payload = { ok: true, command: 'map', root, recall: result };
|
|
65
|
+
if (isJson(flags)) {
|
|
66
|
+
emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
|
|
67
|
+
return EXIT.OK;
|
|
68
|
+
}
|
|
69
|
+
emit(formatRecall(result), { maxBytes: flags.maxBytes ?? 0 });
|
|
70
|
+
return EXIT.OK;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function loadGraph(root) {
|
|
74
|
+
let files = await readFiles(root);
|
|
75
|
+
if (files.length === 0) {
|
|
76
|
+
try {
|
|
77
|
+
await reindex(root);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return {
|
|
80
|
+
error: fail(
|
|
81
|
+
`could not map this repository (${error.message})`,
|
|
82
|
+
EXIT.UNSUPPORTED,
|
|
83
|
+
'run this inside a git repository',
|
|
84
|
+
),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
files = await readFiles(root);
|
|
88
|
+
}
|
|
89
|
+
const [symbols, edges] = await Promise.all([readSymbols(root), readEdges(root)]);
|
|
90
|
+
return { files, symbols, edges };
|
|
91
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { EXIT, emit, emitJson, fail, isJson, writeMode } from '../output/index.mjs';
|
|
2
|
+
import { formatCounts, planDone, planNext, planStatus } from '../plan/index.mjs';
|
|
3
|
+
import { CLI } from '../name.mjs';
|
|
4
|
+
|
|
5
|
+
// Preview unless --yes, and --dry-run wins even then. A tracker row marked done
|
|
6
|
+
// for a commit that was never written is a claim the next reader will believe.
|
|
7
|
+
|
|
8
|
+
export async function planCommand(sub, args, flags) {
|
|
9
|
+
const root = flags.root ?? process.cwd();
|
|
10
|
+
if (sub === 'status') return runStatus(root, flags);
|
|
11
|
+
if (sub === 'next') return runNext(root, args[0] ?? null, flags);
|
|
12
|
+
if (sub === 'done') {
|
|
13
|
+
if (!args[0]) return fail('plan done needs a slice id', EXIT.USAGE, `run: ${CLI} plan next`);
|
|
14
|
+
return runDone(root, args[0], args[1] ?? null, flags);
|
|
15
|
+
}
|
|
16
|
+
if (!sub) {
|
|
17
|
+
return fail('plan needs a subcommand', EXIT.USAGE, `run: ${CLI} plan status | ${CLI} plan next | ${CLI} plan done <id>`);
|
|
18
|
+
}
|
|
19
|
+
return fail(`unknown plan subcommand ${sub}`, EXIT.USAGE, `run: ${CLI} plan status`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function runStatus(root, flags) {
|
|
23
|
+
const result = await planStatus(root);
|
|
24
|
+
if (result.error) return fail(result.error, EXIT.USAGE, result.remedy);
|
|
25
|
+
if (isJson(flags)) {
|
|
26
|
+
emitJson({ ok: true, command: 'plan status', ...result });
|
|
27
|
+
return EXIT.OK;
|
|
28
|
+
}
|
|
29
|
+
const lines = [`repo ${root}`, `template ${result.template}`, `dirs ${result.directories.join(', ')}`];
|
|
30
|
+
if (result.documents.length === 0) lines.push(`(no tracker in ${result.directories.join(' or ')})`);
|
|
31
|
+
for (const doc of result.documents) {
|
|
32
|
+
lines.push(`${doc.name} slices=${doc.slices} ${formatCounts(doc.counts, result.stateOrder)}`);
|
|
33
|
+
}
|
|
34
|
+
lines.push('', `total ${formatCounts(result.totals, result.stateOrder) || '-'}`);
|
|
35
|
+
lines.push(`ready ${result.ready}`);
|
|
36
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
37
|
+
return EXIT.OK;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function runNext(root, id, flags) {
|
|
41
|
+
const result = await planNext(root, id);
|
|
42
|
+
if (result.error) return fail(result.error, EXIT.USAGE, result.remedy);
|
|
43
|
+
if (isJson(flags)) {
|
|
44
|
+
emitJson({ ok: true, command: 'plan next', ...result });
|
|
45
|
+
return EXIT.OK;
|
|
46
|
+
}
|
|
47
|
+
if (!result.next) {
|
|
48
|
+
const lines = [`repo ${root}`, result.reason];
|
|
49
|
+
if (result.blocked?.length) {
|
|
50
|
+
lines.push('', 'blocked:');
|
|
51
|
+
for (const row of result.blocked) {
|
|
52
|
+
const unknown = row.unknownDependencies.length ? ` unknown: ${row.unknownDependencies.join(', ')}` : '';
|
|
53
|
+
lines.push(` ${row.id} ${row.doc} depends on: ${row.dependsOn || '(none)'}${unknown}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
57
|
+
return EXIT.OK;
|
|
58
|
+
}
|
|
59
|
+
const next = result.next;
|
|
60
|
+
const head = [
|
|
61
|
+
`repo ${root}`,
|
|
62
|
+
`slice ${next.id} ${next.doc}`,
|
|
63
|
+
`state ${next.state}`,
|
|
64
|
+
`depends ${next.dependsOn || '(none)'}`,
|
|
65
|
+
`bytes ${next.sectionBytes}`,
|
|
66
|
+
'',
|
|
67
|
+
next.section || '(no section for this id)',
|
|
68
|
+
];
|
|
69
|
+
emit(head.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
70
|
+
return EXIT.OK;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function runDone(root, id, sha, flags) {
|
|
74
|
+
const mode = writeMode(flags);
|
|
75
|
+
const result = await planDone(root, id, {
|
|
76
|
+
sha,
|
|
77
|
+
test: flags.test ?? null,
|
|
78
|
+
state: flags.state ?? null,
|
|
79
|
+
dryRun: !mode.apply,
|
|
80
|
+
});
|
|
81
|
+
if (result.error) return fail(result.error, EXIT.USAGE, result.remedy);
|
|
82
|
+
if (isJson(flags)) {
|
|
83
|
+
emitJson({ ok: true, command: 'plan done', ...result });
|
|
84
|
+
return EXIT.OK;
|
|
85
|
+
}
|
|
86
|
+
const lines = [`repo ${root}`];
|
|
87
|
+
if (result.dryRun) {
|
|
88
|
+
lines.push(`dry-run ${result.id} ${result.doc} line ${result.line}`);
|
|
89
|
+
lines.push(result.wouldChange ? `would ${result.from} -> ${result.to}` : `state ${result.from}`);
|
|
90
|
+
if (result.remedy) lines.push(`apply ${result.remedy}`);
|
|
91
|
+
} else {
|
|
92
|
+
lines.push(`changed ${result.changed ? 'yes' : 'no'} ${result.id} ${result.doc} line ${result.line}`);
|
|
93
|
+
lines.push(result.from === result.to ? `state ${result.to}` : `state ${result.from} -> ${result.to}`);
|
|
94
|
+
}
|
|
95
|
+
if (result.evidenceRecorded) lines.push(`evidence ${result.sha}${result.test ? ` test: ${result.test}` : ''}`);
|
|
96
|
+
else if (result.evidenceNote) lines.push(`note ${result.evidenceNote}`);
|
|
97
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
98
|
+
return EXIT.OK;
|
|
99
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// `simt index` rebuilds the local cache from git. The cache is not repository
|
|
2
|
+
// content: it is gitignored, and a second run with the same blobs writes the same
|
|
3
|
+
// rows (ADR-089).
|
|
4
|
+
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
|
|
8
|
+
import { CLI } from '../name.mjs';
|
|
9
|
+
import { JSONL_FILE, SQLITE_FILE, reindex } from '../index/store.mjs';
|
|
10
|
+
|
|
11
|
+
export async function indexCommand(args, flags) {
|
|
12
|
+
if (args.length > 0) return fail(`unknown index argument ${args[0]}`, EXIT.USAGE, `run: ${CLI} index`);
|
|
13
|
+
const root = flags.root ?? process.cwd();
|
|
14
|
+
let result;
|
|
15
|
+
try {
|
|
16
|
+
result = await reindex(root);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
return fail(
|
|
19
|
+
`could not index this repository (${error.message})`,
|
|
20
|
+
EXIT.UNSUPPORTED,
|
|
21
|
+
'run this inside a git repository',
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
const file = result.backend === 'sqlite' ? SQLITE_FILE : JSONL_FILE;
|
|
25
|
+
const ignored = await isIgnored(root, file);
|
|
26
|
+
const payload = { ok: true, command: 'index', root, file, ignored, ...result };
|
|
27
|
+
if (isJson(flags)) {
|
|
28
|
+
emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
|
|
29
|
+
return EXIT.OK;
|
|
30
|
+
}
|
|
31
|
+
const lines = [
|
|
32
|
+
`index ${result.backend}`,
|
|
33
|
+
`files ${result.files}`,
|
|
34
|
+
`updated ${result.updated}`,
|
|
35
|
+
`removed ${result.removed}`,
|
|
36
|
+
`symbols ${result.symbols}`,
|
|
37
|
+
`edges ${result.edges}`,
|
|
38
|
+
`ms ${result.ms}`,
|
|
39
|
+
];
|
|
40
|
+
if (result.parsers === 'missing') lines.push('note index.parsers did not load, so the built-in extractors ran');
|
|
41
|
+
if (!ignored) lines.push(`note ${file} is not gitignored, and the cache is not meant to be committed`);
|
|
42
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
43
|
+
return EXIT.OK;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isIgnored(root, file) {
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
execFile('git', ['check-ignore', '-q', '--', file], { cwd: root }, (error) => {
|
|
49
|
+
resolve(!error);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|