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
@@ -0,0 +1,68 @@
1
+ // Where a repository's hidden test patches live.
2
+ //
3
+ // The vault is outside every checkout on purpose: a container of test patches a
4
+ // bench run is graded against must not be readable from the workspace it is measuring.
5
+ // It is keyed on the repository's root commit, which is identical in every clone and
6
+ // survives remote renames, so the corpus a bench was mined from is found on another
7
+ // machine (review 0003 Q48: an absolute-path key made the committed corpus unrunnable
8
+ // from any other checkout).
9
+ //
10
+ // This lives under `src/testing/` because the map measures recall against the same
11
+ // patches and the index asks whether a path is a test — neither of them runs a benchmark
12
+ // (review 0003 Q79).
13
+ import { execFileSync } from 'node:child_process';
14
+ import { createHash } from 'node:crypto';
15
+ import { homedir, platform as osPlatform } from 'node:os';
16
+ import { basename, join, resolve } from 'node:path';
17
+
18
+ import { CONFIG_DIR } from '../name.mjs';
19
+
20
+ /**
21
+ * The stable identity a repository's vault is keyed on: the basename for a
22
+ * human-readable prefix, and the root commit for the digest. Outside git it degrades to
23
+ * the absolute-path digest: a root with no history has no better identity, and the vault
24
+ * path must never fail because git is absent.
25
+ */
26
+ function repoIdentifier(root) {
27
+ const full = resolve(root);
28
+ const name = basename(full).replace(/[^A-Za-z0-9._-]/g, '-') || 'repo';
29
+ try {
30
+ const sha = execFileSync('git', ['rev-list', '--max-parents=0', 'HEAD'], {
31
+ cwd: root,
32
+ encoding: 'utf8',
33
+ stdio: ['ignore', 'pipe', 'ignore'],
34
+ })
35
+ .trim()
36
+ .split(/\s/)[0];
37
+ if (/^[0-9a-f]{40}$/.test(sha)) return `${name}-root-${sha.slice(0, 8)}`;
38
+ } catch {
39
+ // Not a git root (or no commits): fall through to the path digest.
40
+ }
41
+ const digest = createHash('sha256').update(full).digest('hex').slice(0, 8);
42
+ return `${name}-${digest}`;
43
+ }
44
+
45
+ /**
46
+ * Resolve the hidden verifier vault root outside every worktree.
47
+ *
48
+ * Precedence:
49
+ * 1. `SIMT_DEV_VAULT_DIR` (explicit override for hermetic tests)
50
+ * 2. `$XDG_CACHE_HOME/simt-dev/vault/<repo-id>`
51
+ * 3. `~/Library/Caches/simt-dev/vault/<repo-id>` on macOS, or `~/.cache/simt-dev/vault/<repo-id>`
52
+ *
53
+ * @param {string} root
54
+ * @param {{env?: NodeJS.ProcessEnv, home?: string, platform?: string}} [options]
55
+ * @returns {string}
56
+ */
57
+ export function resolveVaultDir(root, { env = process.env, home = homedir(), platform = osPlatform() } = {}) {
58
+ const repoId = repoIdentifier(root);
59
+ if (env.SIMT_DEV_VAULT_DIR) {
60
+ return join(resolve(env.SIMT_DEV_VAULT_DIR), repoId);
61
+ }
62
+ const cacheBase = env.XDG_CACHE_HOME
63
+ ? resolve(env.XDG_CACHE_HOME)
64
+ : platform === 'darwin'
65
+ ? join(home, 'Library', 'Caches')
66
+ : join(home, '.cache');
67
+ return join(cacheBase, CONFIG_DIR, 'vault', repoId);
68
+ }
@@ -0,0 +1,49 @@
1
+ // The one frontmatter block: an opening `---` line, a body, a closing `---` line,
2
+ // at the top of a document.
3
+ //
4
+ // Eleven call sites carried their own copy of the regex, and the copies disagreed
5
+ // about CRLF: two tolerated `\r\n`, nine assumed `\n`, so a document checked out
6
+ // with CRLF line endings parsed in one command and came back with the block glued
7
+ // to the body in another. The shape here is the superset: it tolerates CRLF, and it
8
+ // fixes what "the block" is, so a caller that asks for the block, the body or the
9
+ // stripped document gets all three from one match.
10
+
11
+ /** The block as written: the two `---` lines and what is between them. The line
12
+ * ending after the closing `---` is NOT part of the block, because a caller that
13
+ * swaps the block in place must leave that newline where the file had it. */
14
+ const BLOCK = /^---\r?\n([\s\S]*?)\r?\n---/;
15
+
16
+ /**
17
+ * Split a leading frontmatter block from its document.
18
+ *
19
+ * @param {string} text
20
+ * @returns {{front: string|null, body: string, block: string|null}}
21
+ * `front` is the interior between the `---` lines, `block` is the block as written,
22
+ * and `body` is the document after the block and the one line ending that follows
23
+ * it. All three are null-or-unchanged when the document carries no block.
24
+ */
25
+ export function parseFrontmatter(text) {
26
+ const raw = String(text ?? '');
27
+ const m = BLOCK.exec(raw);
28
+ if (!m) return { front: null, body: raw, block: null };
29
+ const rest = raw.slice(m[0].length);
30
+ const body = rest.startsWith('\r\n') ? rest.slice(2) : rest.startsWith('\n') ? rest.slice(1) : rest;
31
+ return { front: m[1], body, block: m[0] };
32
+ }
33
+
34
+ /** The block as written, or null when the document carries none. */
35
+ export function frontmatterBlock(text) {
36
+ const m = BLOCK.exec(String(text ?? ''));
37
+ return m ? m[0] : null;
38
+ }
39
+
40
+ /**
41
+ * The document without its block, and without the line endings that followed it.
42
+ *
43
+ * The trailing-newline swallow lives here rather than in BLOCK on purpose: a strip
44
+ * that left the blank lines in place would send a following `^`-anchored strip (the
45
+ * H1 a region renderer drops) at a bare newline, and it would silently match nothing.
46
+ */
47
+ export function stripFrontmatter(text) {
48
+ return String(text ?? '').replace(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n)*/, '');
49
+ }
@@ -0,0 +1,52 @@
1
+ // The token split and the two stopword lists, in one place.
2
+ //
3
+ // The two lists are deliberately NOT merged. The resolver's list is the English
4
+ // filler a task string carries ("fix the thing"), hand-authored because a resolver
5
+ // nobody can review is not a resolver; the index's list is the vocabulary a task
6
+ // about code carries ("file", "test", "line"), sized for a different signal. Merging
7
+ // them would change what each one ranks, and a ranking change is a behaviour change
8
+ // this deduplication was not asked to make.
9
+
10
+ /**
11
+ * Split a task string into comparable tokens.
12
+ *
13
+ * Length 2 is kept. The floor used to be 3, and it dropped the most specific words in
14
+ * this domain: `d1`, `r2`, `kv`, `ci` are two-letter vocabulary, not noise, and a task
15
+ * saying "fix d1 migration" lost the one word that identified the change. The cost is
16
+ * that six stopwords of two letters (`in on of or an so`) now reach the summary signal,
17
+ * which is S04's finding and not something this change can resolve on its own - the
18
+ * remedy there is a stopword list, not a longer minimum.
19
+ */
20
+ export function tokenize(text) {
21
+ return String(text ?? '')
22
+ .toLowerCase()
23
+ .split(/[^a-z0-9]+/)
24
+ .filter((t) => t.length > 1);
25
+ }
26
+
27
+ /**
28
+ * The English filler a task string carries. Hand-authored rather than derived, for the
29
+ * same reason the capability aliases are: a resolver nobody can review is not a
30
+ * resolver.
31
+ */
32
+ export const STOPWORDS = new Set([
33
+ 'about', 'after', 'all', 'also', 'and', 'an', 'any', 'are', 'as', 'at', 'be', 'because',
34
+ 'been', 'before', 'being', 'both', 'but', 'by', 'can', 'could', 'did', 'do', 'does', 'each',
35
+ 'for', 'from', 'had', 'has', 'have', 'he', 'her', 'here', 'his', 'how', 'if', 'in', 'into',
36
+ 'is', 'it', 'its', 'just', 'like', 'make', 'many', 'may', 'me', 'more', 'most', 'must', 'my',
37
+ 'no', 'not', 'now', 'of', 'off', 'on', 'one', 'only', 'onto', 'or', 'other', 'our', 'out',
38
+ 'over', 'own', 'per', 'same', 'see', 'set', 'she', 'should', 'so', 'some', 'such', 'than',
39
+ 'that', 'the', 'their', 'them', 'then', 'there', 'these', 'they', 'this', 'those', 'to', 'too',
40
+ 'two', 'under', 'up', 'us', 'use', 'used', 'using', 'very', 'was', 'way', 'we', 'were', 'what',
41
+ 'when', 'where', 'which', 'while', 'who', 'why', 'will', 'with', 'within', 'without', 'would',
42
+ 'you', 'your',
43
+ ]);
44
+
45
+ /** The code vocabulary a task about a tree carries, for the index's term signal. */
46
+ export const INDEX_STOP = new Set([
47
+ 'this', 'that', 'with', 'from', 'have', 'been', 'into', 'only', 'when', 'what',
48
+ 'which', 'their', 'about', 'after', 'before', 'where', 'there', 'these', 'those',
49
+ 'would', 'could', 'should', 'every', 'other', 'than', 'then', 'them', 'they',
50
+ 'your', 'will', 'does', 'file', 'files', 'test', 'tests', 'line', 'lines', 'code',
51
+ 'also', 'just', 'more', 'some', 'such', 'each', 'same', 'over', 'used', 'using',
52
+ ]);
package/src/toolwords.mjs CHANGED
@@ -23,6 +23,7 @@ import { LESSON_KEYS } from './memory/lessons.mjs';
23
23
  import { DECISION_KEYS } from './memory/decide.mjs';
