simt-dev 0.6.2 → 0.7.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 (170) hide show
  1. package/CHANGELOG.md +83 -1
  2. package/README.md +46 -17
  3. package/agents/simt-agent.md +84 -0
  4. package/bin/simt.mjs +19 -6
  5. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  6. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  7. package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
  8. package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
  9. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
  10. package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
  11. package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
  12. package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
  13. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  14. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  15. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  16. package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
  17. package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
  18. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  19. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  20. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  21. package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
  22. package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
  23. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  24. package/docs/decisions/README.md +53 -0
  25. package/package.json +4 -2
  26. package/rules/core.md +2 -2
  27. package/rules/mdc/preview-postflight.mdc +5 -6
  28. package/rules/mdc/test-gate.mdc +2 -0
  29. package/rules/stacks/android.md +10 -11
  30. package/rules/stacks/cloudflare-workers.md +1 -1
  31. package/rules/stacks/connectiq.md +2 -2
  32. package/rules/stacks/container.md +2 -0
  33. package/rules/stacks/dart.md +1 -1
  34. package/rules/stacks/deno.md +1 -1
  35. package/rules/stacks/dotnet.md +1 -1
  36. package/rules/stacks/elixir.md +1 -1
  37. package/rules/stacks/go.md +1 -1
  38. package/rules/stacks/jvm.md +4 -3
  39. package/rules/stacks/node.md +2 -2
  40. package/rules/stacks/python.md +7 -6
  41. package/rules/stacks/react-native.md +3 -5
  42. package/rules/stacks/react-web.md +9 -9
  43. package/rules/stacks/ruby.md +1 -1
  44. package/rules/stacks/rust.md +1 -1
  45. package/rules/stacks/swift.md +1 -1
  46. package/rules/stacks/typescript.md +2 -2
  47. package/rules/stacks/uv.md +3 -2
  48. package/skills/simt-commands/SKILL.md +70 -0
  49. package/src/advice/advice.mjs +7 -3
  50. package/src/agenthooks/decide.mjs +238 -15
  51. package/src/agenthooks/opencode.mjs +72 -0
  52. package/src/agenthooks/policy.mjs +81 -5
  53. package/src/agenthooks/prompt.mjs +6 -2
  54. package/src/agenthooks/render.mjs +186 -19
  55. package/src/agenthooks/shell.mjs +523 -0
  56. package/src/agenthooks/shim.mjs +55 -0
  57. package/src/agents/render.mjs +277 -0
  58. package/src/argv.mjs +62 -9
  59. package/src/bench/ablate.mjs +31 -14
  60. package/src/bench/mine.mjs +48 -1
  61. package/src/bench/probes.mjs +5 -6
  62. package/src/bench/report.mjs +243 -80
  63. package/src/bench/run.mjs +12 -11
  64. package/src/bench/variants.mjs +112 -40
  65. package/src/brief/assemble.mjs +6 -0
  66. package/src/ci/workflow.mjs +16 -7
  67. package/src/commands/agent.mjs +206 -0
  68. package/src/commands/agenthook.mjs +133 -17
  69. package/src/commands/bench.mjs +2 -1
  70. package/src/commands/ci.mjs +28 -3
  71. package/src/commands/compare.mjs +8 -7
  72. package/src/commands/completions.mjs +27 -0
  73. package/src/commands/decide.mjs +20 -6
  74. package/src/commands/doctor.mjs +367 -0
  75. package/src/commands/evidence.mjs +13 -3
  76. package/src/commands/explain.mjs +52 -0
  77. package/src/commands/gate.mjs +85 -19
  78. package/src/commands/graph.mjs +18 -5
  79. package/src/commands/help.mjs +96 -26
  80. package/src/commands/hooks.mjs +258 -67
  81. package/src/commands/ignore.mjs +152 -0
  82. package/src/commands/impact.mjs +4 -2
  83. package/src/commands/index.mjs +69 -102
  84. package/src/commands/init.mjs +239 -37
  85. package/src/commands/lease.mjs +304 -0
  86. package/src/commands/lessons.mjs +77 -9
  87. package/src/commands/map.mjs +33 -18
  88. package/src/commands/mcp.mjs +2 -2
  89. package/src/commands/pack.mjs +9 -0
  90. package/src/commands/plan.mjs +13 -1
  91. package/src/commands/plugin.mjs +17 -2
  92. package/src/commands/prbody.mjs +45 -0
  93. package/src/commands/repoindex.mjs +4 -0
  94. package/src/commands/rules.mjs +219 -105
  95. package/src/commands/shared.mjs +36 -5
  96. package/src/commands/skills.mjs +329 -206
  97. package/src/commands/skillscompile.mjs +8 -7
  98. package/src/commands/skillsinstall.mjs +112 -30
  99. package/src/commands/slice.mjs +712 -0
  100. package/src/commands/status.mjs +62 -0
  101. package/src/commands/sync.mjs +146 -36
  102. package/src/commands/tests.mjs +5 -2
  103. package/src/commands/undo.mjs +114 -0
  104. package/src/commands/upgrade.mjs +103 -9
  105. package/src/commands/why.mjs +10 -4
  106. package/src/compare/hooksstate.mjs +23 -1
  107. package/src/compare/learn.mjs +82 -57
  108. package/src/compare/parallel.mjs +50 -0
  109. package/src/compare/row.mjs +26 -1
  110. package/src/completions.mjs +196 -0
  111. package/src/decisions/dir.mjs +84 -0
  112. package/src/decisions/index.mjs +109 -11
  113. package/src/diff.mjs +254 -0
  114. package/src/distill/distill.mjs +3 -1
  115. package/src/ecosystem/node-runner.mjs +144 -0
  116. package/src/explain.mjs +270 -0
  117. package/src/federation/config.mjs +44 -0
  118. package/src/graph/load.mjs +52 -24
  119. package/src/hooks/capability.mjs +14 -1
  120. package/src/hooks/dispatcher.mjs +441 -9
  121. package/src/hooks/gates.mjs +14 -3
  122. package/src/hooks/resolve.mjs +23 -3
  123. package/src/hooks/shim.mjs +12 -7
  124. package/src/identity.mjs +67 -14
  125. package/src/ignore.mjs +133 -0
  126. package/src/index/extract.mjs +192 -11
  127. package/src/index/persist.mjs +372 -55
  128. package/src/index/store.mjs +263 -60
  129. package/src/index/tests.mjs +6 -35
  130. package/src/journal.mjs +545 -0
  131. package/src/kernel/card.mjs +37 -12
  132. package/src/kernel/checks.mjs +44 -9
  133. package/src/kernel/variants.mjs +27 -16
  134. package/src/lease.mjs +195 -0
  135. package/src/ledger/index.mjs +91 -25
  136. package/src/mcp/resources.mjs +5 -3
  137. package/src/mcp/server.mjs +86 -13
  138. package/src/mcp/tools.mjs +340 -264
  139. package/src/memory/decide.mjs +98 -38
  140. package/src/memory/lessons.mjs +180 -33
  141. package/src/name.mjs +9 -0
  142. package/src/output/index.mjs +82 -12
  143. package/src/package-version.mjs +49 -1
  144. package/src/packs/manage.mjs +13 -4
  145. package/src/plan/index.mjs +37 -8
  146. package/src/plan/template.mjs +27 -5
  147. package/src/plugins.mjs +33 -8
  148. package/src/prbody.mjs +253 -0
  149. package/src/projection/baselines.mjs +1 -1
  150. package/src/projection/disclosure.mjs +2 -2
  151. package/src/projection/loaders.mjs +41 -1
  152. package/src/projection/lock.mjs +21 -14
  153. package/src/projection/manifest.mjs +82 -21
  154. package/src/projection/markers.mjs +43 -3
  155. package/src/projection/mdc.mjs +34 -7
  156. package/src/projection/project.mjs +162 -18
  157. package/src/projection/rules.mjs +54 -3
  158. package/src/projection/shared.mjs +4 -1
  159. package/src/projection/surfaces.mjs +9 -1
  160. package/src/repo-root.mjs +69 -0
  161. package/src/resolve/body.mjs +4 -0
  162. package/src/resolve/fingerprint.mjs +5 -0
  163. package/src/resolve/local.mjs +58 -0
  164. package/src/review/critic.mjs +4 -2
  165. package/src/secrets.mjs +374 -0
  166. package/src/state.mjs +76 -0
  167. package/src/status.mjs +157 -0
  168. package/src/toolwords.mjs +5 -0
  169. package/src/tsconfig.mjs +39 -99
  170. package/templates/plan/tracker.json +1 -1
