simt-dev 0.7.1 → 0.8.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 (186) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/README.md +169 -80
  3. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  4. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
  5. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
  6. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
  7. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
  8. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  9. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
  10. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
  11. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
  12. package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
  13. package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
  14. package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
  15. package/ecosystems/bun.json +6 -0
  16. package/ecosystems/cargo.json +25 -0
  17. package/ecosystems/composer.json +6 -0
  18. package/ecosystems/dart.json +24 -0
  19. package/ecosystems/deno.json +21 -0
  20. package/ecosystems/devcontainers.json +6 -0
  21. package/ecosystems/docker-compose.json +6 -0
  22. package/ecosystems/docker.json +6 -0
  23. package/ecosystems/dotnet.json +25 -0
  24. package/ecosystems/elixir.json +23 -0
  25. package/ecosystems/gitsubmodule.json +6 -0
  26. package/ecosystems/go.json +25 -0
  27. package/ecosystems/gradle.json +13 -0
  28. package/ecosystems/maven.json +23 -0
  29. package/ecosystems/node.json +25 -0
  30. package/ecosystems/python.json +20 -0
  31. package/ecosystems/ruby.json +23 -0
  32. package/ecosystems/swift.json +25 -0
  33. package/ecosystems/terraform.json +6 -0
  34. package/ecosystems/uv.json +6 -0
  35. package/harnesses/antigravity.json +162 -0
  36. package/harnesses/claude.json +206 -0
  37. package/harnesses/codex.json +136 -0
  38. package/harnesses/copilot.json +88 -0
  39. package/harnesses/cursor.json +140 -0
  40. package/harnesses/deepseek.json +99 -0
  41. package/harnesses/gemini.json +122 -0
  42. package/harnesses/opencode.json +173 -0
  43. package/package.json +3 -1
  44. package/rules/mdc/intake-questions.mdc +8 -0
  45. package/src/advice/advice.mjs +3 -2
  46. package/src/agenthooks/render.mjs +86 -180
  47. package/src/agents/render.mjs +15 -18
  48. package/src/api.mjs +7 -5
  49. package/src/argv.mjs +19 -0
  50. package/src/bench/ablate.mjs +3 -4
  51. package/src/bench/adapters.json +155 -113
  52. package/src/bench/adapters.mjs +466 -0
  53. package/src/bench/arms.mjs +333 -0
  54. package/src/bench/grade.mjs +12 -26
  55. package/src/bench/graders.mjs +42 -0
  56. package/src/bench/mine.mjs +17 -535
  57. package/src/bench/probe-ledger.mjs +149 -0
  58. package/src/bench/probes.catalog.mjs +918 -0
  59. package/src/bench/probes.mjs +15 -1037
  60. package/src/bench/render.mjs +111 -0
  61. package/src/bench/report.mjs +116 -971
  62. package/src/bench/run.mjs +164 -881
  63. package/src/bench/variants.mjs +4 -5
  64. package/src/bench/workspace.mjs +160 -0
  65. package/src/brief/assemble.mjs +11 -13
  66. package/src/ci/dependabot.mjs +25 -55
  67. package/src/commands/agent.mjs +1 -9
  68. package/src/commands/agenthook.mjs +66 -35
  69. package/src/commands/bench.mjs +2 -2
  70. package/src/commands/ci.mjs +11 -13
  71. package/src/commands/docs.mjs +88 -0
  72. package/src/commands/gate.mjs +10 -13
  73. package/src/commands/harness.mjs +3 -3
  74. package/src/commands/help.mjs +44 -3
  75. package/src/commands/hooks.mjs +20 -20
  76. package/src/commands/ignore.mjs +11 -7
  77. package/src/commands/impact.mjs +4 -8
  78. package/src/commands/index.mjs +49 -9
  79. package/src/commands/init-scan.mjs +90 -0
  80. package/src/commands/init.mjs +122 -104
  81. package/src/commands/lease.mjs +67 -18
  82. package/src/commands/map.mjs +10 -5
  83. package/src/commands/mcp.mjs +2 -17
  84. package/src/commands/repoindex.mjs +8 -8
  85. package/src/commands/rules.mjs +8 -4
  86. package/src/commands/shared.mjs +12 -16
  87. package/src/commands/skills.mjs +5 -6
  88. package/src/commands/skillscompile.mjs +3 -3
  89. package/src/commands/skillsinstall.mjs +5 -4
  90. package/src/commands/slice.mjs +199 -64
  91. package/src/commands/spec.mjs +78 -62
  92. package/src/commands/status.mjs +24 -12
  93. package/src/commands/sync.mjs +14 -14
  94. package/src/commands/tests.mjs +12 -12
  95. package/src/commands/tokens.mjs +19 -24
  96. package/src/commands/upgrade.mjs +8 -4
  97. package/src/commands/why.mjs +6 -13
  98. package/src/compare/hooksstate.mjs +2 -6
  99. package/src/compare/learn.mjs +3 -1
  100. package/src/decisions/index.mjs +3 -2
  101. package/src/docs/commands.mjs +65 -0
  102. package/src/ecosystem/records.mjs +219 -0
  103. package/src/explain.mjs +31 -0
  104. package/src/gates/index.mjs +385 -0
  105. package/src/graph/compile.mjs +8 -4
  106. package/src/graph/load.mjs +2 -1
  107. package/src/graph/spec.mjs +10 -8
  108. package/src/harness/records.mjs +143 -0
  109. package/src/hooks/capability.mjs +5 -3
  110. package/src/hooks/dispatcher.mjs +26 -32
  111. package/src/hooks/gates.mjs +12 -2
  112. package/src/hooks/resolve.mjs +2 -7
  113. package/src/hooks/where.mjs +2 -7
  114. package/src/identity.mjs +2 -11
  115. package/src/index/coverage.mjs +58 -0
  116. package/src/index/extract.mjs +6 -16
  117. package/src/index/gate.mjs +1 -1
  118. package/src/index/govern.mjs +26 -9
  119. package/src/index/impact.mjs +4 -8
  120. package/src/index/map.mjs +14 -15
  121. package/src/index/open.mjs +173 -0
  122. package/src/index/persist.mjs +224 -57
  123. package/src/index/store.mjs +82 -39
  124. package/src/index/tests.mjs +2 -6
  125. package/src/journal.mjs +16 -11
  126. package/src/kernel/card.mjs +37 -63
  127. package/src/kernel/checks.mjs +4 -10
  128. package/src/lease.mjs +102 -7
  129. package/src/ledger/index.mjs +124 -17
  130. package/src/mcp/paginate.mjs +33 -0
  131. package/src/mcp/schemas.mjs +512 -0
  132. package/src/mcp/server.mjs +49 -9
  133. package/src/mcp/tools-facts.mjs +326 -0
  134. package/src/mcp/tools-read.mjs +221 -0
  135. package/src/mcp/tools.mjs +4 -1005
  136. package/src/{bench → measure}/corpus.mjs +1 -1
  137. package/src/measure/metrics.mjs +236 -0
  138. package/src/measure/records.mjs +211 -0
  139. package/src/measure/stats.mjs +538 -0
  140. package/src/measure/task-schema.mjs +125 -0
  141. package/src/measure/tasks.mjs +69 -0
  142. package/src/memory/decide.mjs +6 -15
  143. package/src/memory/lesson-text.mjs +61 -0
  144. package/src/memory/lessons.mjs +29 -82
  145. package/src/name.mjs +7 -0
  146. package/src/output/index.mjs +122 -31
  147. package/src/packs/manage.mjs +15 -9
  148. package/src/plan/index.mjs +1 -1
  149. package/src/plugins.mjs +22 -16
  150. package/src/prbody.mjs +7 -11
  151. package/src/projection/loaders.mjs +7 -692
  152. package/src/projection/manifest.mjs +10 -435
  153. package/src/projection/mdc.mjs +7 -8
  154. package/src/projection/project.mjs +126 -221
  155. package/src/projection/region.mjs +561 -0
  156. package/src/projection/rules.mjs +16 -547
  157. package/src/projection/surfaces.mjs +43 -210
  158. package/src/projection/targets.mjs +5 -5
  159. package/src/repo-root.mjs +13 -1
  160. package/src/resolve/eval.mjs +2 -1
  161. package/src/resolve/fingerprint.mjs +0 -7
  162. package/src/resolve/resolve.mjs +1 -41
  163. package/src/review/critic.mjs +8 -12
  164. package/src/run.mjs +98 -0
  165. package/src/secrets.mjs +14 -22
  166. package/src/state/events.mjs +129 -0
  167. package/src/status-snapshot.mjs +233 -0
  168. package/src/status.mjs +2 -6
  169. package/src/sys/git.mjs +82 -0
  170. package/src/{bench/process.mjs → sys/proc.mjs} +28 -5
  171. package/src/testing/parsers.mjs +265 -0
  172. package/src/testing/patches.mjs +23 -0
  173. package/src/testing/paths.mjs +49 -0
  174. package/src/testing/runners.mjs +186 -0
  175. package/src/testing/vault.mjs +68 -0
  176. package/src/text/frontmatter.mjs +49 -0
  177. package/src/text/tokens.mjs +52 -0
  178. package/src/toolwords.mjs +1 -1
  179. package/src/util/fs.mjs +20 -0
  180. package/src/util/sort.mjs +12 -0
  181. package/src/workspace.mjs +3 -1
  182. package/src/write/plan.mjs +82 -0
  183. package/templates/plan/tracker.json +1 -0
  184. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
  185. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
  186. package/src/bench/tasks.mjs +0 -36
