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.
Files changed (95) hide show
  1. package/CHANGELOG.md +239 -0
  2. package/bin/simt-dev.mjs +21 -2
  3. package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
  4. package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
  5. package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
  6. package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
  7. package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
  8. package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
  9. package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
  10. package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
  11. package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
  12. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
  13. package/docs/decisions/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
  14. package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
  15. package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
  16. package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
  17. package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
  18. package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
  19. package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
  20. package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
  21. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
  22. package/package.json +2 -1
  23. package/rules/core.md +1 -0
  24. package/rules/stacks/android.md +2 -0
  25. package/rules/stacks/cloudflare-workers.md +2 -0
  26. package/rules/stacks/connectiq.md +1 -0
  27. package/rules/stacks/container.md +2 -0
  28. package/rules/stacks/dart.md +2 -0
  29. package/rules/stacks/deno.md +2 -0
  30. package/rules/stacks/dotnet.md +2 -0
  31. package/rules/stacks/elixir.md +2 -0
  32. package/rules/stacks/go.md +2 -0
  33. package/rules/stacks/jvm.md +2 -0
  34. package/rules/stacks/node.md +2 -0
  35. package/rules/stacks/python.md +2 -0
  36. package/rules/stacks/react-native.md +2 -0
  37. package/rules/stacks/react-web.md +2 -0
  38. package/rules/stacks/ruby.md +2 -0
  39. package/rules/stacks/rust.md +2 -0
  40. package/rules/stacks/swift.md +2 -0
  41. package/rules/stacks/typescript.md +2 -0
  42. package/rules/stacks/uv.md +2 -0
  43. package/src/agenthooks/decide.mjs +123 -0
  44. package/src/agenthooks/policy.mjs +78 -0
  45. package/src/agenthooks/render.mjs +240 -0
  46. package/src/argv.mjs +55 -1
  47. package/src/bench/ablate.mjs +630 -0
  48. package/src/bench/adapters.json +149 -0
  49. package/src/bench/grade.mjs +229 -0
  50. package/src/bench/mine.mjs +811 -0
  51. package/src/bench/probes.mjs +1350 -0
  52. package/src/bench/report.mjs +899 -0
  53. package/src/bench/run.mjs +1002 -0
  54. package/src/commands/agenthook.mjs +282 -0
  55. package/src/commands/bench.mjs +359 -0
  56. package/src/commands/compare.mjs +15 -0
  57. package/src/commands/graph.mjs +45 -21
  58. package/src/commands/harness.mjs +301 -0
  59. package/src/commands/help.mjs +73 -2
  60. package/src/commands/index.mjs +67 -25
  61. package/src/commands/init.mjs +12 -8
  62. package/src/commands/mcp.mjs +20 -5
  63. package/src/commands/plan.mjs +99 -0
  64. package/src/commands/rules.mjs +42 -57
  65. package/src/commands/skills.mjs +67 -92
  66. package/src/commands/skillsinstall.mjs +17 -2
  67. package/src/commands/sync.mjs +62 -6
  68. package/src/commands/tokens.mjs +169 -0
  69. package/src/compare/row.mjs +73 -2
  70. package/src/decisions/index.mjs +23 -5
  71. package/src/graph/spec.mjs +227 -0
  72. package/src/graph/speccheck.mjs +68 -0
  73. package/src/hooks/dispatcher.mjs +35 -3
  74. package/src/kernel/card.mjs +254 -0
  75. package/src/kernel/inferability.mjs +188 -0
  76. package/src/mcp/resources.mjs +157 -0
  77. package/src/mcp/schema.mjs +139 -0
  78. package/src/mcp/server.mjs +228 -20
  79. package/src/mcp/tools.mjs +372 -1
  80. package/src/measure/context.mjs +542 -0
  81. package/src/measure/loaderView.mjs +435 -0
  82. package/src/measure/session.mjs +267 -0
  83. package/src/monorepo.mjs +339 -0
  84. package/src/output/index.mjs +78 -2
  85. package/src/packs.mjs +182 -0
  86. package/src/plan/index.mjs +650 -0
  87. package/src/plan/template.mjs +331 -0
  88. package/src/projection/loaders.mjs +689 -0
  89. package/src/projection/manifest.mjs +114 -3
  90. package/src/projection/project.mjs +140 -9
  91. package/src/projection/rules.mjs +365 -22
  92. package/src/projection/surfaces.mjs +155 -23
  93. package/src/projection/targets.mjs +37 -0
  94. package/src/toolwords.mjs +69 -0
  95. package/templates/plan/tracker.json +36 -0
@@ -6,11 +6,18 @@
6
6
  // Output is line-oriented and bounded (plan 0002 section 6). A lint failure names
