simt-dev 0.7.0 → 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 (235) hide show
  1. package/CHANGELOG.md +191 -870
  2. package/README.md +188 -213
  3. package/bin/simt.mjs +19 -55
  4. package/docs/decisions/ADR-014-workspace-is-configuration.md +2 -2
  5. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
  6. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
  7. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
  8. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
  9. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  10. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
  11. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
  12. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
  13. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  14. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  15. package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
  16. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  17. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  18. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  19. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  20. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  21. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  22. package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
  23. package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
  24. package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
  25. package/ecosystems/bun.json +6 -0
  26. package/ecosystems/cargo.json +25 -0
  27. package/ecosystems/composer.json +6 -0
  28. package/ecosystems/dart.json +24 -0
  29. package/ecosystems/deno.json +21 -0
  30. package/ecosystems/devcontainers.json +6 -0
  31. package/ecosystems/docker-compose.json +6 -0
  32. package/ecosystems/docker.json +6 -0
  33. package/ecosystems/dotnet.json +25 -0
  34. package/ecosystems/elixir.json +23 -0
  35. package/ecosystems/gitsubmodule.json +6 -0
  36. package/ecosystems/go.json +25 -0
  37. package/ecosystems/gradle.json +13 -0
  38. package/ecosystems/maven.json +23 -0
  39. package/ecosystems/node.json +25 -0
  40. package/ecosystems/python.json +20 -0
  41. package/ecosystems/ruby.json +23 -0
  42. package/ecosystems/swift.json +25 -0
  43. package/ecosystems/terraform.json +6 -0
  44. package/ecosystems/uv.json +6 -0
  45. package/harnesses/antigravity.json +162 -0
  46. package/harnesses/claude.json +206 -0
  47. package/harnesses/codex.json +136 -0
  48. package/harnesses/copilot.json +88 -0
  49. package/harnesses/cursor.json +140 -0
  50. package/harnesses/deepseek.json +99 -0
  51. package/harnesses/gemini.json +122 -0
  52. package/harnesses/opencode.json +173 -0
  53. package/package.json +12 -2
  54. package/rules/core.md +7 -0
  55. package/rules/mdc/intake-questions.mdc +8 -0
  56. package/rules/mdc/slice-commit.mdc +57 -4
  57. package/src/advice/advice.mjs +3 -2
  58. package/src/agenthooks/prompt.mjs +5 -2
  59. package/src/agenthooks/render.mjs +86 -180
  60. package/src/agenthooks/shim.mjs +1 -1
  61. package/src/agents/render.mjs +15 -18
  62. package/src/api.mjs +31 -0
  63. package/src/argv.mjs +48 -3
  64. package/src/bench/ablate.mjs +3 -4
  65. package/src/bench/adapters.json +163 -105
  66. package/src/bench/adapters.mjs +466 -0
  67. package/src/bench/arms.mjs +333 -0
  68. package/src/bench/grade.mjs +33 -73
  69. package/src/bench/graders.mjs +42 -0
  70. package/src/bench/mine.mjs +121 -475
  71. package/src/bench/probe-ledger.mjs +149 -0
  72. package/src/bench/probes.catalog.mjs +918 -0
  73. package/src/bench/probes.mjs +79 -1064
  74. package/src/bench/render.mjs +111 -0
  75. package/src/bench/report.mjs +120 -962
  76. package/src/bench/run.mjs +185 -802
  77. package/src/bench/variants.mjs +4 -5
  78. package/src/bench/workspace.mjs +160 -0
  79. package/src/brief/assemble.mjs +48 -15
  80. package/src/ci/dependabot.mjs +25 -55
  81. package/src/ci/workflow.mjs +30 -7
  82. package/src/cli.mjs +45 -0
  83. package/src/commands/agent.mjs +4 -13
  84. package/src/commands/agenthook.mjs +70 -40
  85. package/src/commands/bench.mjs +191 -18
  86. package/src/commands/brief.mjs +5 -4
  87. package/src/commands/ci.mjs +13 -14
  88. package/src/commands/compare.mjs +42 -42
  89. package/src/commands/completions.mjs +2 -6
  90. package/src/commands/decide.mjs +4 -4
  91. package/src/commands/distill.mjs +4 -3
  92. package/src/commands/docs.mjs +88 -0
  93. package/src/commands/doctor.mjs +11 -7
  94. package/src/commands/evidence.mjs +6 -9
  95. package/src/commands/explain.mjs +0 -1
  96. package/src/commands/gate.mjs +26 -25
  97. package/src/commands/graph.mjs +2 -3
  98. package/src/commands/harness.mjs +6 -5
  99. package/src/commands/help.mjs +168 -19
  100. package/src/commands/hooks.mjs +119 -49
  101. package/src/commands/ignore.mjs +13 -8
  102. package/src/commands/impact.mjs +8 -15
  103. package/src/commands/index.mjs +318 -193
  104. package/src/commands/init-scan.mjs +90 -0
  105. package/src/commands/init.mjs +124 -105
  106. package/src/commands/lease.mjs +73 -25
  107. package/src/commands/lessons.mjs +10 -9
  108. package/src/commands/local.mjs +3 -3
  109. package/src/commands/map.mjs +24 -14
  110. package/src/commands/mcp.mjs +2 -18
  111. package/src/commands/pack.mjs +9 -6
  112. package/src/commands/plan.mjs +6 -5
  113. package/src/commands/plugin.mjs +2 -2
  114. package/src/commands/prbody.mjs +3 -3
  115. package/src/commands/repoindex.mjs +12 -11
  116. package/src/commands/review.mjs +2 -2
  117. package/src/commands/rules.mjs +37 -16
  118. package/src/commands/shared.mjs +14 -19
  119. package/src/commands/skills.mjs +33 -32
  120. package/src/commands/skillscompile.mjs +7 -8
  121. package/src/commands/skillsinstall.mjs +7 -6
  122. package/src/commands/slice.mjs +220 -74
  123. package/src/commands/spec.mjs +167 -0
  124. package/src/commands/status.mjs +27 -18
  125. package/src/commands/sync.mjs +27 -25
  126. package/src/commands/tests.mjs +18 -21
  127. package/src/commands/tokens.mjs +21 -26
  128. package/src/commands/undo.mjs +5 -15
  129. package/src/commands/upgrade.mjs +15 -7
  130. package/src/commands/why.mjs +15 -21
  131. package/src/compare/hooksstate.mjs +2 -6
  132. package/src/compare/learn.mjs +3 -1
  133. package/src/compare/row.mjs +0 -2
  134. package/src/context.mjs +64 -0
  135. package/src/decisions/index-doc.mjs +46 -0
  136. package/src/decisions/index.mjs +3 -2
  137. package/src/diff.mjs +1 -1
  138. package/src/distill/distill.mjs +14 -1
  139. package/src/distill/engine.mjs +5 -1
  140. package/src/docs/commands.mjs +65 -0
  141. package/src/ecosystem/records.mjs +219 -0
  142. package/src/explain.mjs +31 -0
  143. package/src/gates/index.mjs +385 -0
  144. package/src/graph/compile.mjs +8 -4
  145. package/src/graph/load.mjs +6 -2
  146. package/src/graph/spec.mjs +10 -8
  147. package/src/harness/records.mjs +143 -0
  148. package/src/hooks/capability.mjs +7 -5
  149. package/src/hooks/dispatcher.mjs +209 -60
  150. package/src/hooks/gates.mjs +10 -43
  151. package/src/hooks/resolve.mjs +4 -14
  152. package/src/hooks/shim.mjs +5 -2
  153. package/src/hooks/where.mjs +2 -7
  154. package/src/identity.mjs +31 -12
  155. package/src/index/coverage.mjs +58 -0
  156. package/src/index/extract.mjs +6 -16
  157. package/src/index/gate.mjs +1 -1
  158. package/src/index/govern.mjs +26 -9
  159. package/src/index/impact.mjs +4 -8
  160. package/src/index/map.mjs +14 -15
  161. package/src/index/open.mjs +173 -0
  162. package/src/index/persist.mjs +224 -57
  163. package/src/index/store.mjs +82 -39
  164. package/src/index/tests.mjs +2 -6
  165. package/src/journal.mjs +94 -20
  166. package/src/kernel/card.mjs +37 -63
  167. package/src/kernel/checks.mjs +4 -10
  168. package/src/lease.mjs +102 -7
  169. package/src/ledger/index.mjs +150 -14
  170. package/src/local/client.mjs +4 -6
  171. package/src/locate.mjs +33 -9
  172. package/src/manifest/schema.mjs +213 -0
  173. package/src/mcp/paginate.mjs +33 -0
  174. package/src/mcp/resources.mjs +7 -10
  175. package/src/mcp/schemas.mjs +512 -0
  176. package/src/mcp/server.mjs +119 -25
  177. package/src/mcp/tools-facts.mjs +326 -0
  178. package/src/mcp/tools-read.mjs +221 -0
  179. package/src/mcp/tools.mjs +4 -967
  180. package/src/measure/corpus.mjs +150 -0
  181. package/src/measure/metrics.mjs +236 -0
  182. package/src/measure/records.mjs +211 -0
  183. package/src/measure/session.mjs +3 -2
  184. package/src/measure/stats.mjs +538 -0
  185. package/src/measure/task-schema.mjs +125 -0
  186. package/src/measure/tasks.mjs +69 -0
  187. package/src/memory/decide.mjs +6 -15
  188. package/src/memory/lesson-text.mjs +61 -0
  189. package/src/memory/lessons.mjs +29 -82
  190. package/src/name.mjs +7 -0
  191. package/src/node-version.mjs +37 -0
  192. package/src/output/index.mjs +209 -10
  193. package/src/packs/manage.mjs +32 -12
  194. package/src/packs.mjs +20 -2
  195. package/src/plan/index.mjs +1 -1
  196. package/src/plugins.mjs +22 -16
  197. package/src/prbody.mjs +7 -11
  198. package/src/projection/loaders.mjs +7 -692
  199. package/src/projection/lock.mjs +11 -1
  200. package/src/projection/manifest.mjs +29 -468
  201. package/src/projection/mdc.mjs +7 -8
  202. package/src/projection/project.mjs +126 -221
  203. package/src/projection/region.mjs +561 -0
  204. package/src/projection/rules.mjs +20 -550
  205. package/src/projection/surfaces.mjs +43 -210
  206. package/src/projection/targets.mjs +5 -5
  207. package/src/repo-root.mjs +31 -0
  208. package/src/resolve/eval.mjs +2 -1
  209. package/src/resolve/fingerprint.mjs +19 -22
  210. package/src/resolve/resolve.mjs +1 -41
  211. package/src/review/critic.mjs +10 -13
  212. package/src/run.mjs +98 -0
  213. package/src/secrets.mjs +14 -22
  214. package/src/state/events.mjs +129 -0
  215. package/src/status-snapshot.mjs +233 -0
  216. package/src/status.mjs +2 -6
  217. package/src/sys/git.mjs +82 -0
  218. package/src/sys/proc.mjs +151 -0
  219. package/src/testing/parsers.mjs +265 -0
  220. package/src/testing/patches.mjs +23 -0
  221. package/src/testing/paths.mjs +49 -0
  222. package/src/testing/runners.mjs +186 -0
  223. package/src/testing/vault.mjs +68 -0
  224. package/src/text/frontmatter.mjs +49 -0
  225. package/src/text/tokens.mjs +52 -0
  226. package/src/toolwords.mjs +3 -0
  227. package/src/util/fs.mjs +20 -0
  228. package/src/util/sort.mjs +12 -0
  229. package/src/workspace.mjs +58 -11
  230. package/src/write/plan.mjs +82 -0
  231. package/templates/plan/tracker.json +1 -0
  232. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
  233. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
  234. package/docs/decisions/README.md +0 -600
  235. 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