@@ -53,9 +53,16 @@
53
53
  // unverified assumption into every repo that adopted it, which is the exact
54
54
  // failure this table exists to prevent.
55
55
 
56
+ import { harnessPointers, harnessScopeKeys, harnessSurfaces } from '../harness/records.mjs';
57
+
56
58
  /**
57
59
  * Surfaces, in the order the writer emits them.
58
60
  *
61
+ * The rows are data records under `harnesses/` (Q71): a surface is a fact about a vendor,
62
+ * and a new one arrives as a new file, not a table edit (ADR-013). The engine attaches
63
+ * the one facet a record cannot carry — `ruleFrontmatter`, a function — by id below,
64
+ * and nothing else.
65
+ *
59
66
  * `kind`:
60
67
  * - `region` a generated region inside a file the repo may also write by hand
61
68
  * - `file` a wholly generated file; hand-written body text is retired
@@ -80,180 +87,35 @@
80
87
  * `ruleFrontmatter` returns the keys that editor reads BEFORE any rule text. A
81
88
  * surface that needs none returns '' and the projected file starts at the body.
82
89
  */
83
- export const SURFACES = [
84
- {
85
- // THE STANDARD SURFACE. One repo, one editor, one file — unless the agent or the
86
- // repo's owner names another. This row exists because "write a file for every IDE
87
- // we know" is not support: it gives a repo that lives in opencode three rule
88
- // directories and two symlinks it will never open, and it means every rule change
89
- // lands in four places instead of one. ADR-020 wrote all four; ADR-021 writes one
90
- // and asks.
91
- id: 'opencode',
92
- label: 'opencode (AGENTS.md + .agents/skills)',
93
- readers:
94
- 'OpenCode v2, and AGENTS.md is also read natively by Codex, Cursor, Windsurf, Zed, Amp, Jules, Copilot CLI and the DeepSeek harness; Claude Code falls back to it, Antigravity and Gemini CLI accept GEMINI.md as an equal name',
95
- primary: 'AGENTS.md',
96
- kind: 'region',
97
- // OpenCode v2 documents no per-rule directory. It reads AGENTS.md only — every
98
- // file from the workspace up to the project root, combined — and it does not fall
99
- // back to CLAUDE.md. So `foldRules`: this surface carries every rule inside the
100
- // one generated region, and the other rows are how a rule reaches an editor that
101
- // cannot read AGENTS.md. Writing .opencode/rules/*.md would be a guess, and a
102
- // guessed path is the failure this table exists to prevent.
103
- rule: null,
104
- foldRules: true,
105
- readsAgentsMd: 'all',
106
- configDirs: ['.opencode'],
107
- skills: '.agents/skills',
108
- documentedSkillsPaths: ['.opencode/skills', '.claude/skills', '.agents/skills'],
109
- // Codex concatenates AGENTS.md from the root down and stops at
110
- // project_doc_max_bytes. The default is 32 KiB. A region that ends past it is
111
- // never read, and nothing else in check notices. Other readers of this file
112
- // publish no such limit.
113
- readLimits: [
114
- {
115
- reader: 'Codex',
116
- bytes: 32768,
117
- verified: 'Codex project_doc_max_bytes defaults to 32 KiB',
118
- },
119
- ],
120
- verified:
121
- 'OpenCode v2 instructions and skills docs (opencode.ai/v2/docs, read 2026-10-14) — AGENTS.md only, combined from the workspace up to the project root; CLAUDE.md is not a fallback; skills at .opencode/skills/, .claude/skills/ and .agents/skills/<name>/SKILL.md, with the model shown name and description',
122
- note:
123
- '.agents/skills is one of the three skill locations opencode documents, so the same directory serves the standard surface without a symlink.',
124
- },
125
- {
126
- id: 'cursor',
127
- label: 'Cursor rules',
128
- readers: 'Cursor',
129
- primary: null,
130
- kind: 'file',
131
- rule: '.cursor/rules/{name}.mdc',
132
- readsAgentsMd: 'all',
133
- configDirs: ['.cursor'],
134
- // `documentedKeys` is the set the vendor's own docs name for this surface, and
135
- // `ruleFrontmatter` must emit nothing outside it. A key invented here would be
136
- // inert at best; on Antigravity an unrecognised value silently discards the
137
- // rule. The guard lives in test/shipped.test.mjs, and this row is its evidence.
138
- documentedKeys: ['description', 'alwaysApply', 'globs'],
139
- // ADR-021 ruled that "a Cursor reader getting .mdc files does not serve them",
140
- // because Cursor had no documented channel for AGENTS.md. It does, and has:
141
- // cursor.com/docs/context/rules calls AGENTS.md "a simple alternative to
142
- // .cursor/rules" and documents support in the project root and subdirectories.
143
- // So a folded rule reaches a Cursor reader twice - once inside the region, once
144
- // in the .mdc - and the second copy is 28 KB of pure repetition across the five
145
- // repos that select both surfaces.
146
- //
147
- // The caveat belongs here rather than in the ADR body, because it is a fact about
148
- // a product and products move: a Cursor bug report records AGENTS.md NOT being
149
- // read in worktree agents by default, fixed in 3.9. A reader below that version
150
- // gets the .mdc copy and a reader above it does not need it.
151
- readsPrimary: true,
152
- // Filled into a substitution only when every selected surface names the same
153
- // value. One body is written for all of them, so a Cursor name must not land
154
- // in a file Claude Code also reads.
155
- substitutions: { askTool: 'Use the interactive `AskQuestion` tool' },
156
- ruleFrontmatter: (rule) => [
157
- `description: ${rule.description ?? `Canonical ${rule.id} rule managed by simt-dev`}`,
158
- // `globs` only appears when the rule declares a scope. Cursor attaches a rule
159
- // with no globs on the strength of `alwaysApply` alone, so emitting either
160
- // unquoted patterns on an eager rule or no globs at all on a scoped one is
161
- // asking for the wrong loading behaviour.
162
- ...(rule.scope ? [`globs: ${rule.scope.map((g) => `"${g}"`).join(', ')}`] : []),
163
- `alwaysApply: ${rule.alwaysApply ? 'true' : 'false'}`,
164
- ],
165
- verified:
166
- 'Cursor project rules (.cursor/rules/*.mdc, description + alwaysApply + globs); alwaysApply: false with globs is "auto-attached when a matching file is in context", and the docs warn that alwaysApply: false with no description is manual @-mention only',
167
- note: 'The only surface that reads `.mdc`. Every other row here is a plain `.md` or a symlink.',
168
- },
169
- {
170
- id: 'claude',
171
- label: 'Claude Code rules',
172
- readers: 'Claude Code',
173
- primary: null,
174
- kind: 'file',
175
- rule: '.claude/rules/{name}.md',
176
- // `some`, not `none`. It reads AGENTS.md - the same docs say CLAUDE.md is used only
177
- // when no AGENTS.md exists - but only on that condition, so a repository that has a
178
- // hand-written CLAUDE.md is read by neither. That is why this row still needs its
179
- // own channel and a pointer: the fallback is not a channel, it is the absence of
180
- // one, and a reader who relied on it would be relying on the reader deleting a
181
- // file they wrote.
182
- readsAgentsMd: 'some',
183
- readsAgentsMdNote:
184
- 'CLAUDE.md is read first; AGENTS.md is read only when there is no CLAUDE.md, which is why the pointer exists',
185
- configDirs: ['.claude'],
186
- // Intentionally no `paths:`. See decision 3 in the header.
187
- documentedKeys: ['paths', 'description'],
188
- ruleFrontmatter: () => [],
189
- // The claude-md pointer makes CLAUDE.md a symlink to AGENTS.md (ADR-021 ruling 1),
190
- // and Claude reads CLAUDE.md in preference to AGENTS.md. So the folded region
191
- // arrives through the pointer and a rule file alongside it is the same text twice.
192
- readsPrimary: true,
193
- substitutions: { askTool: 'Use the interactive `AskUserQuestion` tool' },
194
- verified: 'Claude Code, "Organize rules with .claude/rules/" — recursive discovery, symlinks supported, no `paths` loads at session start',
195
- note:
196
- 'CLAUDE.md is a separate pointer surface (see POINTERS). A rule file without frontmatter loads eagerly, which is what an always-on rule wants.',
197
- },
198
- {
199
- id: 'antigravity',
200
- label: 'Antigravity workspace rules',
201
- readers: 'Antigravity IDE, Antigravity CLI',
202
- primary: null,
203
- kind: 'file',
204
- rule: '.agents/rules/{name}.md',
205
- readsAgentsMd: 'all',
206
- configDirs: ['.agents'],
207
- // `trigger` is REQUIRED here and an unrecognised value discards the rule with no
208
- // message, which is why the value is mapped rather than passed through.
209
- documentedKeys: ['trigger', 'description', 'globs', 'glob'],
210
- // The gemini-md pointer's own `verified` cites the same Antigravity docs: "workspace
211
- // rules are AGENTS.md or GEMINI.md in the root or any subdirectory". Either name
212
- // reaches the reader, so a .agents/rules file repeats text the region already
213
- // carries. Both channels are documented as alternatives to each other, which is
214
- // what makes them duplicates rather than a fallback.
215
- readsPrimary: true,
216
- ruleFrontmatter: (rule) => [
217
- // `trigger: glob` carries the patterns, and they are quoted because YAML reads a
218
- // value starting with `*` as an alias anchor — the Antigravity docs require it, and
219
- // an invalid frontmatter block is discarded with no message at all.
220
- `trigger: ${rule.scope ? 'glob' : rule.alwaysApply ? 'always_on' : 'model_decision'}`,
221
- ...(rule.scope ? [`globs: ${rule.scope.map((g) => `"${g}"`).join(', ')}`] : []),
222
- `description: ${rule.description ?? `Canonical ${rule.id} rule managed by simt-dev`}`,
223
- ],
224
- verified:
225
- 'Google Antigravity docs, "Rules" — .agents/rules/*.md, YAML frontmatter with trigger: always_on | model_decision | glob | manual; missing or invalid frontmatter silently discards the rule; trigger: glob takes comma-separated globs, which the docs require to be quoted so YAML does not read a leading * as an alias anchor; a 24 KB per-file limit and a 20,000-token budget for always_on rules, with automatic demotion to pointers past it',
226
- note:
227
- 'This is why frontmatter is mandatory here and why the trigger value is not guessed: an unrecognised trigger means the rule is dropped with no message.',
228
- },
229
- {
230
- id: 'copilot',
231
- label: 'GitHub Copilot instructions',
232
- readers: 'GitHub Copilot chat, Copilot CLI, Copilot code review, Copilot cloud agent',
233
- primary: null,
234
- kind: 'file',
235
- rule: '.github/instructions/{name}.instructions.md',
236
- // Copilot is why the column is three values and not two. Its CLI reads AGENTS.md
237
- // and its editor and code review do not, so the row both needs its own channel and
238
- // cannot be described as "reads AGENTS.md" without a reader reading that claim as
239
- // "this repo is covered for Copilot", which for two of its three readers it is not.
240
- readsAgentsMd: 'some',
241
- readsAgentsMdNote:
242
- 'Copilot CLI reads AGENTS.md; the editor, code review and the cloud agent do not',
243
- configDirs: ['.github'],
244
- documentedKeys: ['applyTo', 'excludeAgent'],
245
- ruleFrontmatter: (rule) => [`applyTo: ${rule.scope ? `"${rule.scope.join(',')}"` : '"**"'}`],
246
- verified:
247
- 'GitHub docs, "Adding repository custom instructions" — .github/instructions/NAME.instructions.md with applyTo globs; `applyTo: "**"` is the documented example, and a comma-separated pattern list is how more than one is named',
248
- // NOT marked readsPrimary, and this row is the reason the flag is per-surface
249
- // rather than global. The reader is split: Copilot CLI reads AGENTS.md, the
250
- // editor and code review do not. A per-rule file here is not duplication for two
251
- // of the three, and dropping it would remove the process rules from a chat that
252
- // had no other channel to them.
253
- note:
254
- 'Copilot reads AGENTS.md itself through Copilot CLI, but the editor and code review do not; this is the channel they use.',
255
- },
256
- ];
90
+ // `ruleFrontmatter` is the one facet a record cannot carry: it is a function, and JSON
91
+ // carries data. Attached by id, so a surface that needs none (opencode) keeps the row
92
+ // exactly as the record wrote it.
93
+ const RULE_FRONTMATTER = {
94
+ cursor: (rule) => [
95
+ `description: ${rule.description ?? `Canonical ${rule.id} rule managed by simt-dev`}`,
96
+ // `globs` only appears when the rule declares a scope. Cursor attaches a rule
97
+ // with no globs on the strength of `alwaysApply` alone, so emitting either
98
+ // unquoted patterns on an eager rule or no globs at all on a scoped one is
99
+ // asking for the wrong loading behaviour.
100
+ ...(rule.scope ? [`globs: ${rule.scope.map((g) => `"${g}"`).join(', ')}`] : []),
101
+ `alwaysApply: ${rule.alwaysApply ? 'true' : 'false'}`,
102
+ ],
103
+ // Intentionally no `paths:`. See decision 3 in the header.
104
+ claude: () => [],
105
+ antigravity: (rule) => [
106
+ // `trigger: glob` carries the patterns, and they are quoted because YAML reads a
107
+ // value starting with `*` as an alias anchor — the Antigravity docs require it, and
108
+ // an invalid frontmatter block is discarded with no message at all.
109
+ `trigger: ${rule.scope ? 'glob' : rule.alwaysApply ? 'always_on' : 'model_decision'}`,
110
+ ...(rule.scope ? [`globs: ${rule.scope.map((g) => `"${g}"`).join(', ')}`] : []),
111
+ `description: ${rule.description ?? `Canonical ${rule.id} rule managed by simt-dev`}`,
112
+ ],
113
+ copilot: (rule) => [`applyTo: ${rule.scope ? `"${rule.scope.join(',')}"` : '"**"'}`],
114
+ };
115
+
116
+ export const SURFACES = harnessSurfaces().map((row) =>
117
+ RULE_FRONTMATTER[row.id] ? { ...row, ruleFrontmatter: RULE_FRONTMATTER[row.id] } : row,
118
+ );
257
119
 