7
7
  // the offending node, the file it disagrees with, and both numbers.
8
8
 
9
- import { EXIT, emit, fail, isJson } from '../output/index.mjs';
9
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
10
10
  import { loadRegistry } from '../graph/load.mjs';
11
+ import { lintSpec } from '../graph/speccheck.mjs';
12
+ import { SPEC, SPEC_SOURCE } from '../graph/spec.mjs';
11
13
  import { CLI } from '../name.mjs';
12
14
 
13
15
  export async function lintCommand(positional, flags) {
16
+ const spec = flags.spec ?? null;
17
+ if (spec !== null && spec !== SPEC) {
18
+ return fail(`unknown --spec "${spec}"`, EXIT.USAGE, `the one specification this lints is: ${SPEC}`);
19
+ }
20
+
14
21
  // The resolved path comes back from the loader, because when no positional is given it
15
22
  // falls back to the member that holds the registry in this workspace (ADR-061) - and the
16
23
  // reader should see where it actually looked, not a constant that no longer exists.
@@ -19,27 +26,38 @@ export async function lintCommand(positional, flags) {
19
26
  flags,
20
27
  );
21
28
 
29
+ // The conformance pass is separate from the registry pass on purpose. The registry
30
+ // answers "does the bookkeeping match the files"; the specification answers "would a
31
+ // client accept these files", and the second question needs no registry at all. A
32
+ // reader with no registry can still lint a skill directory, so the two are additive
33
+ // rather than one gating the other.
34
+ const specReport = registry && workspace?.root
35
+ ? await lintSpec(registry, workspace.root)
36
+ : { errors: [], warnings: [], checked: 0 };
37
+ errors.push(...specReport.errors);
38
+ warnings.push(...specReport.warnings);
39
+
22
40
  if (isJson(flags)) {
23
- emit(
24
- `${JSON.stringify(
25
- {
26
- ok,
27
- registry: path,
28
- capabilities: registry?.capabilities?.length ?? 0,
29
- skills: registry?.skills?.length ?? 0,
30
- // `verified: false` means the byte counts in the registry were NOT
31
- // checked against files on disk. It is not a failure - it is the difference
32
- // between "the registry is right" and "the registry could not be checked",
33
- // and an agent that cannot see the difference will trust a number nobody
34
- // measured (ADR-014).
35
- verified,
36
- workspace,
37
- errors,
38
- warnings,
39
- },
40
- null,
41
- 2,
42
- )}\n`,
41
+ emitJson(
42
+ {
43
+ ok,
44
+ registry: path,
45
+ capabilities: registry?.capabilities?.length ?? 0,
46
+ skills: registry?.skills?.length ?? 0,
47
+ // `verified: false` means the byte counts in the registry were NOT
48
+ // checked against files on disk. It is not a failure - it is the difference
49
+ // between "the registry is right" and "the registry could not be checked",
50
+ // and an agent that cannot see the difference will trust a number nobody
51
+ // measured (ADR-014).
52
+ verified,
53
+ workspace,
54
+ // Present only when asked for, so a reader of the default output is not told
55
+ // about a pass that never ran. `null` is the honest value; `true` would be a
56
+ // claim about conformance nobody checked.
57
+ spec: spec === null ? null : { name: SPEC, source: SPEC_SOURCE, checked: specReport.checked },
58
+ errors,
59
+ warnings,
60
+ },
43
61
  { maxBytes: flags.maxBytes ?? 0 },
44
62
  );
45
63
  return ok ? EXIT.OK : EXIT.REGISTRY;
@@ -57,6 +75,12 @@ export async function lintCommand(positional, flags) {
57
75
  `nodes ${registry.skills.length} skills, ${registry.capabilities.length} capabilities`,
58
76
  `verified ${verified ? 'byte counts checked against disk' : 'NOT checked - see WARN below'}`,
59
77
  ];
78
+ if (spec !== null) {
79
+ lines.push(
80
+ `spec ${specReport.checked} skills checked against ${SPEC_SOURCE}` +
81
+ `${specReport.checked === 0 ? ' (none had a readable SKILL.md)' : ''}`,
82
+ );
83
+ }
60
84
 
61
85
  const deferred = registry.skills.filter((s) => s.deferred);
62
86
  lines.push(
@@ -0,0 +1,301 @@
1
+ // `simt harness detect` - which harnesses this repository (and this user) is set up
2
+ // for, and which of them simt-dev has a channel for.
3
+ //
4
+ // Why this is a command and not a sentence in the docs: the question "which harness
5
+ // reads my rules" has two halves that used to be answered in different places, and the
6
+ // second half was answered by a reader's guess. What is present on disk is observable
7
+ // and belongs to the tool; what a given harness actually loads is a vendor fact, and a
8
+ // vendor fact is only trustworthy when it names its source. So the table that says "we
9
+ // have a channel for this" and the table that says "we have not verified one" are kept
10
+ // apart, and this command reports both without collapsing them into a verdict.
11
+ //
12
+ // It exits 0 whatever it finds. A harness with no channel is a fact about coverage, not
13
+ // a defect in the repository: the rules in AGENTS.md still reach whichever harnesses
14
+ // read that file, and a reader who wanted a failing command could not tell the
15
+ // difference between "no channel" and "broken".
16
+
17
+ import { stat } from 'node:fs/promises';
18
+ import { homedir } from 'node:os';
19
+ import { isAbsolute, join } from 'node:path';
20
+
21
+ import { EXIT, emit, emitJson, fail, isJson, note } from '../output/index.mjs';
22
+ import { SURFACES, CANDIDATE_DIRS } from '../projection/surfaces.mjs';
23
+ import { LOADERS, LOADER_IDS, caps, loader } from '../projection/loaders.mjs';
24
+ import { viewAll } from '../measure/loaderView.mjs';
25
+
26
+ /**
27
+ * `simt-dev harness detect`. The subcommand is validated by dispatch, which rejects a
28
+ * second token that is not in the group registry rather than silently routing to the
29
+ * default; this function therefore has exactly one thing to do and no second spelling
30
+ * to keep in step with it.
31
+ *
32
+ * @param {string[]} positional arguments after the command path
33
+ * @param {object} flags
34
+ * @returns {Promise<number>} process exit code
35
+ */
36
+ export async function harnessCommand(positional, flags) {
37
+ const root = flags.root ?? process.cwd();
38
+
39
+ const repo = await scan(root);
40
+ // Only on request. The home directory is the reader's, not the repository's, and a
41
+ // command that reports both by default is a command whose output changes because
42
+ // somebody installed something elsewhere.
43
+ const user = flags.user === true ? await scan(homedir()) : null;
44
+
45
+ if (isJson(flags)) {
46
+ emitJson({
47
+ ok: true,
48
+ command: 'harness detect',
49
+ root,
50
+ repo,
51
+ ...(user === null ? {} : { user }),
52
+ });
53
+ return EXIT.OK;
54
+ }
55
+
56
+ emit(render(root, repo, user));
57
+ if (repo.rows.length === 0) {
58
+ note(
59
+ `note no harness configuration directory found in ${root}. That is a repository ` +
60
+ 'nobody has set an agent up in - not a finding.',
61
+ );
62
+ }
63
+ const unclaimed = countUnclaimed(repo);
64
+ if (unclaimed > 0) {
65
+ note(
66
+ `note ${unclaimed} present configur${unclaimed === 1 ? 'ation has' : 'ations have'} ` +
67
+ 'no rule channel in this release. Each one is reported as present, not as ' +
68
+ 'unsupported: whether it reads AGENTS.md is a vendor question this tool does not ' +
69
+ 'answer without a source, and a guess here would be the reader\'s problem later.',
70
+ );
71
+ }
72
+ return EXIT.OK;
73
+ }
74
+
75
+ /**
76
+ * Every harness directory present under one root, sorted by directory name.
77
+ *
78
+ * A directory whose stat fails for any reason other than absence is treated as absent
79
+ * rather than raised: a permissions error on one candidate must not make the whole
80
+ * command fail, and the difference between "could not look" and "not there" is one
81
+ * note in the output rather than a second failure mode nobody asked for.
82
+ */
83
+ async function scan(root) {
84
+ const rows = [];
85
+ for (const s of SURFACES) {
86
+ for (const dir of s.configDirs ?? []) {
87
+ if (await present(root, dir)) {
88
+ rows.push(row(dir, s.label, { id: s.id, verified: s.verified ?? null, readsAgentsMd: s.readsAgentsMd, rule: s.rule }));
89
+ }
90
+ }
91
+ }
92
+ for (const c of CANDIDATE_DIRS) {
93
+ if (await present(root, c.dir)) {
94
+ rows.push(row(c.dir, c.harness, { readsAgentsMd: c.readsAgentsMd ?? null, verified: c.verified ?? null }));
95
+ }
96
+ }
97
+ rows.sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0));
98
+ return { root, rows };
99
+ }
100
+
101
+ async function present(root, dir) {
102
+ try {
103
+ return (await stat(join(root, dir))).isDirectory();
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ /** One present directory, and what - if anything - this tool can project into it. */
110
+ function row(dir, label, surface) {
111
+ const id = surface.id ?? null;
112
+ return {
113
+ dir,
114
+ label,
115
+ surface: id,
116
+ // `channel: false` is the honest half of the answer: the directory exists and this
117
+ // release has no rule file to write there. Reported as its own field rather than
118
+ // left implicit, because "we found it and did nothing" and "we found nothing" look
119
+ // identical in a list of names.
120
+ channel: id !== null && surface.rule != null,
121
+ readsAgentsMd: surface.readsAgentsMd ?? null,
122
+ verified: surface.verified ?? null,
123
+ };
124
+ }
125
+
126
+ function countUnclaimed(scan) {
127
+ // A candidate that cites a document and reads AGENTS.md is covered by the standard
128
+ // region. One that cites nothing is the case the note exists for (ADR-067).
129
+ return scan.rows.filter((r) => r.surface === null && r.readsAgentsMd == null).length;
130
+ }
131
+
132
+ /**
133
+ * One line per present directory, in the order the scanner found them.
134
+ *
135
+ * `surface` is printed when there is one, and `AGENTS.md` is named when the surface
136
+ * loads it - because the answer a reader actually wants is "do my rules reach this",
137
+ * and the channel column alone does not answer it.
138
+ */
139
+ function render(root, repo, user) {
140
+ const lines = [`repo ${root}`];
141
+ lines.push(...block(repo, ' '));
142
+ if (user !== null) {
143
+ lines.push(`user ${user.root}`);
144
+ lines.push(...block(user, ' '));
145
+ }
146
+ return `${lines.join('\n')}\n`;
147
+ }
148
+
149
+ function block(scan, pad) {
150
+ if (scan.rows.length === 0) return [`${pad}(none present)`];
151
+ return scan.rows.map((r) => {
152
+ const through =
153
+ r.surface === null
154
+ ? r.readsAgentsMd === 'all'
155
+ ? 'AGENTS.md'
156
+ : 'no rule channel in this release'
157
+ : r.channel
158
+ ? 'projected rule file'
159
+ : 'AGENTS.md';
160
+ const agents = r.readsAgentsMd === 'all' ? ' (reads AGENTS.md)' : r.readsAgentsMd === 'some' ? ' (some readers read AGENTS.md)' : '';
161
+ return `${pad}${r.dir.padEnd(12)} ${r.label.padEnd(38)} ${through}${agents}`;
162
+ });
163
+ }
164
+
165
+ // `simt harness loaders` - what each reader loads at session start, and the vendor
166
+ // document every claim in that answer came from.
167
+ //
168
+ // It exists because `simt tokens` answers a question about files and this one answers a
169
+ // question about a reader, and the two disagree in ways that matter: Antigravity scans
170
+ // `.agents/rules/` flat and ignores nested files, and Codex stops merging at 32 KiB
171
+ // across the chain rather than per file. A byte count that ignores those is not
172
+ // pessimistic, it is wrong, and it was wrong in the meter this slice unblocked.
173
+ //
174
+ // It exits 0 whatever it finds, for the same reason `harness detect` does: a reader that
175
+ // loads nothing here is a fact about this repository, not a defect in it.
176
+
177
+ /**
178
+ * `simt harness loaders [--root <path>] [--reader <id>] [--table]`.
179
+ *
180
+ * With no flags it prints one block per reader: what it loads, what the cap leaves out,
181
+ * and the sentence it was read from. `--reader` narrows it. `--table` prints the
182
+ * vendor facts without measuring anything, which is the answer to "what does this reader
183
+ * do" in a repository where the answer is not about bytes.
184
+ */
185
+ export async function harnessLoadersCommand(positional, flags) {
186
+ const root = flags.root ?? process.cwd();
187
+ const wanted = typeof flags.reader === 'string' ? flags.reader : null;
188
+ const rows = wanted === null ? LOADERS : [loader(wanted)].filter(Boolean);
189
+
190
+ if (rows.length === 0) {
191
+ return fail(
192
+ `unknown reader "${wanted}"`,
193
+ EXIT.USAGE,
194
+ `known readers: ${LOADER_IDS.join(', ')}`,
195
+ );
196
+ }
197
+
198
+ if (flags.table === true) {
199
+ const payload = rows.map((row) => ({
200
+ id: row.id,
201
+ label: row.label,
202
+ readers: row.readers,
203
+ order: row.order,
204
+ nested: row.nested,
205
+ start: row.start,
206
+ alsoReads: row.alsoReads ?? [],
207
+ imports: row.imports,
208
+ caps: caps(row),
209
+ skills: row.skills ?? [],
210
+ hooks: row.hooks,
211
+ hooksNote: row.hooksNote ?? null,
212
+ mcp: row.mcp,
213
+ verified: row.verified,
214
+ note: row.note ?? null,
215
+ }));
216
+ if (isJson(flags)) {
217
+ emitJson({ ok: true, command: 'harness loaders', rows: payload });
218
+ return EXIT.OK;
219
+ }
220
+ emit(rows.map((row) => loaderTable(row)).join('\n'));
221
+ return EXIT.OK;
222
+ }
223
+
224
+ // `--at` asks what a reader loading from a subdirectory would get, which is the only
225
+ // way the chain rows mean anything: Codex merges the project root down to the working
226
+ // directory, so the same repository costs a different reader a different total
227
+ // depending on where that reader is standing.
228
+ const views = await viewAll(root, rows, flags.at ? { cwd: isAbsolute(flags.at) ? flags.at : join(root, flags.at) } : {});
229
+ if (isJson(flags)) {
230
+ emitJson({ ok: true, command: 'harness loaders', root, rows: views });
231
+ return EXIT.OK;
232
+ }
233
+ emit(`${views.map((view) => renderView(view)).join('\n')}`);
234
+ return EXIT.OK;
235
+ }
236
+
237
+ /**
238
+ * One reader's measured view of the repository.
239
+ *
240
+ * The dropped rows come first under a heading of their own. A total that quietly
241
+ * excludes a file past the cap reads as a total, and the reader who is about to lose the
242
+ * tail of their deepest instruction file to a merge is exactly the person who needs to
243
+ * see it named.
244
+ */
245
+ function renderView(view) {
246
+ const lines = [`${view.reader.padEnd(11)} ${view.label} ${view.files.length} file(s) at start, ${view.totalBytes} B`];
247
+ lines.push(` order ${view.order}`);
248
+ for (const file of view.files) {
249
+ const state = file.beyondCap
250
+ ? ' BEYOND CAP - not read'
251
+ : file.truncated
252
+ ? ` TRUNCATED to ${file.keptBytes} B`
253
+ : file.overCap
254
+ ? ` OVER CAP (${file.countedBytes} B)`
255
+ : '';
256
+ lines.push(` ${file.file.padEnd(44)} ${String(file.countedBytes).padStart(7)} B${state}`);
257
+ }
258
+ if (view.cap) {
259
+ lines.push(` cap ${view.cap.bytes} B over ${view.cap.appliesTo} (${view.cap.config ?? 'fixed'})`);
260
+ } else if (!view.perFileCap && !view.budget) {
261
+ lines.push(' cap none published');
262
+ }
263
+ if (view.perFileCap) {
264
+ lines.push(` file cap ${view.perFileCap.bytes} B per rule file (${view.perFileCap.config ?? 'fixed'})`);
265
+ }
266
+ if (view.budget) {
267
+ lines.push(` budget ${view.budget.tokens} tokens (${view.budget.bytes} B approx) over ${view.budget.appliesTo}`);
268
+ }
269
+ if (view.imports?.syntax) {
270
+ lines.push(` imports ${view.imports.syntax}, ${view.imports.depth} hop(s)`);
271
+ }
272
+ if (view.userScope.length > 0) {
273
+ lines.push(` user ${view.userScope.join(', ')} (not measured: your bytes, not the repo's)`);
274
+ }
275
+ return `${lines.join('\n')}\n`;
276
+ }
277
+
278
+ /** The vendor facts, without a repository — the part of the answer that is not a measurement. */
279
+ function loaderTable(row) {
280
+ const lines = [`${row.id} ${row.label} (${row.readers})`];
281
+ lines.push(` order ${row.order}`);
282
+ lines.push(` nested ${row.nested}`);
283
+ for (const entry of row.start) {
284
+ const where = entry.scope === 'user' ? 'user' : 'project';
285
+ lines.push(` start ${where} ${entry.path}${entry.atStart ? '' : ' (on demand)'}`);
286
+ }
287
+ for (const extra of row.alsoReads ?? []) {
288
+ lines.push(` also ${extra.what}${extra.atStart ? '' : ' (on demand)'}`);
289
+ }
290
+ for (const c of caps(row)) {
291
+ lines.push(` cap ${c.bytes} ${c.scope === 'tokens' ? 'tokens' : 'B'} over ${c.appliesTo ?? c.scope}`);
292
+ }
293
+ if (row.cap === null) lines.push(` cap none published — ${row.capNote ?? 'checked'}`);
294
+ lines.push(` skills ${(row.skills ?? []).join(', ') || 'none documented'}`);
295
+ lines.push(` hooks ${row.hooks?.path ?? 'none documented'}${row.hooksNote ? ` — ${row.hooksNote}` : ''}`);
296
+ lines.push(
297
+ ` mcp ${row.mcp?.path ?? (row.mcp?.key ? `profile ${row.mcp.key}` : 'none documented')}`,
298
+ );
299
+ lines.push(` source ${row.verified}`);
300
+ return lines.join('\n');
301
+ }
@@ -1,6 +1,7 @@
1
1
  import { emit } from '../output/index.mjs';
2
2
  import { parse } from '../argv.mjs';
3
- import { CLI } from '../name.mjs';
3
+ import { CLI, MANIFEST_FILE } from '../name.mjs';
4
+ import { DEFAULT_DIRECTORIES, DEFAULT_TEMPLATE } from '../plan/template.mjs';
4
5
 
5
6
  const COMMANDS = `${CLI} init [path] adopt or scaffold a repo into the ecosystem
6
7
  (dry-run unless --yes; conflicts block without
@@ -38,6 +39,20 @@ ${CLI} hooks install [--yes] install the shared dispatcher; report what it wou
38
39
  ${CLI} hooks status what each git event routes to, and whether it is wired
39
40
  ${CLI} hooks run <event> run one event's gates now, as git would
40
41
  (pre-commit, pre-push, post-merge, ...)
42
+ ${CLI} agent-hook <event> the entry point a harness calls before a tool call
43
+ (reads one JSON payload on stdin and answers in that
44
+ harness's dialect; exit 2 blocks; needs --harness)
45
+ ${CLI} agent-hooks install write this repo's policies into the harnesses that
46
+ read a hooks file
47
+ (dry-run unless --yes; merges into the file, keeps every
48
+ key it does not own; Codex needs the hook trusted)
49
+ ${CLI} agent-hooks status what each harness will run, and what is not trusted yet
50
+ ${CLI} plan status count tracker rows
51
+ (${MANIFEST_FILE} key plans when set, otherwise
52
+ the ${DEFAULT_TEMPLATE} template:
53
+ ${DEFAULT_DIRECTORIES.join(' and ')})
54
+ ${CLI} plan next [<id>] one ready slice's section, or the one named; ~1 KB
55
+ ${CLI} plan done <id> [<sha>] mark one row; dry-run unless --yes
41
56
  ${CLI} graph lint check the skill registry against the skills on disk
42
57
  (byte counts, dangling edges, unresolvable variants)
43
58
  ${CLI} ci simt-dev's own .github files: create or refresh them
@@ -46,11 +61,39 @@ ${CLI} ci simt-dev's own .github files: create or refresh th
46
61
  ${CLI} compare org-wide matrix across every sibling repo
47
62
  (read-only; exits non-zero on defects, not on
48
63
  repos that have not adopted yet)
64
+ ${CLI} tokens [--repos] what a reader loads at session start, in bytes
65
+ (read-only; exits 0 whatever it finds, because a
66
+ measurement is not a verdict; --repos measures every
67
+ repository under the root and what repeats an earlier)
68
+ ${CLI} harness detect which harnesses this repo (and, with --user, you)
69
+ are set up for, and which have a rule channel here
70
+ (presence only: a directory found is not a claim about
71
+ what its owner reads; exits 0 whatever it finds)
72
+ ${CLI} harness loaders what each reader loads at session start, in bytes,
73
+ and what its size cap drops
74
+ (--table prints the vendor facts without measuring;
75
+ --reader <id> narrows it; every claim cites its source)
76
+ ${CLI} bench mine [--yes] mine verified benchmark tasks from git history
77
+ (dry-run unless --yes; validates RED and GOLD twice in
78
+ a temp worktree and stores test patches in the vault)
79
+ ${CLI} bench run [--yes] run benchmark tasks across arms in isolated one-commit exports
80
+ (dry-run unless --yes; grades against vault test patches
81
+ and declared gates; --regrade <run> verifies stored runs)
82
+ ${CLI} bench report statistical report across benchmark arms (R, Q, C, CPI, cache)
83
+ (read-only; paired bootstrap 95% CIs; --compare <a> <b>
84
+ prints deltas with McNemar and Wilcoxon p-values)
85
+ ${CLI} bench ablate [--yes] leave-one-out ablation across core, profile and process sections
86
+ (dry-run unless --yes; 10-task canary with escalation,
87
+ value-per-KB ranking and deletion candidates)
88
+ ${CLI} bench probes [--yes] 12 synthetic rule-compliance probes with deterministic graders
89
+ (dry-run unless --yes; grades git state, transcript and ledger,
90
+ and reports compliance per arm)
49
91
  ${CLI} mcp the read-only surface as MCP tools over stdio
50
92
  (--list prints it without reading stdin; init, sync,
51
93
  upgrade and the installers stay CLI-only by design)`;
52
94
 
53
- const FLAGS = `--json structured output
95
+ const FLAGS = `--json structured output (compact; one document, one line)
96
+ --pretty indent --json for a human
54
97
  --max-bytes <n> hard ceiling on emitted bytes (0 = unlimited, default 0)
55
98
  --budget <n> context budget for skills brief (default 20480)
56
99
  --plan emit a fetch plan instead of inlining skill bodies
@@ -67,6 +110,8 @@ const FLAGS = `--json structured output
67
110
  --dry-run report what would change, change nothing
68
111
  --yes assume yes; required in automation, never prompts
69
112
  --check report drift and write nothing (sync, ci)
113
+ --explain sync: name each line dropped as already inferable, with
114
+ the fact behind it
70
115
  --all ignore a cap: every ruling of a record, or the skills budget
71
116
  --variant <id> which body of a multi-variant skill to use
72
117
  --from <repo/path> install from a declared source by name
@@ -74,6 +119,32 @@ const FLAGS = `--json structured output
74
119
  --accept-conflicts init: adopt rules that collide with hand-written ones
75
120
  --backup init: tag the pre-adoption commit and branch onto
76
121
  simt-dev/adopt before writing (the undo path)
122
+ --spec <name> graph lint: also check every skill against a named
123
+ specification (one exists: agentskills)
124
+ --user harness detect: also report the directories in your home
125
+ --repos tokens: measure every repository under the root, not
126
+ just the one, and report what repeats an earlier one
127
+ --session <path> tokens: split a local Claude Code or Codex transcript
128
+ into rules, file reads and tool output
129
+ --reader <id> harness loaders: report one reader (opencode, claude, codex,
130
+ cursor, antigravity, copilot, deepseek)
131
+ --table harness loaders: print order, caps, imports, skill dirs and
132
+ the hook and MCP config files, without measuring a repo
133
+ --at <path> harness loaders: measure as if the agent were working in
134
+ this directory (default: the root; readers that merge the
135
+ project root down to the working directory cost more here)
136
+ --arm <spec> bench run: arm to evaluate (bare, current, kernel, brief,
137
+ or name=path/to/arm.json; repeatable)
138
+ --harness <id> bench run: adapter to invoke (claude, codex, gemini, opencode,
139
+ cursor, aider, gold, noop; default: gold);
140
+ agent-hook: which dialect to answer in (claude, cursor,
141
+ codex, antigravity)
142
+ --model <id> bench run: model identifier recorded and passed to the adapter
143
+ --reps <n> bench run: repetitions per task and arm (default: 3)
144
+ --regrade <run> bench run: re-grade a stored run from its archived agent patch
145
+ --compare <a> <b> bench report: compare two arms with deltas and p-values
146
+ --test <cmd> plan done: the test command that closes the row
147
+ --state <name> plan done: target state (default: Complete)
77
148
  --help this text
78
149
  --version the version, and the SHA it was built from`;
79
150
 
@@ -6,7 +6,15 @@
6
6
  // succeeds. An agent that calls `simt-dev compare` on an unreleased build must get a
7
7
  // failure, not an empty result it will read as "no drift".
8
8
 
9
- import { EXIT, emit, fail, isJson } from '../output/index.mjs';
9
+ import {
10
+ EXIT,
11
+ emit,
12
+ emitJson,
13
+ fail,
14
+ isJson,
15
+ jsonFailMode,
16
+ jsonIndentDefault,
17
+ } from '../output/index.mjs';
10
18
  import { help } from './help.mjs';
11
19
  import { lintCommand } from './graph.mjs';
12
20
  import {
@@ -21,11 +29,22 @@ import { syncCommand, checkCommand } from './sync.mjs';
21
29
  import { initCommand } from './init.mjs';
22
30
  import { ciCommand } from './ci.mjs';
23
31
  import { hooksCommand } from './hooks.mjs';
32
+ import { agentHookCommand, agentHooksCommand } from './agenthook.mjs';
24
33
  import { compareCommand } from './compare.mjs';
25
34
  import { upgradeCommand } from './upgrade.mjs';
26
35
  import { mcpCommand } from './mcp.mjs';
27
36
  import { installCommand } from './skillsinstall.mjs';
28
37
  import { listCommand as rulesList, showCommand as rulesShow, whyCommand as rulesWhy } from './rules.mjs';
38
+ import { tokensCommand } from './tokens.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';
29
48
  import { parse } from '../argv.mjs';
30
49
  import { fingerprint } from '../resolve/fingerprint.mjs';
31
50
  import { resolveWorkspace } from '../workspace.mjs';
@@ -43,6 +62,10 @@ const GROUPS = new Map([
43
62
  ['graph', 'lint'],
44
63
  ['rules', 'list'],
45
64
  ['hooks', 'status'],
65
+ ['plan', 'status'],
66
+ ['agent-hooks', 'status'],
67
+ ['harness', 'detect'],
68
+ ['bench', 'mine'],
46
69
  ]);
47
70
 
48
71
  /**
@@ -58,7 +81,7 @@ const GROUPS = new Map([
58
81
  * Module scope so `test/cli.test.mjs` can assert help against it. Module scope is also
59
82
  * just correct: nothing in the map depends on the call.
60
83
  */
61
- const IMPLEMENTED = {
84
+ export const IMPLEMENTED = {
62
85
  doctor: runDoctor,
63
86
  'graph lint': lintCommand,
64
87
  'skills index': skillsIndex,
@@ -73,12 +96,26 @@ const IMPLEMENTED = {
73
96
  'rules why': rulesWhy,
74
97
  sync: syncCommand,
75
98
  check: checkCommand,
99
+ 'plan status': (args, flags) => planCommand('status', args, flags),
100
+ 'plan next': (args, flags) => planCommand('next', args, flags),
101
+ 'plan done': (args, flags) => planCommand('done', args, flags),
76
102
  init: initCommand,
77
103
  ci: ciCommand,
78
104
  'hooks install': (args, flags) => hooksCommand('install', args, flags),
79
105
  'hooks status': (args, flags) => hooksCommand('status', args, flags),
80
106
  'hooks run': (args, flags) => hooksCommand('run', args, flags),
107
+ 'agent-hook': agentHookCommand,
108
+ 'agent-hooks install': (args, flags) => agentHooksCommand('install', args, flags),
109
+ 'agent-hooks status': (args, flags) => agentHooksCommand('status', args, flags),
81
110
  compare: compareCommand,
111
+ tokens: tokensCommand,
112
+ 'harness detect': harnessCommand,
113
+ 'harness loaders': harnessLoadersCommand,
114
+ 'bench mine': benchMineCommand,
115
+ 'bench run': benchRunCommand,
116
+ 'bench report': benchReportCommand,
117
+ 'bench ablate': benchAblateCommand,
118
+ 'bench probes': benchProbesCommand,
82
119
  upgrade: upgradeCommand,
83
120
  mcp: mcpCommand,
84
121
  };
@@ -92,6 +129,17 @@ export const COMMANDS = Object.keys(IMPLEMENTED).sort();
92
129
  export async function dispatch(argv) {
93
130
  const { flags, positional } = parseOrExit(argv);
94
131
 
132
+ // The JSON shape is decided once, here, and every command inherits it. Threading a
133
+ // `--pretty` through twenty-five call sites would mean twenty-five places to forget,
134
+ // and the one that forgot would be the one a reader pipes into a script.
135
+ //
136
+ // Both halves are set before anything can fail, because a command that fails under
137
+ // `--json` has still produced a result: the reason it failed. Set here, an unknown
138
+ // command answers in the same shape a working one does, and the caller's parse
139
+ // never sees an empty document.
140
+ jsonIndentDefault(flags.pretty === true);
141
+ jsonFailMode(flags.json === true);
142
+
95
143
  // Help is shown only when explicitly asked for. An unknown command must fail with
96
144
  // a usage code, not print 40 lines of prose and exit 0 — an agent that mistyped
97
145
  // needs to be told so, not handed a wall of text it may misread as output.
@@ -191,29 +239,23 @@ async function runDoctor(positional, flags) {
191
239
  const code = failure ? EXIT.CONFIG : EXIT.OK;
192
240
 
193
241
  if (isJson(flags)) {
194
- emit(
195
- `${JSON.stringify(
196
- {
197
- ok: code === EXIT.OK,
198
- command: 'doctor',
199
- root,
200
- ...result,
201
- // The workspace answer is a fact about which directory the tool would
202
- // act on next (ADR-014). It is printed in the text report; leaving it
203
- // out of the JSON meant a machine-readable caller could not see it at
204
- // all, which is the one-question-two-answers defect again.
205
- workspace: {
206
- root: ws.root ?? null,
207
- source: ws.source,
208
- detail: ws.detail,
209
- error: ws.error ?? null,
210
- },
211
- ...(failure ?? {}),
212
- },
213
- null,
214
- 2,
215
- )}\n`,
216
- );
242
+ emitJson({
243
+ ok: code === EXIT.OK,
244
+ command: 'doctor',
245
+ root,
246
+ ...result,
247
+ // The workspace answer is a fact about which directory the tool would
248
+ // act on next (ADR-014). It is printed in the text report; leaving it
249
+ // out of the JSON meant a machine-readable caller could not see it at
250
+ // all, which is the one-question-two-answers defect again.
251
+ workspace: {
252
+ root: ws.root ?? null,
253
+ source: ws.source,
254
+ detail: ws.detail,
255
+ error: ws.error ?? null,
256
+ },
257
+ ...(failure ?? {}),
258
+ });
217
259
  return code;
218
260
  }
219
261
  if (result.error) {