@@ -50,6 +50,37 @@ export function repoRoot(cwd) {
50
50
  }
51
51
  }
52
52
 
53
+ /**
54
+ * The directory a command works on: `--root`, else the first positional when the command
55
+ * takes one, else the current directory.
56
+ *
57
+ * This precedence is a user-visible contract, not an implementation detail: it decides
58
+ * which directory `--root .` and a bare positional mean, and thirty-four commands had
59
+ * their own copy of it. The copies drifted - some resolved the answer, some did not; some
60
+ * read `args[0]`, some `positional[0]` - so the same argv could name two directories
61
+ * depending on which command read it (review 0003 Q65).
62
+ *
63
+ * The answer is returned verbatim, not resolved: a caller that wants an absolute path
64
+ * says so, and a command that reports the root it was given still reports what the reader
65
+ * typed. `repoRoot` answers the different question of which repository that directory
66
+ * belongs to.
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
+
80
+ export function commandRoot(flags = {}, positional = []) {
81
+ return namedRoot(flags, positional) ?? process.cwd();
82
+ }
83
+
53
84
  /**
54
85
  * Whether git on this repository compares paths case-insensitively, which is
55
86
  * the one filesystem fact a path comparison must honour: on a case-insensitive
@@ -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) {
@@ -19,10 +19,11 @@
19
19
 
20
20
  import { readdir, readFile, stat } from 'node:fs/promises';
21
21
  import { spawn } from 'node:child_process';
22
- import { basename, join } from 'node:path';
22
+ import { join } from 'node:path';
23
23
 
24
24
  import { matchesGlob } from './resolve.mjs';
25
25
  import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
26
+ import { loadManifest } from '../manifest/schema.mjs';
26
27
 
27
28
  /** tag -> human-readable marker(s) that imply it. First match wins per tag. */