@@ -0,0 +1,277 @@
1
+ // One renderer per harness for the agent file, and the plan the install writes.
2
+ //
3
+ // Six harnesses read a subagent and no two agree on the file. Five write a
4
+ // markdown file with YAML frontmatter and a body that becomes the system prompt;
5
+ // Codex writes a TOML file whose `developer_instructions` holds the same body.
6
+ // The path, the extension, the frontmatter keys and the document shape are all
7
+ // different, and none of them is the same fact. So this module owns the
8
+ // differences and the command owns none of them:
9
+ //
10
+ // opencode .opencode/agents/simt-agent.md markdown description
11
+ // claude .claude/agents/simt-agent.md markdown name, description
12
+ // cursor .cursor/agents/simt-agent.md markdown name, description
13
+ // gemini .gemini/agents/simt-agent.md markdown name, description
14
+ // codex .codex/agents/simt-agent.toml TOML name, description, developer_instructions
15
+ // antigravity .agents/agents/simt-agent.md markdown name, description
16
+ //
17
+ // The source of truth is ONE file in this repository, `agents/simt-agent.md`:
18
+ // neutral frontmatter plus a harness-agnostic body. The renderer strips that
19
+ // frontmatter and re-emits the keys each harness documents, so the body — the
20
+ // part that says how to act — is written once and cannot drift between
21
+ // harnesses. An agent is a file the tool owns whole, not a config entry to
22
+ // merge, so `rendered` compares bytes and reports created, unchanged or updated.
23
+
24
+ import { mkdir, readFile } from 'node:fs/promises';
25
+ import { dirname, join } from 'node:path';
26
+
27
+ import { PACKAGE_ROOT } from '../workspace.mjs';
28
+ import { splitFrontmatter } from '../memory/decide.mjs';
29
+ import { LOADERS } from '../projection/loaders.mjs';
30
+ import { journaledWriteFile } from '../journal.mjs';
31
+
32
+ /** The one agent this tool ships, and the filename stem every harness shares. */
33
+ const AGENT_FILE = 'simt-agent.md';
34
+ export const AGENT_STEM = 'simt-agent';
35
+
36
+ /** The companion skill: the command reference the agent reads on demand. */
37
+ const SKILL_SOURCE = join('skills', 'simt-commands', 'SKILL.md');
38
+ export const SKILL_STEM = 'simt-commands';
39
+
40
+ /**
41
+ * The per-harness differences. `frontmatter` returns the ordered [key, value]
42
+ * pairs a markdown harness documents; Codex has none because its shape is TOML,
43
+ * not frontmatter. The body is identical everywhere and is not a difference.
44
+ */
45
+ const ADAPTERS = {
46
+ opencode: {
47
+ id: 'opencode',
48
+ format: 'md',
49
+ frontmatter: (agent) => [
50
+ ['description', agent.description],
51
+ ],
52
+ },
53
+ claude: {
54
+ id: 'claude',
55
+ format: 'md',
56
+ frontmatter: (agent) => [
57
+ ['name', agent.name],
58
+ ['description', agent.description],
59
+ ],
60
+ },
61
+ cursor: {
62
+ id: 'cursor',
63
+ format: 'md',
64
+ frontmatter: (agent) => [
65
+ ['name', agent.name],
66
+ ['description', agent.description],
67
+ ],
68
+ },
69
+ gemini: {
70
+ id: 'gemini',
71
+ format: 'md',
72
+ frontmatter: (agent) => [
73
+ ['name', agent.name],
74
+ ['description', agent.description],
75
+ ],
76
+ },
77
+ codex: {
78
+ id: 'codex',
79
+ format: 'toml',
80
+ },
81
+ antigravity: {
82
+ id: 'antigravity',
83
+ format: 'md',
84
+ frontmatter: (agent) => [
85
+ ['name', agent.name],
86
+ ['description', agent.description],
87
+ ],
88
+ },
89
+ };
90
+
91
+ /** The reader-table row for a harness's agents directory, or null when none is documented. */
92
+ function agentInfo(id) {
93
+ const row = LOADERS.find((l) => l.id === id);
94
+ return row?.agents ?? null;
95
+ }
96
+
97
+ /**
98
+ * The path a harness reads the agent from, sourced from the reader table rather
99
+ * than spelled here. Null for a harness that documents no agents directory.
100
+ */
101
+ export function agentPath(id) {
102
+ const info = agentInfo(id);
103
+ return info ? join(info.dir, `${AGENT_STEM}${info.ext}`) : null;
104
+ }
105
+
106
+ /**
107
+ * Every reader, with the agents path it documents and the note for the ones that
108
+ * do not. The command reports these so a harness this tool does not write is
109
+ * named rather than silent.
110
+ */
111
+ export function agentRows() {
112
+ return LOADERS.map((l) => ({
113
+ id: l.id,
114
+ label: l.label,
115
+ path: l.agents ? join(l.agents.dir, `${AGENT_STEM}${l.agents.ext}`) : null,
116
+ note: l.agents ? l.agents.verified : (l.agentsNote ?? 'no agents file is documented'),
117
+ }));
118
+ }
119
+
120
+ /** The adapters that both have a reader-table path and a renderer, in reader order. */
121
+ export function adapters() {
122
+ return agentRows()
123
+ .filter((r) => r.path !== null)
124
+ .map((r) => ADAPTERS[r.id])
125
+ .filter(Boolean);
126
+ }
127
+
128
+ /** One adapter by id, or null when the harness has no path or no renderer. */
129
+ export function adapter(id) {
130
+ return ADAPTERS[id] && agentInfo(id) !== null ? ADAPTERS[id] : null;
131
+ }
132
+
133
+ /** Parse the neutral frontmatter of the source file: `key: value` lines. */
134
+ function parseSimpleFront(front) {
135
+ const out = {};
136
+ for (const line of front.split('\n')) {
137
+ const idx = line.indexOf(':');
138
+ if (idx < 0) continue;
139
+ const key = line.slice(0, idx).trim();
140
+ const val = line.slice(idx + 1).trim();
141
+ if (key) out[key] = val;
142
+ }
143
+ return out;
144
+ }
145
+
146
+ /**
147
+ * The one agent, read from this repository's `agents/` directory. The body is
148
+ * the system prompt; `name` and `description` are re-emitted per harness.
149
+ */
150
+ export async function loadAgent() {
151
+ const raw = await readFile(join(PACKAGE_ROOT, 'agents', AGENT_FILE), 'utf8');
152
+ const { front, body } = splitFrontmatter(raw);
153
+ const meta = parseSimpleFront(front ?? '');
154
+ if (!meta.name || !meta.description) {
155
+ throw new Error(`${AGENT_FILE} must declare a name and a description in its frontmatter`);
156
+ }
157
+ return { name: meta.name, description: meta.description, body: body.trim() };
158
+ }
159
+
160
+ /** The companion skill file, read verbatim from this repository. */
161
+ async function loadSkill() {
162
+ return readFile(join(PACKAGE_ROOT, SKILL_SOURCE), 'utf8');
163
+ }
164
+
165
+ /**
166
+ * Where the skill lands in the receiving repo. The same heuristic `skills
167
+ * install` uses: respect an existing skills directory, default to `.agents/skills`.
168
+ */
169
+ async function skillDestPath(root) {
170
+ let entries = [];
171
+ try {
172
+ const { readdir } = await import('node:fs/promises');
173
+ entries = await readdir(root);
174
+ } catch { /* fresh repo, no entries */ }
175
+ let dir;
176
+ if (entries.includes('.agents')) dir = '.agents/skills';
177
+ else if (entries.includes('.cursor')) dir = '.cursor/skills';
178
+ else if (entries.includes('.claude')) dir = '.claude/skills';
179
+ else dir = '.agents/skills';
180
+ return join(dir, SKILL_STEM, 'SKILL.md');
181
+ }
182
+
183
+ /** A basic TOML string: double-quoted, with backslashes and quotes escaped. */
184
+ function tomlString(value) {
185
+ return '"' + String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
186
+ }
187
+
188
+ /** The markdown form: frontmatter the harness documents, a blank line, the body. */
189
+ function renderMarkdown(adapterRow, agent) {
190
+ const fm = adapterRow.frontmatter(agent).map(([k, v]) => `${k}: ${v}`).join('\n');
191
+ return `---\n${fm}\n---\n\n${agent.body}\n`;
192
+ }
193
+
194
+ /**
195
+ * The Codex form: a TOML file. The body goes in `developer_instructions` as a
196
+ * multi-line basic string; a literal `"""` in the body would end the string, so
197
+ * one of its quotes is escaped.
198
+ */
199
+ function renderToml(agent) {
200
+ const body = agent.body.replace(/"""/g, '\\"""');
201
+ return `name = ${tomlString(agent.name)}\ndescription = ${tomlString(agent.description)}\ndeveloper_instructions = """\n${body}\n"""\n`;
202
+ }
203
+
204
+ /**
205
+ * Render the agent for a harness and compare it to what is on disk. The tool
206
+ * owns the file whole, so the comparison is by bytes and the action is one of
207
+ * created, unchanged, updated.
208
+ */
209
+ export function rendered(adapterRow, agent, existing) {
210
+ const text = adapterRow.format === 'toml' ? renderToml(agent) : renderMarkdown(adapterRow, agent);
211
+ if (existing === null) return { text, action: 'created' };
212
+ if (existing === text) return { text, action: 'unchanged' };
213
+ return { text, action: 'updated' };
214
+ }
215
+
216
+ /** Read a file as UTF-8, or null when it is not there. */
217
+ async function readIfExists(path) {
218
+ try {
219
+ return await readFile(path, 'utf8');
220
+ } catch {
221
+ return null;
222
+ }
223
+ }
224
+
225
+ /**
226
+ * The agent files a repository should carry, one per harness that documents an
227
+ * agents directory, plus the companion skill file. Reads each existing file and
228
+ * renders what install would write, so a dry run and a run describe the same set
229
+ * of files. `harness` narrows the agent files to one; the skill is always included.
230
+ */
231
+ export async function planAgents(root, { harness } = {}) {
232
+ const agent = await loadAgent();
233
+ const rows = harness === undefined ? adapters() : adapters().filter((a) => a.id === harness);
234
+ const plan = [];
235
+ for (const row of rows) {
236
+ const path = agentPath(row.id);
237
+ const existing = await readIfExists(join(root, path));
238
+ const result = rendered(row, agent, existing);
239
+ plan.push({ id: row.id, path, action: result.action, text: result.text });
240
+ }
241
+ // The companion skill: one file, not per-harness.
242
+ const skillText = await loadSkill();
243
+ const skillPath = await skillDestPath(root);
244
+ const skillExisting = await readIfExists(join(root, skillPath));
245
+ const skillAction = skillExisting === null ? 'created' : skillExisting === skillText ? 'unchanged' : 'updated';
246
+ plan.push({ id: 'skill', path: skillPath, action: skillAction, text: skillText });
247
+ return plan;
248
+ }
249
+
250
+ /**
251
+ * Write the agent files a plan names, journaled so `undo` restores the run.
252
+ * Skips files that are already unchanged. Returns the paths written.
253
+ */
254
+ export async function writeAgentPlan(root, journal, plan) {
255
+ const written = [];
256
+ for (const p of plan.filter((x) => x.action !== 'unchanged')) {
257
+ await mkdir(join(root, dirname(p.path)), { recursive: true });
258
+ await journaledWriteFile(root, journal, join(root, p.path), p.text);
259
+ written.push(p.path);
260
+ }
261
+ return written;
262
+ }
263
+
264
+ /**
265
+ * Write just the skill file so `installedSkills` can find it before `plan()`
266
+ * computes the region. Called by `init` before the region is rendered.
267
+ */
268
+ export async function writeSkillFile(root, journal) {
269
+ const text = await loadSkill();
270
+ const path = await skillDestPath(root);
271
+ const full = join(root, path);
272
+ const existing = await readIfExists(full);
273
+ if (existing === text) return null;
274
+ await mkdir(dirname(full), { recursive: true });
275
+ await journaledWriteFile(root, journal, full, text);
276
+ return path;
277
+ }
package/src/argv.mjs CHANGED
@@ -10,7 +10,7 @@
10
10
  // Anything unrecognised is an error, not a silent ignore: a typo'd gate name
11
11
  // that is ignored is worse than a typo'd gate name that fails.
12
12
 
13
- import { EXIT, fail } from './output/index.mjs';
13
+ import { EXIT } from './output/index.mjs';
14
14
 
15
15
  // `--quiet`, `--semantic` and `--mermaid` were here and were read by nothing (review
16
16
  // 0001 filed it; it survived every change to this file since). Each was a plan's
@@ -93,6 +93,18 @@ export const BOOL_FLAGS = new Set([
93
93
  // the deterministic outline. Off by default: no model in the default path (P1),
94
94
  // and a model that is not asked for should not be opened against (ADR-090).
95
95
  'local',
96
+ // doctor: apply the remedies flagged safe (local, idempotent, journaled) and
97
+ // list the rest. Off by default because a repair, however safe, is still a
98
+ // write, and a command whose name is a diagnosis should not write by default.
99
+ 'fix',
100
+ // status: print the one-line gate summary from the last check. Off by
101
+ // default because the only mode this command has is the line; bare `status`
102
+ // is a usage error naming it, the way `plan` without a subcommand is.
103
+ 'line',
104
+ // every writer: print the plan as unified diffs. Off by default because a
105
+ // plan report is the compact answer and the diff is the verbose one; --diff
106
+ // never changes what the run writes or its exit code, only what it shows.
107
+ 'diff',
96
108
  ]);
97
109
 
98
110
  export const VALUE_FLAGS = new Set([
@@ -107,6 +119,14 @@ export const VALUE_FLAGS = new Set([
107
119
  'task',
108
120
  'workspace',
109
121
  'root',
122
+ // skills/graph: which registry to read. A value rather than a boolean because
123
+ // "a registry" without a path would have to guess, and the guess is the
124
+ // defect Q37 exists to remove (review 0003).
125
+ 'registry',
126
+ // compare: how many repository rows to build at once (review 0003 Q38).
127
+ // A count, not a switch, because "parallel" without a cap is the burst this
128
+ // flag exists to prevent.
129
+ 'concurrency',
110
130
  // skills install: which body of a multi-variant skill, and which source when the
111
131
  // bytes alone do not identify one.
112
132
  'variant',
@@ -138,6 +158,15 @@ export const VALUE_FLAGS = new Set([
138
158
  // plan done: the test command that proves the slice. Plan 0003 R31.
139
159
  'test',
140
160
  'state',
161
+ // slice finish: the Conventional Commits type for the commit it makes. A value,
162
+ // not a boolean, because a row title cannot say whether the slice was a feat, a
163
+ // fix or a chore, and deriving it from the words would be a guess no reader of
164
+ // the message could argue with (plan 0003 R53).
165
+ 'type',
166
+ // lease: how long the claim lasts, in seconds. A value, because "a lease is short"
167
+ // is not a number any reader can act on, and an expiry nobody chose is an expiry
168
+ // nobody can plan around (plan 0003 R54, ADR-101).
169
+ 'ttl',
141
170
  // map: outline (paths), names (exported symbols) or signatures (the line).
142
171
  // A boolean would not say which of the three the caller wanted.
143
172
  'level',
@@ -151,6 +180,10 @@ export const VALUE_FLAGS = new Set([
151
180
  // "generate a package" with no destination would have to guess, and a guess about where
152
181
  // a directory tree goes is the one failure that leaves files behind.
153
182
  'out',
183
+ // pr-body: the ref the body diffs against. A value, because the fork point is a
184
+ // name only the author's branch knows, and the default (origin/main, then main)
185
+ // is a guess the report states out loud rather than a question it asks.
186
+ 'base',
154
187
  ]);
155
188
 
156
189
  /**
@@ -168,9 +201,29 @@ export const VALUE_FLAGS = new Set([
168
201
  */
169
202
  export const REPEATABLE_FLAGS = new Set(['skill', 'arm', 'compare', 'supersedes']);
170
203
 
204
+ /**
205
+ * Whether a raw argv asks for JSON, read before parsing succeeds.
206
+ *
207
+ * A parse failure is still a result the caller may need as JSON (Q11), but the
208
+ * parsed flags do not exist yet — so the dispatcher reads the spelling straight
209
+ * off argv. Only the exact `--json` spelling counts, and nothing past `--` does:
210
+ * a positional that happens to read `--json` is a path, not a flag.
211
+ */
212
+ export function rawJsonFlag(argv) {
213
+ for (const arg of argv) {
214
+ if (arg === '--') break;
215
+ if (arg === '--json') return true;
216
+ if (arg === '--no-json') return false;
217
+ }
218
+ return false;
219
+ }
220
+
171
221
  /**
172
222
  * @param {string[]} argv raw arguments, excluding node and the script path
173
- * @returns {{ok: true, value: object} | {ok: false, code: number}}
223
+ * @returns {{ok: true, value: object} | {ok: false, error: {message: string, code: number}}}
224
+ *
225
+ * A failure is data, never output: reporting it is the caller's job, because only
226
+ * the caller knows whether `--json` was asked for (Q11). Nothing here prints.
174
227
  */
175
228
  export function parse(argv) {
176
229
  const flags = {};
@@ -196,14 +249,14 @@ export function parse(argv) {
196
249
  if (raw.startsWith('no-')) {
197
250
  const positive = raw.slice(3);
198
251
  if (!BOOL_FLAGS.has(positive)) {
199
- return { ok: false, code: usage(`unknown flag --${raw}`) };
252
+ return { ok: false, error: usage(`unknown flag --${raw}`) };
200
253
  }
201
254
  flags[camel(positive)] = false;
202
255
  continue;
203
256
  }
204
257
  if (BOOL_FLAGS.has(raw)) {
205
258
  if (inlineValue !== undefined) {
206
- return { ok: false, code: usage(`--${raw} takes no value`) };
259
+ return { ok: false, error: usage(`--${raw} takes no value`) };
207
260
  }
208
261
  flags[camel(raw)] = true;
209
262
  continue;
@@ -211,19 +264,19 @@ export function parse(argv) {
211
264
  if (VALUE_FLAGS.has(raw)) {
212
265
  const value = inlineValue ?? argv[i + 1];
213
266
  if (value === undefined || (inlineValue === undefined && value.startsWith('--'))) {
214
- return { ok: false, code: usage(`--${raw} requires a value`) };
267
+ return { ok: false, error: usage(`--${raw} requires a value`) };
215
268
  }
216
269
  if (inlineValue === undefined) i += 1;
217
270
  if (raw === 'budget' || raw === 'max-bytes') {
218
271
  const n = Number(value);
219
272
  if (!Number.isInteger(n) || n < 0) {
220
- return { ok: false, code: usage(`--${raw} must be a non-negative integer, received ${value}`) };
273
+ return { ok: false, error: usage(`--${raw} must be a non-negative integer, received ${value}`) };
221
274
  }
222
275
  flags[camel(raw)] = n;
223
276
  } else if (raw === 'reps') {
224
277
  const n = Number(value);
225
278
  if (!Number.isInteger(n) || n < 1) {
226
- return { ok: false, code: usage(`--reps must be a positive integer, received ${value}`) };
279
+ return { ok: false, error: usage(`--reps must be a positive integer, received ${value}`) };
227
280
  }
228
281
  flags.reps = n;
229
282
  } else {
@@ -234,7 +287,7 @@ export function parse(argv) {
234
287
  }
235
288
  continue;
236
289
  }
237
- return { ok: false, code: usage(`unknown flag --${raw}`) };
290
+ return { ok: false, error: usage(`unknown flag --${raw}`) };
238
291
  }
239
292
 
240
293
  return { ok: true, value: { flags, positional } };
@@ -253,5 +306,5 @@ function camel(name) {
253
306
  }
254
307
 
255
308
  function usage(message) {
256
- return fail(message, EXIT.USAGE);
309
+ return { message, code: EXIT.USAGE };
257
310
  }
@@ -11,6 +11,7 @@ import { mkdir, readFile, rm, unlink, writeFile } from 'node:fs/promises';
11
11
  import { dirname, join, resolve } from 'node:path';
12
12
 
13
13
  import { MANIFEST_FILE, STATE_DIR } from '../name.mjs';
14
+ import { LOCAL_DIR } from '../state.mjs';
14
15
  import { readManifest } from '../projection/manifest.mjs';
15
16
  import { inject, parse as parseRegion } from '../projection/markers.mjs';
16
17
  import { loadMdcRules, renderMdc, ruleName } from '../projection/mdc.mjs';
@@ -23,6 +24,7 @@ import {
23
24
  DEFAULT_BOOTSTRAP_SEED,
24
25
  compareArms,
25
26
  evaluateRunRecord,
27
+ holmAdjust,
26
28
  loadPrices,
27
29
  loadRunRecords,
28
30
  } from './report.mjs';
@@ -340,11 +342,14 @@ export async function ablateSectionInWorkspace(workDir, ablate) {
340
342
 
341
343
  /**
342
344
  * Decide whether a canary comparison warrants escalating the section arm to the full task suite.
345
+ *
346
+ * `pValue` overrides the comparison's raw p-values when the caller supplies a
347
+ * multiplicity-adjusted value (Holm over the family of sections tested in one run).
343
348
  */
344
- export function shouldEscalateCanary(comparison, { alpha = CANARY_ALPHA } = {}) {
349
+ export function shouldEscalateCanary(comparison, { alpha = CANARY_ALPHA, pValue } = {}) {
345
350
  if (!comparison || !comparison.pairedTasks) return false;
346
- if (comparison.mcnemar.pValue < alpha) return true;
347
- if (comparison.wilcoxon.pValue < alpha) return true;
351
+ const p = pValue ?? Math.min(comparison.rTest.pValue, comparison.wilcoxon.pValue);
352
+ if (p < alpha) return true;
348
353
  if (comparison.ci95.R[0] > 0 || comparison.ci95.R[1] < 0) return true;
349
354
  if (Math.abs(comparison.deltas.R) >= 0.2) return true;
350
355
  return false;
@@ -469,15 +474,27 @@ export async function ablateBench(
469
474
  const canaryEvaluated = canaryRecords.map((rec) => evaluateRunRecord(rec, prices));
470
475
  const currentCanary = canaryEvaluated.filter((r) => r.arm === 'current');
471
476
 
477
+ const canaryComparisons = [];
472
478
  for (const sec of sections) {
473
479
  const armCanary = canaryEvaluated.filter((r) => r.arm === sec.arm);
474
480
  if (currentCanary.length > 0 && armCanary.length > 0) {
475
- const cmp = compareArms(sec.arm, armCanary, 'current', currentCanary, { samples, seed });
476
- if (shouldEscalateCanary(cmp)) {
477
- escalatedArms.add(sec.arm);
478
- }
481
+ canaryComparisons.push({
482
+ sec,
483
+ cmp: compareArms(sec.arm, armCanary, 'current', currentCanary, { samples, seed }),
484
+ });
479
485
  }
480
486
  }
487
+ // Family = every section tested in this one run: testing ~N sections at α 0.05
488
+ // each inflates the false-positive rate, so the p-values are Holm-adjusted
489
+ // before the escalation decision (review 0003 Q46).
490
+ const adjusted = holmAdjust(
491
+ canaryComparisons.map(({ cmp }) => Math.min(cmp.rTest.pValue, cmp.wilcoxon.pValue)),
492
+ );
493
+ canaryComparisons.forEach(({ sec, cmp }, i) => {
494
+ if (shouldEscalateCanary(cmp, { pValue: adjusted[i] })) {
495
+ escalatedArms.add(sec.arm);
496
+ }
497
+ });
481
498
 
482
499
  if (escalatedArms.size > 0) {
483
500
  await execRunBench(resolvedRoot, {
@@ -529,7 +546,7 @@ export async function ablateBench(
529
546
  dC: [0, 0],
530
547
  valuePerKb: [0, 0],
531
548
  },
532
- mcnemar: { statistic: 0, pValue: 1, b: 0, c: 0 },
549
+ rTest: { statistic: 0, pValue: 1, plus: 0, minus: 0 },
533
550
  wilcoxon: { statistic: 0, pValue: 1, wPlus: 0, wMinus: 0 },
534
551
  deletionCandidate: false,
535
552
  };
@@ -566,11 +583,11 @@ export async function ablateBench(
566
583
  dC: cmp.ci95.C,
567
584
  valuePerKb: vCi,
568
585
  },
569
- mcnemar: {
570
- statistic: cmp.mcnemar.statistic,
571
- pValue: cmp.mcnemar.pValue,
572
- b: cmp.mcnemar.b,
573
- c: cmp.mcnemar.c,
586
+ rTest: {
587
+ statistic: cmp.rTest.statistic,
588
+ pValue: cmp.rTest.pValue,
589
+ plus: cmp.rTest.plus,
590
+ minus: cmp.rTest.minus,
574
591
  },
575
592
  wilcoxon: {
576
593
  statistic: cmp.wilcoxon.statistic,
@@ -616,7 +633,7 @@ export async function ablateBench(
616
633
  const markdown = formatAblationMarkdown(result);
617
634
 
618
635
  if (apply) {
619
- const outPath = join(resolvedRoot, STATE_DIR, 'bench', 'ablation.json');
636
+ const outPath = join(resolvedRoot, LOCAL_DIR, 'bench', 'ablation.json');
620
637
  await mkdir(dirname(outPath), { recursive: true });
621
638
  await writeFile(outPath, `${JSON.stringify({ schemaVersion: 1, ...result }, null, 2)}\n`, 'utf8');
622
639
  }
@@ -29,11 +29,12 @@ export const DEFAULT_MINE_TARGET = 20;
29
29
  /**
30
30
  * Classify whether a repository-relative path is a test file.
31
31
  *
32
- * Covers the five ecosystems named in plan 0003 §5 R01:
32
+ * Covers the ecosystems named in plan 0003 §5 R01:
33
33
  * - `test/`, `tests/`, `spec/`, `__tests__/`, `src/test/`
34
34
  * - `*.test.*`, `*.spec.*`, `*_test.*`, `*_spec.*`
35
35
  * - `*_test.go` (Go)
36
36
  * - `test_*.py` (pytest)
37
+ * - `tests/*.rs` (Cargo integration tests)
37
38
  * - `*Test.java`, `*Tests.java`, `*Test.kt`, `*Tests.kt` (Gradle / Maven)
38
39
  *
39
40
  * @param {string} relPath forward-slash repository-relative path
@@ -259,6 +260,31 @@ export function parseGoTestJson(text) {
259
260
  };
260
261
  }
261
262
 
263
+ /**
264
+ * Parse libtest's text output, which `cargo test` prints for every test binary.
265
+ *
266
+ * A line is a result only when it has the `test <name> ... <status>` shape.
267
+ * Harness summaries (`test result: ok.`), compile output and doctest prose are
268
+ * not results. `ignored` is neither a pass nor a failure: counting it as one
269
+ * would make a skipped test look like evidence the change works.
270
+ *
271
+ * @param {string} text
272
+ * @returns {{passed: string[], failed: string[]}}
273
+ */
274
+ export function parseCargoTestOutput(text) {
275
+ const passed = [];
276
+ const failed = [];
277
+ for (const line of String(text ?? '').split('\n')) {
278
+ const match = /^test\s+(.+?)\s+\.\.\.\s+(ok|FAILED|ignored)\s*$/.exec(line.trimEnd());
279
+ if (!match || match[2] === 'ignored') continue;
280
+ (match[2] === 'ok' ? passed : failed).push(match[1]);
281
+ }
282
+ return {
283
+ passed: dedupeSorted(passed),
284
+ failed: dedupeSorted(failed),
285
+ };
286
+ }
287
+
262
288
  /**
263
289
  * Detect the test runner for a repository and build the command for a set of
264
290
  * changed test files.
@@ -302,6 +328,27 @@ export async function detectTestRunner(root, testFiles) {
302
328
  };
303
329
  }
304
330
 
331
+ if (existsSync(join(root, 'Cargo.toml'))) {
332
+ // Cargo has no "run this file" argument. An integration test is a target
333
+ // named after its file in `tests/`, so it can be selected with `--test`.
334
+ // A source file carries inline unit tests and can only be reached by the
335
+ // crate's test binary, so one such file means the whole crate runs.
336
+ const targets = dedupeSorted(
337
+ files.filter((f) => /^tests\/[^/]+\.rs$/.test(f)).map((f) => basename(f, '.rs')),
338
+ );
339
+ const integrationOnly = files.length > 0 && files.every((f) => /^tests\/[^/]+\.rs$/.test(f));
340
+ const args = ['test'];
341
+ if (integrationOnly) {
342
+ for (const target of targets) args.push('--test', target);
343
+ }
344
+ return {
345
+ runner: 'cargo-test',
346
+ displayCmd: ['cargo', ...args].join(' '),
347
+ buildExec: () => ({ cmd: 'cargo', args }),
348
+ parse: (stdout) => parseCargoTestOutput(stdout),
349
+ };
350
+ }
351
+
305
352
  if (existsSync(join(root, 'go.mod'))) {
306
353
  const pkgs = dedupeSorted(
307
354
  files.map((f) => {
@@ -25,7 +25,7 @@ import { chmod, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs
25
25
  import { tmpdir } from 'node:os';
26
26
  import { dirname, join, resolve } from 'node:path';
27
27
 
28
- import { STATE_DIR } from '../name.mjs';
28
+ import { LOCAL_DIR } from '../state.mjs';
29
29
  import { parse as parseRegion } from '../projection/markers.mjs';
30
30
  import { materialiseArm, parseArmSpec } from './run.mjs';
31
31
 
@@ -92,7 +92,7 @@ function runNode(args, { cwd, env = {} } = {}) {
92
92
  * @returns {Promise<Array<object>>}
93
93
  */
94
94
  export async function readLedgerEntries(workDir) {
95
- const ledgerPath = join(workDir, STATE_DIR, 'ledger.jsonl');
95
+ const ledgerPath = join(workDir, LOCAL_DIR, 'ledger.jsonl');
96
96
  try {
97
97
  const text = await readFile(ledgerPath, 'utf8');
98
98
  const entries = [];
@@ -115,7 +115,7 @@ export async function readLedgerEntries(workDir) {
115
115
  * Append a ledger entry to `.simt-dev/ledger.jsonl` in `workDir`.
116
116
  */
117
117
  export async function appendLedgerEntry(workDir, entry) {
118
- const ledgerPath = join(workDir, STATE_DIR, 'ledger.jsonl');
118
+ const ledgerPath = join(workDir, LOCAL_DIR, 'ledger.jsonl');
119
119
  await mkdir(dirname(ledgerPath), { recursive: true });
120
120
  const treeRes = await runGit(['write-tree'], { cwd: workDir });
121
121
  const record = {
@@ -1148,8 +1148,7 @@ export async function gradeProbe(probeOrId, ctx) {
1148
1148
  * @param {string} root
1149
1149
  * @returns {Promise<Array<object>>}
1150
1150
  */
1151
- export async function loadProbeRecords(root) {
1152
- const probesRoot = join(resolve(root), STATE_DIR, 'bench', 'probes');
1151
+ export async function loadProbeRecords(root) { const probesRoot = join(resolve(root), LOCAL_DIR, 'bench', 'probes');
1153
1152
  if (!existsSync(probesRoot)) return [];
1154
1153
 
1155
1154
  const entries = await readdir(probesRoot, { withFileTypes: true }).catch(() => []);
@@ -1242,7 +1241,7 @@ export async function runBenchProbes(
1242
1241
  for (let rep = 1; rep <= reps; rep += 1) {
1243
1242
  const runDir = join(
1244
1243
  resolvedRoot,
1245
- STATE_DIR,
1244
+ LOCAL_DIR,
1246
1245
  'bench',
1247
1246
  'probes',
1248
1247
  `${harness}-${armSpec.name}-r${rep}`,