258
120
  /**
259
121
  * Pointers: files that must exist and must carry no content.
@@ -263,37 +125,7 @@ export const SURFACES = [
263
125
  * that fails loudly, whereas a stale copy is a file full of confidently wrong
264
126
  * rules that nobody notices for a release.
265
127
  */
266
- export const POINTERS = [
267
- {
268
- id: 'claude-md',
269
- // A pointer belongs to the surface that needs it, never to every repo.
270
- // ADR-020 wrote both of these unconditionally, which meant that narrowing to
271
- // one surface under ADR-021 still created a CLAUDE.md nobody asked for — the
272
- // same over-writing that ruling set out to stop, one file down.
273
- surface: 'claude',
274
- path: 'CLAUDE.md',
275
- to: 'AGENTS.md',
276
- readers: 'Claude Code',
277
- verified:
278
- 'Claude Code memory docs: "A symlink also works" — `ln -s AGENTS.md CLAUDE.md`; Claude reads CLAUDE.md by default and AGENTS.md only as a fallback',
279
- note:
280
- 'Claude Code reads CLAUDE.md and ignores AGENTS.md when both exist. A pointer is what makes one file answer for both without a second copy.',
281
- },
282
- {
283
- id: 'gemini-md',
284
- surface: 'antigravity',
285
- path: 'GEMINI.md',
286
- to: 'AGENTS.md',
287
- readers: 'Gemini CLI, Antigravity',
288
- verified:
289
- 'Google Antigravity docs, "Rules": workspace rules are AGENTS.md or GEMINI.md in the root or any subdirectory; Copilot CLI also discovers GEMINI.md. ' +
290
- 'Gemini CLI, read today: its own docs (core memory index, and the configuration reference) say the memory service loads GEMINI.md hierarchically from the working directory up to the project root and the home directory and also into subdirectories, and that `context.fileName` in settings.json "accepts either a single string or an array of strings" naming the context file(s) to load, default undefined',
291
- note:
292
- 'Covers Gemini CLI and gives Antigravity a second, equally-native name for the same file. ' +
293
- 'A repository can also point Gemini CLI at AGENTS.md itself with `context.fileName: ["AGENTS.md"]` and need no pointer at all, ' +
294
- 'which is the reason there is no separate Gemini CLI row here: this pointer and a native row would be two ways to hand one reader the same rule.',
295
- },
296
- ];
128
+ export const POINTERS = harnessPointers();
297
129
 