28
29
  const MARKERS = [
@@ -225,6 +226,12 @@ export async function fingerprint(root) {
225
226
  }
226
227
  const names = new Set(entries);
227
228
 
229
+ // One walk answers every `glob` fact. Each candidate used to walk the tree again, so
230
+ // a repository with seven globs paid for seven identical walks - the same directories,
231
+ // in the same order, for the same answer (review 0003 Q67).
232
+ let nested = null;
233
+ const nestedPaths = async () => (nested ??= await listNested(root));
234
+
228
235
  for (const [tag, markers] of MARKERS) {
229
236
  const hit = markers.find((m) => names.has(m));
230
237
  if (hit) {
@@ -249,7 +256,7 @@ export async function fingerprint(root) {
249
256
  break;
250
257
  }
251
258
  if (fact.kind === 'glob') {
252
- const hit = (await findNested(root, candidate))[0];
259
+ const hit = findNestedIn(await nestedPaths(), candidate)[0];
253
260
  if (hit) {
254
261
  facts.add(fact.name);
255
262
  factEvidence[fact.name] ??= hit;
@@ -306,12 +313,6 @@ export async function fingerprint(root) {
306
313
  }
307
314
  }
308
315
 
309
- // two repositories both carry a wear/ module; only one of them is the wearable.
310
- if (names.has('wear') && (await isDir(join(root, 'wear')))) {
311
- stack.add('wear-os');
312
- evidence.push('wear/');
313
- }
314
-
315
316
  // Either spelling: a repository that adopted this tool has one of the two on disk,
316
317
  // and a fingerprint that missed the manifest would silently lose the repo's own
317
318
  // declared stack - which is the one input no amount of marker reading can replace.
@@ -320,9 +321,9 @@ export async function fingerprint(root) {
320
321
  : names.has(MANIFEST_LEGACY)
321
322
  ? MANIFEST_LEGACY
322
323
  : null;
323
- const manifest = manifestName === null
324
- ? null
325
- : await readManifest(join(root, manifestName));
324
+ // One reader, so a file that does not parse is a missing declaration rather than an
325
+ // object whose only key is "error" pretending to be this repo manifest (Q69).
326
+ const manifest = manifestName === null ? null : (await loadManifest(root)).manifest;
326
327
 
327
328
  const stackList = [...stack].sort();
328
329
  const factList = [...facts].sort();
@@ -339,14 +340,6 @@ export async function fingerprint(root) {
339
340
  };
340
341
  }
341
342
 
342
- async function readManifest(path) {
343
- try {
344
- return JSON.parse(await readFile(path, 'utf8'));
345
- } catch {
346
- return { error: `${basename(path)} unparseable` };
347
- }
348
- }
349
-
350
343
  async function isDir(path) {
351
344
  try {
352
345
  return (await stat(path)).isDirectory();
@@ -364,7 +357,7 @@ async function isDir(path) {
364
357
  * are skipped for the reason the extension sample skips them: detection is not a
365
358
  * scan of vendored output.
366
359
  */
367
- async function findNested(root, pattern) {
360
+ async function listNested(root) {
368
361
  const found = [];
369
362
  const walk = async (dir, prefix, depth) => {
370
363
  let items;
@@ -378,7 +371,7 @@ async function findNested(root, pattern) {
378
371
  const rel = prefix ? `${prefix}/${item.name}` : item.name;
379
372
  if (item.isDirectory()) {
380
373
  if (depth < GLOB_DEPTH) await walk(join(dir, item.name), rel, depth + 1);
381
- } else if (matchesGlob(rel, pattern)) {
374
+ } else {
382
375
  found.push(rel);
383
376
  }
384
377
  }
@@ -387,6 +380,11 @@ async function findNested(root, pattern) {
387
380
  return found.sort();
388
381
  }
389
382
 
383
+ /** The paths in the one walk that match a shape. */
384
+ function findNestedIn(paths, pattern) {
385
+ return paths.filter((rel) => matchesGlob(rel, pattern)).sort();
386
+ }
387
+
390
388
  /**
391
389
  * Which extensions this repository is written in.
392
390
  *
@@ -543,7 +541,6 @@ export const STACK_TAGS = new Set([
543
541
  ...MARKERS.map(([tag]) => tag),
544
542
  ...EXTENSION_TAGS.values(),
545
543
  ...DEP_TAGS.values(),
546
- 'wear-os', // derived from a wear/ module, not a marker file
547
544
  ]);
548
545
 
549
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,23 +16,21 @@
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';
33
+ import { commandRoot } from '../repo-root.mjs';
36
34
 
37
35
  // Locked on-disk beneath machine-local state, like the distilled cache (R38) —
38
36
  // proposals keyed by content digest, never projected and never committed.
@@ -118,8 +116,7 @@ async function governingItems(root, files, commands = []) {
118
116
  const ctx = { files: fileSet, commands: new Set(commands), roots: sourceRoots(files) };
119
117
  const items = new Map();
120
118
  for (const file of files) {
121
- const { hits } = decisionHits(records, file, ctx);
122
- for (const hit of sortHits([...hits, ...scopeHits(file, corpus)])) {
119
+ for (const hit of governs(file, { records, ...corpus, ctx })) {
123
120
  if (!items.has(hit.id)) items.set(hit.id, hit);
124
121
  }
125
122
  }
@@ -216,7 +213,7 @@ export async function reviewTarget(args, flags, { write = true, commands = [] }
216
213
  if (args.length > 0) {
217
214
  return fail(`unknown review argument ${args[0]}`, EXIT.USAGE, `usage: ${CLI} review [--local]`);
218
215
  }
219
- const root = resolve(flags.root ?? process.cwd());
216
+ const root = resolve(commandRoot(flags));
220
217
  const listed = await tracked(root);
221
218
  if (listed.error) return listed.error;
222
219
 
@@ -224,11 +221,11 @@ export async function reviewTarget(args, flags, { write = true, commands = [] }
224
221
  let names;
225
222
  try {
226
223
  const [diff, changed] = await Promise.all([
227
- exec('git', ['diff', '--staged'], { cwd: root, maxBuffer: 64 << 20 }),
228
- 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 }),
229
226
  ]);
230
- patch = diff.stdout;
231
- names = changed.stdout.split('\n').filter(Boolean);
227
+ patch = diff;
228
+ names = changed.split('\n').filter(Boolean);
232
229
  } catch (error) {
233
230
  return fail(
234
231
  `could not read the staged diff here (${error.message})`,
@@ -442,7 +439,7 @@ export async function criticBench(root, { labelsPath, flags, commands = [] }) {
442
439
  async function peer(root, sha, flags, endpoint, commands) {
443
440
  let diff;
444
441
  try {
445
- 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 });
446
443
  } catch (error) {
447
444
  return { failed: true, exitMessage: error.message };
448
445
  }