24
24
  import { PROMPT_WORDS } from './mcp/prompts.mjs';
25
25
  import { AGENT_STEM, SKILL_STEM } from './agents/render.mjs';
26
+ import { CORPUS_KIND } from './measure/corpus.mjs';
26
27
 
27
28
  /**
28
29
  * `simt-` names that belong to this tool and never to a repository.
@@ -50,6 +51,8 @@ export const TOOL_WORDS = new Set([
50
51
  AGENT_STEM,
51
52
  // The companion skill the agent reads on demand; its stem is the directory name.
52
53
  SKILL_STEM,
54
+ // The kind stamp this tool writes into every sealed bench corpus document it exports.
55
+ CORPUS_KIND,
53
56
  ]);
54
57
 
55
58
  /**
@@ -0,0 +1,20 @@
1
+ // Small file and output helpers with two or more call sites, too small to earn
2
+ // a module apiece.
3
+
4
+ import { readFile } from 'node:fs/promises';
5
+
6
+ /** Read a file as UTF-8, answering null when it is not there. */
7
+ export async function readIfExists(path) {
8
+ try {
9
+ return await readFile(path, 'utf8');
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+
15
+ /** `1234567`, never `1,234,567`: two machines must print the same bytes (ADR-013).
16
+ * `toLocaleString` output is locale-dependent, and these commands exist so that two
17
+ * machines produce two numbers a pull request can compare. */
18
+ export function plainNumber(n) {
19
+ return String(n);
20
+ }
@@ -0,0 +1,12 @@
1
+ // The one string comparison for every sort that orders paths, names and ids.
2
+ //
3
+ // Seven copies of the same three-line comparator grew up under src/, spelled two
4
+ // different ways, and a divergence in any one of them would quietly reorder a list
5
+ // a reader has memorized. One definition, one order.
6
+
7
+ /** Deterministic string order: -1, 0 or 1. */
8
+ export function cmp(a, b) {
9
+ if (a < b) return -1;
10
+ if (a > b) return 1;
11
+ return 0;
12
+ }
package/src/workspace.mjs CHANGED
@@ -21,6 +21,8 @@
21
21
  // confidently wrong.
22
22
 
23
23
  import { readFile } from 'node:fs/promises';
24
+
25
+ import { namedRoot } from './repo-root.mjs';
24
26
  import { existsSync } from 'node:fs';
25
27
  import { homedir, platform as osPlatform } from 'node:os';
26
28
  import { dirname, isAbsolute, join, resolve } from 'node:path';
@@ -38,7 +40,8 @@ export const PACKAGE_ROOT = resolve(HERE, '..');
38
40
  // other LOCKED on-disk names, and a second copy here is a name that can drift from the
39
41
  // list of names that are allowed to move. Imported as well as re-exported, because a
40
42
  // bare `export { X } from` does not bind X here and every use below would fail.
41
- import { CONFIG_DIR } from './name.mjs';
43
+ import { CONFIG_DIR, MANIFEST_FILE } from './name.mjs';
44
+ import { manifestPath, readManifest } from './projection/manifest.mjs';
42
45
  export { CONFIG_DIR };
43
46
  export const CONFIG_FILE = 'config.json';
44
47
 
@@ -133,14 +136,25 @@ async function looksLikeWorkspace(dir) {
133
136
  /**
134
137
  * Resolve the workspace.
135
138
  *
136
- * Precedence, highest first: `--workspace`, `SIMT_DEV_WORKSPACE`, the user config file, then the
137
- * sibling heuristic of this package - and that last one only if it actually contains a git
138
- * repository.
139
+ * Precedence, highest first (ADR-119): `--workspace`, the repository's own manifest
140
+ * declaration, `SIMT_DEV_WORKSPACE` - a value that names a directory is honoured only
141
+ * where the manifest declares a workspace, because the variable is a pointer INTO a
142
+ * workspace the repo opted into, not one imposed on a repo that never declared one;
143
+ * a value that declines is honoured from anywhere, because a decline points at
144
+ * nothing - the manifest's value, the user config file, then the sibling heuristic
145
+ * of this package, which is accepted only if it actually contains a git repository.
146
+ *
147
+ * A relative manifest value resolves against the manifest's directory, not the
148
+ * process's, so `workspace: ".."` means the same thing from any subdirectory.
139
149
  *
150
+ * @param {{repoRoot?: string}} [options] `repoRoot` names the repository whose manifest
151
+ * declares the workspace; it defaults to `process.cwd()`, which is the repository
152
+ * for the way every command is run.
140
153
  * @returns {Promise<{root: string|null, source: string, detail: string, searched: string[], error: string|null}>}
141
154
  */
142
155
  export async function resolveWorkspace(flags = {}, options = {}) {
143
156
  const env = options.env ?? process.env;
157
+ const repoRoot = options.repoRoot ?? process.cwd();
144
158
  const searched = configPaths(options);
145
159
 
146
160
  // The config file is read FIRST, before precedence is applied, and a parse failure
@@ -169,16 +183,49 @@ export async function resolveWorkspace(flags = {}, options = {}) {
169
183
  );
170
184
  }
171
185
 
186
+ // The repository's own declaration (ADR-119). It is read before the environment
187
+ // variable because it is what the variable is allowed to point into: a repo that
188
+ // declared no workspace is a single repo with no workspace, and an environment
189
+ // variable cannot turn it into a member of a workspace nobody reviewed.
190
+ const manifest = await readManifest(repoRoot);
191
+ if (manifest.error) {
192
+ return {
193
+ root: null,
194
+ source: 'repo',
195
+ detail: await manifestPath(repoRoot),
196
+ searched,
197
+ error: manifest.error,
198
+ };
199
+ }
200
+ let repoValue = null;
201
+ if (manifest.exists && typeof manifest.manifest.workspace === 'string' && manifest.manifest.workspace.trim() !== '') {
202
+ const off = disabled(manifest.manifest.workspace, 'repo', `${MANIFEST_FILE} workspace`, searched);
203
+ if (off) return off; // the repo declined; the environment variable cannot enforce one
204
+ repoValue = manifest.manifest.workspace;
205
+ }
206
+
172
207
  if (env.SIMT_DEV_WORKSPACE) {
173
- return (
174
- disabled(env.SIMT_DEV_WORKSPACE, 'env', 'SIMT_DEV_WORKSPACE', searched) ?? {
208
+ const off = disabled(env.SIMT_DEV_WORKSPACE, 'env', 'SIMT_DEV_WORKSPACE', searched);
209
+ if (off) return off; // a decline points at nothing, so it needs no declaration to work
210
+ if (repoValue !== null) {
211
+ return {
175
212
  root: absolute(env.SIMT_DEV_WORKSPACE),
176
213
  source: 'env',
177
214
  detail: 'SIMT_DEV_WORKSPACE',
178
215
  searched,
179
216
  error: null,
180
- }
181
- );
217
+ };
218
+ }
219
+ }
220
+
221
+ if (repoValue !== null) {
222
+ return {
223
+ root: absolute(repoValue, repoRoot),
224
+ source: 'repo',
225
+ detail: `${MANIFEST_FILE} workspace`,
226
+ searched,
227
+ error: null,
228
+ };
182
229
  }
183
230
 
184
231
  if (user.exists && typeof user.config.workspace === 'string' && user.config.workspace.length > 0) {
@@ -245,11 +292,11 @@ export const WORKSPACE_REMEDY = (() => {
245
292
  return `--workspace <path>, the SIMT_DEV_WORKSPACE environment variable, or ${where}`;
246
293
  })();
247
294
 
248
- function absolute(p) {
295
+ function absolute(p, base = process.cwd()) {
249
296
  const expanded = p.startsWith('~/')
250
297
  ? join(homedir(), p.slice(2))
251
298
  : p;
252
- return isAbsolute(expanded) ? expanded : resolve(expanded);
299
+ return isAbsolute(expanded) ? expanded : resolve(base, expanded);
253
300
  }
254
301
 
255
302
  /**
@@ -266,7 +313,7 @@ function absolute(p) {
266
313
  * answers it from the filesystem rather than from a guess.
267
314
  */
268
315
  export async function targetRoot(flags = {}, positional = []) {
269
- const explicit = flags.root ?? positional[0];
316
+ const explicit = namedRoot(flags, positional);
270
317
  if (explicit) {
271
318
  return { root: absolute(explicit), source: 'flag', detail: explicit, ws: null };
272
319
  }
@@ -0,0 +1,82 @@
1
+ // One change record, and the one function that applies it (review 0003 Q68).
2
+ //
3
+ // Fourteen writers hand-roll the same five steps: read the before bytes, decide create or
4
+ // update, write through the journal, commit the set, print the diff. They do not all do
5
+ // them in the same order, and the differences are defects rather than decisions - a
6
+ // writer that skipped the journal (the tracker, the lock, `skills compile`) could not be
7
+ // undone, and one that read the before bytes after writing produced a diff against
8
+ // itself.
9
+ //
10
+ // A change is data: what the path will hold, what it holds now, who owns it and why it
11
+ // changes. `applyPlan` is the only place that knows about journals, directories and
12
+ // `--yes`, so a writer states the change and nothing else.
13
+ //
14
+ // The record carries no `action` from the caller. Create, update, remove and unchanged
15
+ // are derived from `before` and `after`, because a writer that passes an action it
16
+ // computed by hand is the bug this replaces.
17
+ import { isAbsolute, join } from 'node:path';
18
+
19
+ import { commitJournal, journaledRemoveFile, journaledWriteFile } from '../journal.mjs';
20
+
21
+ /**
22
+ * One change to one path.
23
+ *
24
+ * `kind: 'remove'` is a deletion, and `after` is then null. `owner` and `reason` are for
25
+ * the report a person reads: which writer owns the file and why it is changing. `mode` is
26
+ * permission bits for a file that must be executable, and rides on the same entry so
27
+ * `undo` restores the mode it found.
28
+ */
29
+ export function change({ path, kind = 'file', before, after, owner, reason, mode }) {
30
+ if (kind !== 'file' && kind !== 'remove') throw new Error(`unknown change kind: ${kind}`);
31
+ return {
32
+ path,
33
+ kind,
34
+ before,
35
+ after,
36
+ owner: owner ?? null,
37
+ reason: reason ?? null,
38
+ mode,
39
+ action: actionOf(kind, before, after),
40
+ };
41
+ }
42
+
43
+ function actionOf(kind, before, after) {
44
+ if (kind === 'remove') return before === null ? 'unchanged' : 'remove';
45
+ if (before === after) return 'unchanged';
46
+ if (before === null) return 'create';
47
+ return 'update';
48
+ }
49
+
50
+ /** The changes that would do something, in the order the caller listed them. */
51
+ export function pending(changes) {
52
+ return (changes ?? []).filter((c) => c.action !== 'unchanged');
53
+ }
54
+
55
+ /**
56
+ * Apply the plan, or report it.
57
+ *
58
+ * Without `mode.apply` nothing touches the disk and the pending paths come back as
59
+ * `pending`: a rehearsal and a real run answer the same question with the same plan, and
60
+ * the only difference is whether the bytes moved. With it, every change is journaled into
61
+ * one set under `label`, so `undo` restores the run rather than one file of it.
62
+ *
63
+ * A writer whose run is more than one plan - `init` writes a manifest, an identity and a
64
+ * set of targets and commits once - passes its own `journal` and `commit: false`. The
65
+ * entries land in the caller's set, so the run stays one undoable step rather than one
66
+ * step per plan.
67
+ */
68
+ export async function applyPlan(root, changes, { mode, label, journal: shared, commit = true } = {}) {
69
+ const todo = pending(changes);
70
+ if (todo.length === 0) return { applied: false, written: [], pending: [], journal: [] };
71
+ if (!mode?.apply) return { applied: false, written: [], pending: todo.map((c) => c.path), journal: [] };
72
+
73
+ const journal = shared ?? [];
74
+ const first = journal.length;
75
+ for (const c of todo) {
76
+ const path = isAbsolute(c.path) ? c.path : join(root, c.path);
77
+ if (c.kind === 'remove') await journaledRemoveFile(root, journal, path);
78
+ else await journaledWriteFile(root, journal, path, c.after, c.mode === undefined ? {} : { mode: c.mode });
79
+ }
80
+ if (commit && journal.length > first) await commitJournal(root, label, journal);
81
+ return { applied: true, written: todo.map((c) => c.path), pending: [], journal };
82
+ }
@@ -3,6 +3,7 @@
3
3
  "name": "tracker",
4
4
  "directories": [
5
5
  { "kind": "plans", "word": "plan", "path": "docs/plans" },
6
+ { "kind": "research", "word": "research", "path": "docs/research" },
6
7
  { "kind": "reviews", "word": "review", "path": "docs/reviews" }
7
8
  ],
8
9
  "id": {
@@ -1,82 +0,0 @@
1
- # ADR-033 — three plan rows closed by decision, not by authoring
2
-
3
- Status: accepted · 2026-10-08
4
- **Affects:** `docs/plans/0001-centralise-agent-rules.md` §3.1 row 3, §6 phase 6, §6 phase 7.
5
- Ships no rule text. **Refuses:** the seven agent policies row 3 asks for, the twin collapse
6
- phase 6 asks for, and untracking the two committed `local.properties` files phase 7 does not
7
- mention but whose existence made the sweep look unfinished.
8
-
9
- ## Context
10
-
11
- Closing plan 0001 meant auditing each row against the tree rather than against the plan's own
12
- prose, because the plan was written on 2026-10-05 and three things changed underneath it. This
13
- record is the audit's residue: three items that read as unfinished work and are not.
14
-
15
- Row 3 asked for eight agent policies as separate files. Seven were never authored. A search for
16
- their source material across every tracked `*.md` and `*.mdc` in the estate returns two files,
17
- neither of which is a policy: they are a progress ledger and a completed UX plan that happen to
18
- contain the phrases. The three most specific of the eight — the 70/30 quality-versus-performance
19
- split, the 12-state coverage list, the matrix-trigger heuristic — have no source anywhere. Writing
20
- them would mean inventing doctrine this estate never agreed to, into `rules/core.md`, from where
21
- every adopted repo would inherit it.
22
-
23
- Phase 6 asked to "collapse the 7 drifted Social skill twins to canonical + stub". The registry's
24
- `sources.role` mechanism landed the same evening, two hours after the plan was authored, and
25
- answers the row directly. All three resolvers select `role === 'canonical'` and nothing else —
26
- `src/commands/skills.mjs` twice, `src/commands/skillsinstall.mjs:291`, `src/mcp/tools.mjs:192` — so
27
- a copy declared `duplicate` or `variant` is never installed. The one pair that genuinely had to be
28
- collapsed, `add-country-galaxy`, is already a stub in `repo-14` pointing at the API repo.
29
-
30
- Phase 7 asked for a sweep of hardcoded absolute paths. The sweep found 17 tracked files. Six were
31
- fixed. The two Android `local.properties` files were not, and were nearly fixed by reflex.
32
-
33
- ## Rulings
34
-
35
- 1. **A row is closed by ruling on it when the thing it asks for was never a decision anyone made.**
36
- Row 3's eight policies are closed: the four claims `rules/core.md` already carries — the
37
- minimum viable team, never weakening a security control, anti-overclaim, and a stated agent
38
- budget that never converts a failure into an approval — are the doctrine, and they ship. The
39
- other four, including the 70/30 split, the 12-state list and the matrix-trigger heuristic, are
40
- recorded here as unmade. If one is wanted it needs someone who held the original
41
- conversation, not someone reconstructing it from a table cell.
42
-
43
- 2. **A mechanism that supersedes a row retires the row, and the deletion is the wrong move.** The
44
- `sources.role` vocabulary already distinguishes `canonical`, `duplicate`, `variant`, `stub` and
45
- `deprecated`, and `graph lint` passes with all of them in use. Deleting a `duplicate` copy would
46
- remove a version-pinned tool from a repo that can still run it, and nothing would detect the loss
47
- because resolution never read those bytes. Phase 6's twin collapse is therefore closed as
48
- already answered, at `a9983c8`.
49
-
50
- 3. **`local.properties` stays tracked.** Both `repo-05` and `repo-03` commit it, and both
51
- `.gitignore` files have the conventional `# local.properties` line commented out at lines 22 and
52
- 24 — a deliberate override of Android's own convention, not an oversight. Untracking them would
53
- be a house-style change made while closing a plan, and a build machine that checks out without
54
- an SDK would stop building. It stays, and plan 0001 says so, so the next sweep does not
55
- rediscover it as a defect.
56
-
57
- ## Consequences
58
-
59
- Row 3 reads "done, four of eight" rather than "partial". Phase 6's twin item reads "superseded"
60
- rather than "outstanding". The estate keeps two `local.properties` files whose `sdk.dir` names a
61
- developer's home directory, by decision.
62
-
63
- The registry's byte counts moved with the two skill bodies corrected in the same pass, because a
64
- declared source's identity is its size (ADR-015): `cloudflare` 384277 → 384869 and
65
- `production-quality-review` 5869 → 5904. That changed the provenance digest (ADR-032), so all
66
- fourteen adopted repos were re-stamped afterwards rather than left pinning a value this tool can no
67
- longer produce.
68
-
69
- ## Rejected
70
-
71
- **Authoring the seven agent policies.** The plan's one-line summaries are not enough to write seven
72
- files of rule text, and these rules reach every adopted repo. This is ADR-030's exact shape: an
73
- unmade decision is not a gap to fill by invention.
74
-
75
- **Collapsing `graphify` and `parallel-feature-team`.** Both are declared `duplicate` with matching
76
- byte counts, so they look like the obvious candidates. `graphify` is `vendored: true` — an
77
- 84 534-byte, ten-file, version-pinned tool present in both one repository on purpose. Deleting
78
- repo-14's copy would leave nothing to detect the loss, because resolution only ever read the
79
- canonical copy.
80
-
81
- **Untracking `local.properties`.** An Android SDK path is machine state, and two repos chose to
82
- share it. That is the repo's call to reverse, not a plan's defect sweep.
@@ -1,49 +0,0 @@
1
- ---
2
- simt-affects: ["docs/reviews/0002-full-review-adoption-and-public-release.md"]
3
- simt-supersedes: []
4
- ---
5
-
6
- # ADR-102 — the two rows review 0002 left queued are closed by ruling
7
-
8
- Status: **accepted** · 2026-10-28
9
-
10
- ## Context
11
-
12
- Review 0002's §3 tracker is the only tracker of slice state, and two rows in it
13
- still read Queued while everything around them shipped: S38 (workspace token
14
- cost) and S39 (estate policy as a pack). Both rows' evidence cells are
15
- palimpsests — notes from three different landings disagreeing about what is
16
- still open — so reading them as a work list sends the next reader to build what
17
- is already built. Closing plan 0001 meant the same audit, and ADR-033 is the
18
- precedent: rows closed by saying what shipped, what was refused by ruling, and
19
- where the remainder lives, not by authoring what they asked for.
20
-
21
- ## Ruling
22
-
23
- 1. **S38 is Complete.** The instrument shipped (`simt tokens`, ADR-064), the
24
- declaration shipped (`budget.bytes` beside `limits` and `gates`, ADR-072),
25
- and the gate raises it as a finding through one `budgetFinding` read from
26
- one place by both `tokens` and `check` — the "still open" clause the row's
27
- first note ends on. Kernel-line packing shipped with it (R07's `mode:
28
- kernel`, R08's budget compilation). Blocking enforcement is refused, not
29
- outstanding: a gate that fails a repository for exceeding a ceiling it set
30
- itself, when nothing was cut, has no repair except lowering the declared
31
- number (ADR-072). The one genuine remainder is the kernel-default
32
- compliance study, and it is owned by plan 0003 R07's own note, not by this
33
- row — the study gates making `kernel` the default, which no row asks to
34
- decide here.
35
-
36
- 2. **S39 is Complete.** The mechanism shipped (packs vendored and digest-pinned,
37
- ADR-065; measured ΔR, plan 0003 R45). P4's pack-carries-the-registry line was
38
- superseded by ADR-061, which made the registry workspace data, so there is
39
- no registry-carrying pack to build. The remaining half — moving estate
40
- policy into a pack — is the estate's own commit: no estate content may enter
41
- this repository, and a row that waits on another repository's commit is
42
- closed by recording whose commit it waits on.
43
-
44
- ## Consequences
45
-
46
- - The §3 tracker reads Complete on both rows, with this record as the evidence,
47
- so the next audit starts from rulings rather than from mid-flight notes.
48
- - A reader asking for a blocking budget gate or a registry-carrying pack gets
49
- the refusal and its reason instead of an open row that looks like a promise.