298
130
  /**
299
131
  * The global standard: what a repo gets when it has not chosen.
@@ -362,7 +194,7 @@ export function canScope(surface) {
362
194
  }
363
195
 
364
196
  /** Surface ids whose scoping key was read in the vendor's own documentation. */
365
- const SCOPE_KEYS = new Set(['cursor', 'antigravity', 'copilot']);
197
+ const SCOPE_KEYS = harnessScopeKeys();
366
198
 
367
199
  /**
368
200
  * Surfaces whose reader already loads the primary document, so a per-rule file for
@@ -488,10 +320,11 @@ export function surface(id) {
488
320
 
489
321
  /** The absolute-ish path a rule takes on a surface, given the rule's bare name. */
490
322
  /** Assemble YAML frontmatter lines into a block. Lives here because this is the
491
- * lowest module that owns frontmatter: surfaces.mjs imports nothing, so both
492
- * the `.mdc` renderer and the per-surface projector can share one implementation
493
- * without either importing the other. Two copies of these four lines is the
494
- * "third copy of the same logic is a defect" case with a third-copy count of two. */
323
+ * lowest module that owns frontmatter: surfaces.mjs imports neither of its consumers
324
+ * (its only import is the data records), so both the `.mdc` renderer and the
325
+ * per-surface projector can share one implementation without either importing the
326
+ * other. Two copies of these four lines is the "third copy of the same logic is a
327
+ * defect" case with a third-copy count of two. */
495
328
  export function frontmatterBlock(lines) {
496
329
  if (lines.length === 0) return '';
497
330
  return ['---', ...lines, '---'].join('\n');
@@ -19,6 +19,7 @@ import { diff, parse, inject } from './markers.mjs';
19
19
  import { ruleName } from './mdc.mjs';
20
20
  import { foldsRules, frontmatterBlock, POINTERS, rulePath, SURFACES } from './surfaces.mjs';
21
21
  import { foldChecks } from '../kernel/checks.mjs';
22
+ import { frontmatterBlock as extractBlock, stripFrontmatter } from '../text/frontmatter.mjs';
22
23
 
23
24
  /**
24
25
  * A managed rule file is a GENERATED FILE, unlike AGENTS.md.
@@ -276,8 +277,7 @@ export async function pointerState(root, pointer) {
276
277
  export { rulePath };
277
278
 
278
279
  export function extractFrontmatter(text) {
279
- const m = /^---\n[\s\S]*?\n---\n?/.exec(text);
280
- return m ? m[0].trimEnd() : null;
280
+ return extractBlock(text);
281
281
  }
282
282
 
283
283
  /**
@@ -296,10 +296,10 @@ export function extractFrontmatter(text) {
296
296
  */
297
297
  function swapFrontmatter(text, frontmatter) {
298
298
  if (frontmatter === '') return text;
299
- const m = /^---\n[\s\S]*?\n---/.exec(text);
300
- return m === null ? `${frontmatter}\n${text}` : `${frontmatter}${text.slice(m[0].length)}`;
299
+ const block = extractBlock(text);
300
+ return block === null ? `${frontmatter}\n${text}` : `${frontmatter}${text.slice(block.length)}`;
301
301
  }
302
302
 
303
303
  function bodyAfterFrontmatter(text) {
304
- return text.replace(/^---\n[\s\S]*?\n---\n*/, '');
304
+ return stripFrontmatter(text);
305
305
  }
package/src/repo-root.mjs CHANGED
@@ -65,8 +65,20 @@ export function repoRoot(cwd) {
65
65
  * typed. `repoRoot` answers the different question of which repository that directory
66
66
  * belongs to.
67
67
  */
68
+ /**
69
+ * The root a caller named, or undefined when they named none.
70
+ *
71
+ * Absent is not the current directory here. `upgrade` walks a workspace and only limits
72
+ * itself to one project when the caller said which; collapsing "no root" into cwd made a
73
+ * bare `simt upgrade` plan a single project instead of the workspace, which is the defect
74
+ * the two-step resolution exists to prevent.
75
+ */
76
+ export function namedRoot(flags = {}, positional = []) {
77
+ return flags.root ?? positional[0];
78
+ }
79
+
68
80
  export function commandRoot(flags = {}, positional = []) {
69
- return flags.root ?? positional[0] ?? process.cwd();
81
+ return namedRoot(flags, positional) ?? process.cwd();
70
82
  }
71
83
 
72
84
  /**
@@ -6,7 +6,8 @@
6
6
  // disagree about the score - `simt skills eval` reports it to a reader, and
7
7
  // test/eval.test.mjs asserts it in CI - so the arithmetic lives here once (ADR-002).
8
8
 
9
- import { rankSkills, tokenize } from './resolve.mjs';
9
+ import { rankSkills } from './resolve.mjs';
10
+ import { tokenize } from '../text/tokens.mjs';
10
11
 
11
12
  /** The skill names the resolver selects for one case's declared signals and task. */
12
13
  function selectForCase(registry, c) {
@@ -313,12 +313,6 @@ export async function fingerprint(root) {
313
313
  }
314
314
  }
315
315
 
316
- // two repositories both carry a wear/ module; only one of them is the wearable.
317
- if (names.has('wear') && (await isDir(join(root, 'wear')))) {
318
- stack.add('wear-os');
319
- evidence.push('wear/');
320
- }
321
-
322
316
  // Either spelling: a repository that adopted this tool has one of the two on disk,
323
317
  // and a fingerprint that missed the manifest would silently lose the repo's own
324
318
  // declared stack - which is the one input no amount of marker reading can replace.
@@ -547,7 +541,6 @@ export const STACK_TAGS = new Set([
547
541
  ...MARKERS.map(([tag]) => tag),
548
542
  ...EXTENSION_TAGS.values(),
549
543
  ...DEP_TAGS.values(),
550
- 'wear-os', // derived from a wear/ module, not a marker file
551
544
  ]);
552
545
 
553
546
  /** Every fact `fingerprint()` can emit, derived for the same reason as above. */
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { matchesGlob as pathMatchesGlob } from 'node:path';
12
12
  import { STACK_TAGS } from './fingerprint.mjs';
13
+ import { STOPWORDS, tokenize } from '../text/tokens.mjs';
13
14
 
14
15
  /** Signal weights. Deliberately few, so the ranking is explainable. */
15
16
  const W = {
@@ -30,47 +31,6 @@ const W = {
30
31
  */
31
32
  export const MIN_SCORE = W.capability;
32
33
 
33
- /**
34
- * English function words, which appear in every summary this registry has and identify
35
- * none of them.
36
- *
37
- * Applied to the summary signal ONLY. A keyword is an explicit declaration and a name is
38
- * an identity, so suppressing those would hide something a person wrote on purpose;
39
- * a summary is prose, and prose is full of words that are there for grammar. Measured:
40
- * the task "fix a typo in the README" selected four skills on `summary:the` and
41
- * `summary:in` alone. Hand-authored rather than derived, for the same reason the
42
- * capability aliases are: a resolver nobody can review is not a resolver.
43
- */
44
- const STOPWORDS = new Set([
45
- 'about', 'after', 'all', 'also', 'and', 'an', 'any', 'are', 'as', 'at', 'be', 'because',
46
- 'been', 'before', 'being', 'both', 'but', 'by', 'can', 'could', 'did', 'do', 'does', 'each',
47
- 'for', 'from', 'had', 'has', 'have', 'he', 'her', 'here', 'his', 'how', 'if', 'in', 'into',
48
- 'is', 'it', 'its', 'just', 'like', 'make', 'many', 'may', 'me', 'more', 'most', 'must', 'my',
49
- 'no', 'not', 'now', 'of', 'off', 'on', 'one', 'only', 'onto', 'or', 'other', 'our', 'out',
50
- 'over', 'own', 'per', 'same', 'see', 'set', 'she', 'should', 'so', 'some', 'such', 'than',
51
- 'that', 'the', 'their', 'them', 'then', 'there', 'these', 'they', 'this', 'those', 'to', 'too',
52
- 'two', 'under', 'up', 'us', 'use', 'used', 'using', 'very', 'was', 'way', 'we', 'were', 'what',
53
- 'when', 'where', 'which', 'while', 'who', 'why', 'will', 'with', 'within', 'without', 'would',
54
- 'you', 'your',
55
- ]);
56
-
57
- /**
58
- * Split a task string into comparable tokens.
59
- *
60
- * Length 2 is kept. The floor used to be 3, and it dropped the most specific words in
61
- * this domain: `d1`, `r2`, `kv`, `ci` are two-letter vocabulary, not noise, and a task
62
- * saying "fix d1 migration" lost the one word that identified the change. The cost is
63
- * that six stopwords of two letters (`in on of or an so`) now reach the summary signal,
64
- * which is S04's finding and not something this change can resolve on its own - the
65
- * remedy there is a stopword list, not a longer minimum.
66
- */
67
- export function tokenize(text) {
68
- return String(text ?? '')
69
- .toLowerCase()
70
- .split(/[^a-z0-9]+/)
71
- .filter((t) => t.length > 1);
72
- }
73
-
74
34
  /**
75
35
  * Whether `phrase` occurs in `tokens` as consecutive words.
76
36
  *
@@ -16,21 +16,18 @@
16
16
  // earned, the same honesty ADR-076 asked of R38's metric note.
17
17
 
18
18
  import { readFile, mkdir, writeFile } from 'node:fs/promises';
19
- import { execFile } from 'node:child_process';
20
19
  import { join, resolve } from 'node:path';
21
- import { promisify } from 'node:util';
22
20
 
23
21
  import { EXIT, fail } from '../output/index.mjs';
24
22
  import { CLI } from '../name.mjs';
25
23
  import { readLocalConfig, chatEndpoint } from '../local/config.mjs';
26
24
  import { localChat } from '../local/client.mjs';
27
25
  import { decisionRecords, loadCorpus, tracked } from '../commands/why.mjs';
28
- import { decisionHits, scopeHits, sortHits, sourceRoots } from '../index/govern.mjs';
26
+ import { governs, sourceRoots } from '../index/govern.mjs';
29
27
  // `digestOf`, `promptHash` and `proposalFrontmatter` landed with R38's engine and
30
28
  // the critic is their second real user; sharing them beats a third copy (ADR-002).
31
29
  import { digestOf, promptHash, proposalFrontmatter } from '../distill/engine.mjs';
32
-
33
- const exec = promisify(execFile);
30
+ import { git } from '../sys/git.mjs';
34
31
 
35
32
  import { LOCAL_DIR } from '../state.mjs';
36
33
  import { commandRoot } from '../repo-root.mjs';
@@ -119,8 +116,7 @@ async function governingItems(root, files, commands = []) {
119
116
  const ctx = { files: fileSet, commands: new Set(commands), roots: sourceRoots(files) };
120
117
  const items = new Map();
121
118
  for (const file of files) {
122
- const { hits } = decisionHits(records, file, ctx);
123
- for (const hit of sortHits([...hits, ...scopeHits(file, corpus)])) {
119
+ for (const hit of governs(file, { records, ...corpus, ctx })) {
124
120
  if (!items.has(hit.id)) items.set(hit.id, hit);
125
121
  }
126
122
  }
@@ -225,11 +221,11 @@ export async function reviewTarget(args, flags, { write = true, commands = [] }
225
221
  let names;
226
222
  try {
227
223
  const [diff, changed] = await Promise.all([
228
- exec('git', ['diff', '--staged'], { cwd: root, maxBuffer: 64 << 20 }),
229
- exec('git', ['diff', '--staged', '--name-only'], { cwd: root, maxBuffer: 8 << 20 }),
224
+ git(root, ['diff', '--staged'], { maxBuffer: 64 << 20 }),
225
+ git(root, ['diff', '--staged', '--name-only'], { maxBuffer: 8 << 20 }),
230
226
  ]);
231
- patch = diff.stdout;
232
- names = changed.stdout.split('\n').filter(Boolean);
227
+ patch = diff;
228
+ names = changed.split('\n').filter(Boolean);
233
229
  } catch (error) {
234
230
  return fail(
235
231
  `could not read the staged diff here (${error.message})`,
@@ -443,7 +439,7 @@ export async function criticBench(root, { labelsPath, flags, commands = [] }) {
443
439
  async function peer(root, sha, flags, endpoint, commands) {
444
440
  let diff;
445
441
  try {
446
- diff = (await exec('git', ['show', '--unified=2', sha], { cwd: root, maxBuffer: 64 << 20 })).stdout;
442
+ diff = await git(root, ['show', '--unified=2', sha], { maxBuffer: 64 << 20 });
447
443
  } catch (error) {
448
444
  return { failed: true, exitMessage: error.message };
449
445
  }
package/src/run.mjs ADDED
@@ -0,0 +1,98 @@
1
+ // `run(command, input)` - one command, as a result (review 0003 Q63).
2
+ //
3
+ // The CLI prints and returns an exit code; that is right for a terminal and useless for a
4
+ // library, an MCP tool or a harness that wants the answer. Rather than rewrite seventy
5
+ // command modules to return values they do not currently compute, this runs the same
6
+ // handler through the output sink: the handler is unchanged, and what it would have
7
+ // written comes back as data.
8
+ //
9
+ // That is a boundary, not a second implementation. `dispatch` and `run` resolve the same
10
+ // registry, apply the same flag guard rails and call the same function, so a command
11
+ // cannot work in one and not the other - the failure would be invisible in the CLI, which
12
+ // is why `test/run.test.mjs` drives both surfaces.
13
+ //
14
+ // What a caller gets is not a rendering decision made for them: `text` and `documents` are
15
+ // both there when the command produced both, and `renderText`/`renderJson` are the two
16
+ // answers the CLI had. A caller that wants neither reads the fields a command exported
17
+ // (`src/api.mjs`).
18
+
19
+ import { EXIT, clearWarnings, jsonCommand, jsonFailMode, jsonIndentDefault, maxBytesLimit, warnings, withOutputState, withSink } from './output/index.mjs';
20
+ import { CLI } from './name.mjs';
21
+ import { GLOBAL_FLAGS, resolveCommand } from './commands/index.mjs';
22
+
23
+ /** `--some-flag` for a flag key the parser handed back as `someFlag`. */
24
+ function spelled(key) {
25
+ return key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
26
+ }
27
+
28
+ function failure(command, code, message, remedy) {
29
+ return {
30
+ command,
31
+ ok: false,
32
+ code,
33
+ text: '',
34
+ documents: [],
35
+ notes: [],
36
+ warnings: [],
37
+ error: { message, remedy: remedy ?? `run: ${CLI} --help`, exitCode: code },
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Run one command path and answer with what it produced.
43
+ *
44
+ * @param {string} command the dispatch key: `status`, `plan next`, `hooks install`
45
+ * @param {{args?: string[], flags?: object, root?: string}} [input]
46
+ */
47
+ export async function run(command, { args = [], flags = {}, root } = {}) {
48
+ const resolved = await resolveCommand(command);
49
+ if (resolved === null) return failure(command, EXIT.USAGE, `unknown command \`${command}\``);
50
+
51
+ // The guard rails the dispatcher applies, applied here for the same reason: a library
52
+ // caller that passes `--frozen` to `status` must be told, not quietly served a default.
53
+ for (const key of Object.keys(flags)) {
54
+ if (GLOBAL_FLAGS.has(key) || resolved.declared.has(spelled(key))) continue;
55
+ return failure(command, EXIT.USAGE, `--${spelled(key)} is not a flag of \`${command}\``);
56
+ }
57
+ for (const [flag, allowed] of Object.entries(resolved.values)) {
58
+ const value = flags[flag.replace(/-([a-z])/g, (_, c) => c.toUpperCase())];
59
+ if (value !== undefined && !allowed.includes(value)) {
60
+ return failure(command, EXIT.USAGE, `--${flag} takes ${allowed.join(', ')}; received ${value}`);
61
+ }
62
+ }
63
+
64
+ clearWarnings();
65
+ jsonCommand(command);
66
+ jsonIndentDefault(flags.pretty === true);
67
+ maxBytesLimit(flags.maxBytes);
68
+ jsonFailMode(flags.json === true);
69
+
70
+ const effective = root === undefined ? flags : { root, ...flags };
71
+ const produced = await withOutputState(() => withSink(() => resolved.run(args, effective)));
72
+ return {
73
+ command,
74
+ ok: produced.code === EXIT.OK,
75
+ code: produced.code,
76
+ text: produced.text,
77
+ documents: produced.documents,
78
+ notes: produced.notes,
79
+ warnings: warnings(),
80
+ error: produced.error,
81
+ };
82
+ }
83
+
84
+ /** The text the command printed, as one string. Empty when it answered in JSON. */
85
+ export function renderText(result) {
86
+ return result.text;
87
+ }
88
+
89
+ /**
90
+ * The document the command emitted, or null when it answered as text.
91
+ *
92
+ * A command that emits a stream (NDJSON) has more than one document and gets an array:
93
+ * collapsing a stream to its first element is how a caller silently loses rows.
94
+ */
95
+ export function renderJson(result) {
96
+ if (result.documents.length === 0) return null;
97
+ return result.documents.length === 1 ? result.documents[0] : result.documents;
98
+ }
package/src/secrets.mjs CHANGED
@@ -23,10 +23,7 @@
23
23
  // is documentation, and a gate that blocks it teaches the exact `--no-verify`
24
24
  // habit the dispatcher's fail-closed rule exists to prevent.
25
25
 
26
- import { execFile } from 'node:child_process';
27
- import { promisify } from 'node:util';
28
-
29
- const exec = promisify(execFile);
26
+ import { git } from './sys/git.mjs';
30
27
 
31
28
  /** The length at which a token is worth measuring, and the entropy it must reach. */
32
29
  const ENTROPY_MIN_LENGTH = 32;
@@ -294,24 +291,19 @@ export async function scanStaged(root, options = {}) {
294
291
  // program that prints nothing, `-diff` in `.gitattributes` and genuinely binary
295
292
  // files make git print no text at all, a textconv rewrites the bytes on the way
296
293
  // out, `diff.mnemonicPrefix` renames the header prefixes the attribution below
297
- // reads, and C-quoting mangles a non-ASCII name. The scan asks git for the staged
298
- // bytes with its own flags and ignores what the repository asked for.
299
- ({ stdout } = await exec(
300
- 'git',
301
- [
302
- '-c', 'core.quotePath=false',
303
- 'diff',
304
- '--cached',
305
- '--no-ext-diff',
306
- '--no-textconv',
307
- '--text',
308
- '--unified=0',
309
- '--no-color',
310
- '--src-prefix=a/',
311
- '--dst-prefix=b/',
312
- ],
313
- { cwd: root, maxBuffer: 32 * 1024 * 1024 },
314
- ));
294
+ // reads. The scan asks git for the staged bytes with its own flags and ignores
295
+ // what the repository asked for.
296
+ stdout = await git(root, [
297
+ 'diff',
298
+ '--cached',
299
+ '--no-ext-diff',
300
+ '--no-textconv',
301
+ '--text',
302
+ '--unified=0',
303
+ '--no-color',
304
+ '--src-prefix=a/',
305
+ '--dst-prefix=b/',
306
+ ]);
315
307
  } catch (error) {
316
308
  const stderr = String(error.stderr ?? '').trim();
317
309
  const reason = (stderr || String(error.message ?? error)).split('\n')[0];