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
@@ -6,11 +6,9 @@
6
6
  // no longer proves anything about what is on disk now.
7
7
 
8
8
  import { createHash } from 'node:crypto';
9
- import { execFile } from 'node:child_process';
10
- import { appendFile, mkdir, mkdtemp, rm } from 'node:fs/promises';
9
+ import { appendFile, copyFile, mkdir, mkdtemp, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
11
10
  import { tmpdir } from 'node:os';
12
- import { join } from 'node:path';
13
- import { promisify } from 'node:util';
11
+ import { join, resolve } from 'node:path';
14
12
 
15
13
  import {
16
14
  errorSignature,
@@ -19,11 +17,11 @@ import {
19
17
  recordFailThenFix,
20
18
  } from '../memory/lessons.mjs';
21
19
  import { TREE_EXCLUDES, localRel, readStateFile } from '../state.mjs';
20
+ import { appendEvent } from '../state/events.mjs';
21
+ import { LEDGER_FILE_LEGACY, LEDGER_FILE_REL } from '../name.mjs';
22
+ import { git, gitText } from '../sys/git.mjs';
22
23
 
23
- const exec = promisify(execFile);
24
-
25
- export const LEDGER_FILE = localRel('ledger.jsonl');
26
- const LEDGER_FILE_LEGACY = '.simt/ledger.jsonl';
24
+ export const LEDGER_FILE = localRel(LEDGER_FILE_REL);
27
25
 
28
26
  /** Phrases that claim a run happened. `unverified` and `not verified` are removed
29
27
  * first, because both contain the word and neither is a claim. Trailers
@@ -33,6 +31,33 @@ const CLAIM =
33
31
  /\btests?\s+pass(?:ed|ing)?\b|\btest\s+suite\s+(?:is\s+)?green\b|\bsuite\s+is\s+green\b|\bnpm\s+test\s+pass(?:es|ed)?\b|\bverified\b/i;
34
32
  const TRAILER = /^(signed-off-by|co-authored-by|reviewed-by|acked-by|tested-by|fixes|closes)\s*:/i;
35
33
 
34
+ /**
35
+ * Copy the repository's real index into a private one, and answer its path or null.
36
+ *
37
+ * The private index exists so `git add -A` cannot stage the author's work. Building it from
38
+ * nothing meant every path was hashed on every gate run, commit-msg check, `evidence` call
39
+ * and `pr-body`: on a 20k-file tree that is the whole cost of the command (Q75). Seeding it
40
+ * from the index git already maintains turns the hash into a no-op for every unchanged
41
+ * path, and the `add -A` afterwards only has the worktree diff left to do.
42
+ *
43
+ * A repository with no index yet (a fresh `git init`) is not an error: there is nothing to
44
+ * copy, and the add builds it from empty exactly as before.
45
+ *
46
+ * @param {string} root
47
+ * @param {string} target the private index file to populate
48
+ * @returns {Promise<string|null>} the source index, or null when there is none
49
+ */
50
+ export async function seedIndex(root, target) {
51
+ try {
52
+ const stdout = await gitText(root, ['rev-parse', '--git-path', 'index']);
53
+ const from = resolve(root, stdout ?? '');
54
+ await copyFile(from, target);
55
+ return from;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
36
61
  /**
37
62
  * The tree of everything in the worktree, without staging any of it.
38
63
  *
@@ -44,19 +69,46 @@ export async function treeId(root) {
44
69
  const index = join(dir, 'index');
45
70
  const env = { ...process.env, GIT_INDEX_FILE: index };
46
71
  try {
72
+ // Seed before the add: the add then only hashes what actually changed (Q75).
73
+ await seedIndex(root, index);
47
74
  // Machine-local state is a record of the tree, so it cannot be part of the
48
75
  // tree it names. `git add -A` would take it once the file exists, and the
49
76
  // line would prove a tree that stopped existing when the line was written
50
77
  // (review 0003 Q19).
51
- const excludes = TREE_EXCLUDES.map((p) => `:(exclude)${p}`);
52
- await exec('git', ['add', '-A', '--', '.', ...excludes], { cwd: root, env });
53
- const { stdout } = await exec('git', ['write-tree'], { cwd: root, env });
54
- return stdout.trim();
78
+ //
79
+ // Where .gitignore already keeps a path out of the index, the pathspec
80
+ // would name an ignored path, and `git add` refuses it ("use -f") - and -f
81
+ // would force the very state back into the tree. The excludes therefore
82
+ // carry only the paths git does not already ignore.
83
+ const ignored = await ignoredSet(root, TREE_EXCLUDES);
84
+ const excludes = TREE_EXCLUDES.filter((p) => !ignored.has(p)).map((p) => `:(exclude)${p}`);
85
+ await git(root, ['add', '-A', '--', '.', ...excludes], { env });
86
+ return await gitText(root, ['write-tree'], { env });
55
87
  } finally {
56
88
  await rm(dir, { recursive: true, force: true });
57
89
  }
58
90
  }
59
91
 
92
+ /**
93
+ * The paths git already ignores, so the exclude pathspecs do not name them.
94
+ * A repository with no .gitignore ignores nothing, and every exclude ships
95
+ * exactly as before.
96
+ *
97
+ * @param {string} root
98
+ * @param {string[]} paths
99
+ * @returns {Promise<Set<string>>}
100
+ */
101
+ async function ignoredSet(root, paths) {
102
+ try {
103
+ const stdout = await git(root, ['check-ignore', ...paths]);
104
+ return new Set(String(stdout).split('\n').filter(Boolean));
105
+ } catch {
106
+ // `check-ignore` exits 1 when nothing is ignored - the common case, not an
107
+ // error. Failing to the full exclude list is the pre-filter behaviour.
108
+ return new Set();
109
+ }
110
+ }
111
+
60
112
  /**
61
113
  * Append one run. The output itself is not stored: the digest is enough to notice a
62
114
  * change, and a ledger that kept logs would keep whatever the command printed.
@@ -66,8 +118,7 @@ export async function treeId(root) {
66
118
  * Null when git cannot answer; the caller then falls back to the worktree tree. */
67
119
  export async function indexTreeId(root) {
68
120
  try {
69
- const { stdout } = await exec('git', ['write-tree'], { cwd: root });
70
- const tree = String(stdout ?? '').trim();
121
+ const tree = await gitText(root, ['write-tree']);
71
122
  return /^[0-9a-f]{40}$/.test(tree) ? tree : null;
72
123
  } catch {
73
124
  return null;
@@ -87,8 +138,10 @@ export async function recordRun(root, { cmd, exit, ms, output, gate = null, kind
87
138
  ...(typeof gate === 'string' ? { gate } : {}),
88
139
  ...(typeof kind === 'string' ? { kind } : {}),
89
140
  cmd,
141
+ command: cmd,
90
142
  exit,
91
143
  ms,
144
+ wallMs: ms,
92
145
  outputDigest: `sha256-${createHash('sha256').update(output).digest('hex')}`,
93
146
  summary: summarise(output, exit),
94
147
  ...(exit !== 0
@@ -100,6 +153,14 @@ export async function recordRun(root, { cmd, exit, ms, output, gate = null, kind
100
153
  };
101
154
  await mkdir(join(root, '.simt', 'local'), { recursive: true });
102
155
  await appendFile(join(root, LEDGER_FILE), `${JSON.stringify(line)}\n`);
156
+ // The same record in the log every other store writes to (Q73): a tailing consumer has
157
+ // one file to follow, and a ledger line lost to rotation is still recoverable here.
158
+ await appendEvent(root, {
159
+ kind: 'gate.finished',
160
+ tree: worktreeTree,
161
+ subject: typeof cmd === 'string' ? cmd : null,
162
+ data: { ...(gate === null ? {} : { gate }), ...(kind === null ? {} : { kind }), line },
163
+ });
103
164
 
104
165
  if (exit === 0 && typeof cmd === 'string') {
105
166
  let pendingFail = null;
@@ -116,9 +177,84 @@ export async function recordRun(root, { cmd, exit, ms, output, gate = null, kind
116
177
  }
117
178
  }
118
179
 
180
+ await rotateLedger(root).catch(() => {});
119
181
  return line;
120
182
  }
121
183
 
184
+ /**
185
+ * Read only the end of the ledger.
186
+ *
187
+ * `recordRun` and every evidence check read the whole file, and the file grows without a
188
+ * bound: a repository that runs a suite a hundred times a day pays for a year of lines on
189
+ * every commit (Q75). A tail read answers the only question those callers ask - what
190
+ * happened recently on which trees - in bounded time.
191
+ *
192
+ * The first line of the slice is dropped unless the slice starts at byte 0, because a byte
193
+ * offset lands mid-line and half a JSON object is not evidence.
194
+ */
195
+ export async function readLedgerTail(root, { entries = 200, bytes = 128 * 1024 } = {}) {
196
+ const path = join(root, LEDGER_FILE);
197
+ let text = '';
198
+ let partial = false;
199
+ try {
200
+ const st = await stat(path);
201
+ const start = Math.max(0, st.size - bytes);
202
+ partial = start > 0;
203
+ const handle = await open(path, 'r');
204
+ try {
205
+ const buffer = Buffer.alloc(st.size - start);
206
+ await handle.read(buffer, 0, buffer.length, start);
207
+ text = buffer.toString('utf8');
208
+ } finally {
209
+ await handle.close();
210
+ }
211
+ } catch (error) {
212
+ if (error.code !== 'ENOENT') throw error;
213
+ return { entries: [], errors: [], partial: false };
214
+ }
215
+ const lines = text.split('\n').filter((line) => line.trim() !== '');
216
+ if (partial && lines.length > 0) lines.shift();
217
+ const parsed = [];
218
+ const errors = [];
219
+ for (const raw of lines) {
220
+ try {
221
+ const line = JSON.parse(raw);
222
+ if (typeof line.tree === 'string' && typeof line.exit === 'number') parsed.push(line);
223
+ else errors.push('a line has no tree or exit');
224
+ } catch (error) {
225
+ errors.push(`a line does not parse (${error.message})`);
226
+ }
227
+ }
228
+ return { entries: parsed.slice(-entries), errors, partial };
229
+ }
230
+
231
+ /**
232
+ * Keep the ledger bounded: past `maxLines`, the older half moves to `<ledger>.1`.
233
+ *
234
+ * Rotation rather than deletion because the ledger is evidence: an archived line is still
235
+ * readable, and the event log holds the same record independently (Q73). Keeping half
236
+ * rather than `maxLines` means rotation happens every `maxLines/2` appends instead of on
237
+ * every one.
238
+ */
239
+ export async function rotateLedger(root, { maxLines = 5000 } = {}) {
240
+ const path = join(root, LEDGER_FILE);
241
+ let text;
242
+ try {
243
+ text = await readFile(path, 'utf8');
244
+ } catch (error) {
245
+ if (error.code === 'ENOENT') return { rotated: false };
246
+ throw error;
247
+ }
248
+ const lines = text.split('\n').filter((line) => line.trim() !== '');
249
+ if (lines.length <= maxLines) return { rotated: false };
250
+ const keep = Math.max(1, Math.floor(maxLines / 2));
251
+ const archived = lines.slice(0, lines.length - keep);
252
+ const kept = lines.slice(lines.length - keep);
253
+ await writeFile(join(root, `${LEDGER_FILE}.1`), `${archived.join('\n')}\n`, 'utf8');
254
+ await writeFile(path, `${kept.join('\n')}\n`, 'utf8');
255
+ return { rotated: true, archived: archived.length, kept: kept.length };
256
+ }
257
+
122
258
  /** Every well-formed line. A line that does not parse is returned as an error so a
123
259
  * damaged ledger cannot look empty. */
124
260
  export async function readLedger(root) {
@@ -18,14 +18,12 @@ function guardEndpoint(endpoint, { allowRemote = false } = {}) {
18
18
  if (!endpoint || typeof endpoint !== 'object') {
19
19
  return { ok: false, error: 'local model endpoint is not configured', refusedRemote: false };
20
20
  }
21
+ // `url` and `model` are validated once, where the config is read
22
+ // (`local/config.mjs`): an endpoint that reaches this function has both. What is left
23
+ // here is the one rule the config cannot enforce - a loopback-only default - and that
24
+ // is why this function still exists (review 0003 Q83).
21
25
  const rawUrl = typeof endpoint.url === 'string' ? endpoint.url.trim() : '';
22
- if (!rawUrl) {
23
- return { ok: false, error: 'local model endpoint is missing `url`', refusedRemote: false };
24
- }
25
26
  const model = typeof endpoint.model === 'string' ? endpoint.model.trim() : '';
26
- if (!model) {
27
- return { ok: false, error: 'local model endpoint is missing `model`', refusedRemote: false };
28
- }
29
27
  const apiKey = typeof endpoint.apiKey === 'string' && endpoint.apiKey.length > 0 ? endpoint.apiKey : null;
30
28
  const loopback = isLoopbackUrl(rawUrl);
31
29
  if (!loopback && !allowRemote) {
package/src/locate.mjs CHANGED
@@ -35,23 +35,47 @@ export function renderLocator(command, { subject = CLI, remedy = `REMEDY run: np
35
35
  const D = '$';
36
36
  return `# ${D}${BIN_ENV} must name ONE program: an executable on PATH, or a .mjs entry
37
37
  # point to hand to node. It cannot be a command line, because there is no safe way
38
- # to split one in sh without eval, and a value with a space is refused rather than
39
- # half-executed - running from a source checkout is normal for anyone working on
40
- # this project itself, so this path has to work with no install step.
38
+ # to split one in sh without eval, and a value with a space that names no file is
39
+ # refused rather than half-executed - running from a source checkout is normal for
40
+ # anyone working on this project itself, so this path has to work with no install step.
41
+ #
42
+ # Every .mjs entry point goes through run_node, which checks for node first: a GUI
43
+ # client's PATH often carries none (nvm is sourced only in an interactive shell), and
44
+ # exec node there fails with 127 and no explanation of what to install.
45
+ run_node() {
46
+ if ! command -v node >/dev/null 2>&1; then
47
+ echo "${CLI}: cannot find node to run ${D}1." >&2
48
+ echo " the hook runs a .mjs entry point through node; install Node >= 22.20.0," >&2
49
+ echo " or set ${BIN_ENV} to an executable ${CLI}" >&2
50
+ exit 1
51
+ fi
52
+ entry="${D}1"
53
+ shift
54
+ exec node "${D}entry" "${D}@"
55
+ }
56
+
41
57
  run_simt_dev() {
42
58
  case "${D}1" in
43
59
  *' '*)
60
+ # A space is ambiguous as text and unambiguous as a file: a value that names
61
+ # something on disk is a path in a directory with a space, and runs as one.
62
+ if [ -f "${D}1" ]; then
63
+ case "${D}1" in
64
+ *.mjs)
65
+ run_node "${D}@"
66
+ ;;
67
+ *)
68
+ exec "${D}@"
69
+ ;;
70
+ esac
71
+ fi
44
72
  echo "${CLI}: ${D}${BIN_ENV} names a command line, not a program." >&2
45
73
  echo " set it to a single executable or a .mjs path, e.g." >&2
46
74
  echo " ${BIN_ENV}=/path/to/checkout/${BIN_REL}" >&2
47
75
  exit 1
48
76
  ;;
49
77
  *.mjs)
50
- # shift first: "${D}@" still holds ${D}1, which would pass the program path to
51
- # itself as the first argument.
52
- program="${D}1"
53
- shift
54
- exec node "${D}program" "${D}@"
78
+ run_node "${D}@"
55
79
  ;;
56
80
  *)
57
81
  exec "${D}1" "${D}@"
@@ -64,7 +88,7 @@ if [ -n "${D}{${BIN_ENV}:-}" ]; then
64
88
  fi
65
89
 
66
90
  if [ -f "${D}ROOT/node_modules/${PACKAGE_NAME}/${BIN_REL}" ]; then
67
- exec node "${D}ROOT/node_modules/${PACKAGE_NAME}/${BIN_REL}" ${command}
91
+ run_node "${D}ROOT/node_modules/${PACKAGE_NAME}/${BIN_REL}" ${command}
68
92
  fi
69
93
 
70
94
  # The binary this repository downloaded for itself: the pinned bytes, no node.
@@ -0,0 +1,213 @@
1
+ // The manifest's own schema: one table of the keys this file may carry, and one
2
+ // loader that says what is wrong with the file rather than throwing or guessing.
3
+ //
4
+ // The manifest was read in 29 places, each doing a bare `JSON.parse` and then
5
+ // reaching for the key it wanted. That is three answers to "what is this repo" -
6
+ // the parse, the key access, and whatever default the caller invented - and a
7
+ // manifest with `"mode": "kernal"` reached the projection as a mode it had never
8
+ // heard of. A typo in one key silently changed behaviour, because nothing in the
9
+ // tool had ever said what the keys are (review 0003 Q69).
10
+ //
11
+ // Every shape below was read off the manifest this repository carries, not from
12
+ // memory: the first draft of this table called `limits.fileLines` a positive integer
13
+ // and `surfaces` an object, and both are wrong in the only file the tool has to hand.
14
+ // `test/manifest-schema.test.mjs` holds the table to that file, which is the check
15
+ // that would have caught it.
16
+ //
17
+ // `owner` is the reason a key is in this table at all. A `tool` key is written by
18
+ // this binary and a repository editing it is fighting the writer; a `repo` key is
19
+ // the repository's own declaration and this tool only reads it. The distinction is
20
+ // already load-bearing (`GENERATED_KEYS`, `init` keeping `limits`), and stating it
21
+ // here is what keeps the two from drifting apart.
22
+
23
+ import { readFile } from 'node:fs/promises';
24
+ import { existsSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
27
+
28
+ /** Absent means 1: the field arrived after the file did, and every file predates it. */
29
+ const MANIFEST_VERSION = 1;
30
+
31
+ /**
32
+ * The manifest a repository has, which is not necessarily the one this tool would write.
33
+ *
34
+ * The file was renamed from `simt-dev.json` to `simt.json`. Every read site used to say
35
+ * `join(root, MANIFEST_FILE)` and would have reported an adopted repository as unadopted the
36
+ * day the constant moved - and "no manifest" means "never adopted", which is the answer
37
+ * that offers to re-adopt a repo that is already done. Fourteen repositories carry the
38
+ * old name on disk.
39
+ *
40
+ * So the old name is read as a fallback and the new one is written. `writeManifest`
41
+ * renames rather than creating a second file, which is how the window closes: the first
42
+ * `simt init` or `simt sync` in an adopted repo retires the old name by moving it.
43
+ */
44
+ export async function manifestPath(root) {
45
+ const next = join(root, MANIFEST_FILE);
46
+ if (existsSync(next)) return next;
47
+ const previous = join(root, MANIFEST_LEGACY);
48
+ return existsSync(previous) ? previous : next;
49
+ }
50
+
51
+
52
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
53
+ const isStringList = (value) => Array.isArray(value) && value.every((item) => typeof item === 'string');
54
+ const isPositiveInt = (value) => Number.isInteger(value) && value > 0;
55
+
56
+ /**
57
+ * The keys a manifest may carry.
58
+ *
59
+ * `check` returns a sentence naming what is wrong, or null. It returns a sentence
60
+ * rather than a boolean because the reader of a problem is a person looking at a file
61
+ * they edited by hand, and "invalid" tells them nothing they did not know.
62
+ */
63
+ export const KEYS = [
64
+ { key: '$comment', owner: 'tool', generated: true, since: '0.7.0', check: (v) => (typeof v === 'string' || isStringList(v) ? null : '$comment must be a string or a list of strings') },
65
+ { key: 'id', owner: 'tool', since: '0.7.0', check: (v) => (typeof v === 'string' && v.length > 0 ? null : 'id must be a non-empty string') },
66
+ { key: 'version', owner: 'tool', since: '0.9.0', check: (v) => (Number.isInteger(v) && v >= 1 ? null : 'version must be an integer of at least 1') },
67
+ { key: 'stack', owner: 'tool', since: '0.7.0', check: (v) => (isStringList(v) ? null : 'stack must be a list of profile ids') },
68
+ { key: 'limits', owner: 'repo', since: '0.7.0', check: (v) => (isObject(v) ? null : 'limits must be an object') },
69
+ // {warn, error, excludeTextFiles, exclude} - ADR-005's two tiers plus the paths a
70
+ // repository opts out of, all of which the repository owns and `init` keeps.
71
+ { key: 'limits.fileLines', owner: 'repo', since: '0.7.0', check: (v) => (isObject(v) && isPositiveInt(v.warn) && isPositiveInt(v.error) && v.error >= v.warn ? null : 'limits.fileLines must be {warn, error} with error >= warn') },
72
+ { key: 'limits.coverage', owner: 'repo', since: '0.7.0', check: (v) => (v === null || (typeof v === 'number' && v >= 0 && v <= 100) ? null : 'limits.coverage must be null or a percentage') },
73
+ { key: 'gates', owner: 'repo', since: '0.7.0', check: (v) => (isObject(v) ? null : 'gates must be an object of gate name to command') },
74
+ { key: 'localSkills', owner: 'repo', since: '0.7.0', check: (v) => (isStringList(v) ? null : 'localSkills must be a list of skill ids') },
75
+ { key: 'profiles', owner: 'repo', since: '0.8.0', check: (v) => (isStringList(v) ? null : 'profiles must be a list of profile ids') },
76
+ { key: 'substitutions', owner: 'repo', since: '0.8.0', check: (v) => (isObject(v) ? null : 'substitutions must be an object keyed by rule id') },
77
+ { key: 'packs', owner: 'repo', since: '0.8.0', check: (v) => (Array.isArray(v) ? null : 'packs must be a list') },
78
+ // A workspace declaration is a path relative to the repository; `workspace.mjs`
79
+ // refuses to walk one that resolves outside, so the type is the whole check.
80
+ { key: 'workspace', owner: 'repo', since: '0.7.0', check: (v) => (typeof v === 'string' ? null : 'workspace must be a path string') },
81
+ { key: 'decisions', owner: 'repo', since: '0.8.0', check: (v) => (isObject(v) ? null : 'decisions must be an object') },
82
+ { key: 'budget', owner: 'repo', since: '0.8.0', check: (v) => (isObject(v) ? null : 'budget must be an object') },
83
+ { key: 'budget.bytes', owner: 'repo', since: '0.8.0', check: (v) => (isPositiveInt(v) ? null : 'budget.bytes must be a positive integer') },
84
+ { key: 'mode', owner: 'repo', since: '0.8.0', check: (v) => (v === 'full' || v === 'kernel' ? null : 'mode must be "full" or "kernel", not ' + JSON.stringify(v)) },
85
+ { key: 'shared', owner: 'repo', since: '0.8.0', check: (v) => (isObject(v) ? null : 'shared must be an object') },
86
+ // The overlay `plan/template.mjs` compiles over the default tracker body; an array
87
+ // would be accepted by an object-typed read and then produce an empty template.
88
+ { key: 'plans', owner: 'repo', since: '0.7.0', check: (v) => (isObject(v) ? null : 'plans must be an object overlay') },
89
+ // Null and absent both mean "the global standard applies" (manifestDrift's own
90
+ // rule), and a list is what `init` writes. An object here is a shape nothing has
91
+ // ever produced, so accepting it would be the validator blessing a typo.
92
+ { key: 'surfaces', owner: 'repo', since: '0.8.0', check: (v) => (v === null || isStringList(v) ? null : 'surfaces must be null or a list of surface ids') },
93
+ { key: 'projection', owner: 'tool', generated: true, since: '0.7.0', check: (v) => (isObject(v) ? null : 'projection must be an object') },
94
+ ];
95
+
96
+ const KNOWABLE = new Set(KEYS.map((entry) => entry.key));
97
+
98
+ /** The keys this tool writes, derived from the table: the `generated` marks above. */
99
+ export const GENERATED_KEYS_OF = new Set(KEYS.filter((entry) => entry.generated === true).map((entry) => entry.key));
100
+
101
+
102
+ /**
103
+ * Namespaces a consumer registered against this table.
104
+ *
105
+ * A namespace is a sub-object of the manifest that another part of the tool owns -
106
+ * plan 0004's `work` is the first. It registers its keys here rather than editing the
107
+ * table, so adding one touches two files and no reader has to learn a second shape
108
+ * (review 0003 Q69).
109
+ */
110
+ const NAMESPACES = new Map();
111
+
112
+ /**
113
+ * Declare the keys of one namespace. Registered keys are checked exactly like top-level
114
+ * ones; a namespace nobody registered is left alone, because an unknown key has never
115
+ * been an error in this file.
116
+ *
117
+ * @param {string} name the manifest key that carries the namespace
118
+ * @param {{key: string, since?: string, check: (value: unknown) => string|null}[]} keys
119
+ * @returns {number} how many keys the namespace declares
120
+ */
121
+ export function registerNamespace(name, keys) {
122
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) {
123
+ throw new Error('namespace "' + name + '" must be lower-case words joined by hyphens');
124
+ }
125
+ if (KNOWABLE.has(name)) throw new Error('namespace "' + name + '" is already a manifest key');
126
+ if (NAMESPACES.has(name)) throw new Error('namespace "' + name + '" is already registered');
127
+ if (!Array.isArray(keys) || keys.length === 0) {
128
+ throw new Error('namespace "' + name + '" must register at least one key');
129
+ }
130
+ for (const entry of keys) {
131
+ if (typeof entry?.key !== 'string' || !/^[a-z][a-z0-9]*$/.test(entry.key)) {
132
+ throw new Error('namespace "' + name + '" keys must be lower-case single words');
133
+ }
134
+ if (typeof entry.check !== 'function') {
135
+ throw new Error('namespace "' + name + '" key "' + entry.key + '" has no check');
136
+ }
137
+ }
138
+ NAMESPACES.set(name, keys.map((entry) => ({ owner: 'repo', since: '0.9.0', ...entry })));
139
+ return keys.length;
140
+ }
141
+
142
+ /** The namespaces a consumer declared, for a reader that needs the whole set. */
143
+ export function registeredNamespaces() {
144
+ return [...NAMESPACES.keys()].sort();
145
+ }
146
+
147
+ /**
148
+ * What is wrong with one parsed manifest.
149
+ *
150
+ * An unknown key is not a problem. A repository that keeps its own note in this file
151
+ * is doing the thing the file's own comment invites, and a validator that refused it
152
+ * would be the tool insisting its schema is the whole of a file it calls the
153
+ * repository's. Only a KNOWN key with an impossible value is reported.
154
+ */
155
+ export function manifestProblems(manifest) {
156
+ const problems = [];
157
+ for (const { key, check } of KEYS) {
158
+ const [head, tail] = key.includes('.') ? key.split('.') : [key, null];
159
+ if (!(head in manifest)) continue;
160
+ if (tail !== null && (!isObject(manifest[head]) || !(tail in manifest[head]))) continue;
161
+ const value = tail === null ? manifest[head] : manifest[head][tail];
162
+ if (value === undefined) continue;
163
+ const problem = check(value);
164
+ if (problem !== null) problems.push(problem);
165
+ }
166
+ for (const [name, keys] of NAMESPACES) {
167
+ const value = manifest[name];
168
+ if (!isObject(value)) continue;
169
+ for (const entry of keys) {
170
+ if (!(entry.key in value) || value[entry.key] === undefined) continue;
171
+ const problem = entry.check(value[entry.key]);
172
+ if (problem !== null) problems.push(name + '.' + entry.key + ': ' + problem);
173
+ }
174
+ }
175
+ return problems;
176
+ }
177
+
178
+ /**
179
+ * The manifest a repository has, with its problems named.
180
+ *
181
+ * `manifest` is null when there is no file and when the file does not parse, because
182
+ * a caller that receives a half-read object cannot tell which it has - and the one
183
+ * caller that did (`fingerprint`) returned `{error}` in place of a manifest, so a
184
+ * broken file looked like a declaration of a key called `error` (review 0003 Q69).
185
+ * `exists` and `problems` are what tell the two apart, and neither is optional.
186
+ *
187
+ * @returns {Promise<{manifest: object|null, exists: boolean, version: number, problems: string[], path: string}>}
188
+ */
189
+ export async function loadManifest(root) {
190
+ const path = await manifestPath(root);
191
+ let text;
192
+ try {
193
+ text = await readFile(path, 'utf8');
194
+ } catch {
195
+ return { manifest: null, exists: false, version: MANIFEST_VERSION, problems: [], path };
196
+ }
197
+ let parsed;
198
+ try {
199
+ parsed = JSON.parse(text);
200
+ } catch (error) {
201
+ return { manifest: null, exists: true, version: MANIFEST_VERSION, problems: [`unparseable: ${error.message}`], path };
202
+ }
203
+ if (!isObject(parsed)) {
204
+ return { manifest: null, exists: true, version: MANIFEST_VERSION, problems: ['not a JSON object'], path };
205
+ }
206
+ return {
207
+ manifest: parsed,
208
+ exists: true,
209
+ version: Number.isInteger(parsed.version) ? parsed.version : MANIFEST_VERSION,
210
+ problems: manifestProblems(parsed),
211
+ path,
212
+ };
213
+ }
@@ -0,0 +1,33 @@
1
+ // One page of a list result, and the cursor that asks for the next one.
2
+ //
3
+ // MCP lets a server answer a list method in pages, and a client that ignores `nextCursor`
4
+ // must still see a usable first page. This server has never paginated, which was fine
5
+ // while every list fit in one reply and stops being fine the moment a workspace brings
6
+ // dozens of repositories into `resources/list`: a client that renders what it is given
7
+ // then silently shows a subset of the estate (review 0003 Q66).
8
+ //
9
+ // The cursor is the offset, rendered as a string. The specification calls it opaque, and
10
+ // the client must not parse it, but it has to be stable across calls: an index into a list
11
+ // that is rebuilt in a deterministic order is, and a hash of the last item's name is not -
12
+ // two items can share one.
13
+
14
+ /** How many entries one reply carries. */
15
+ export const PAGE_SIZE = 50;
16
+
17
+ /**
18
+ * The page a cursor asks for, or `null` when the cursor is not one this server issued.
19
+ *
20
+ * A cursor outside the list is not clamped to the last page: a client that walked past the
21
+ * end has a stale cursor, and silently repeating the tail hides that the list changed
22
+ * under it.
23
+ */
24
+ export function paginate(items, cursor, size = PAGE_SIZE) {
25
+ const text = cursor === undefined || cursor === null || cursor === '' ? '0' : String(cursor);
26
+ // Only the shape this server writes: a decimal offset. `Number` accepts '0x10' and ' ',
27
+ // and accepting a cursor nobody issued is how pagination silently repeats or skips.
28
+ if (!/^\d+$/.test(text)) return null;
29
+ const start = Number(text);
30
+ const page = items.slice(start, start + size);
31
+ const next = start + size < items.length ? String(start + size) : null;
32
+ return { items: page, nextCursor: next };
33
+ }
@@ -199,15 +199,9 @@ export async function readResource(uri, root = process.cwd()) {
199
199
  const [rules, mdc] = await Promise.all([loadRules(null), loadMdcRules()]);
200
200
  const rule = [...rules, ...mdc].find((candidate) => candidate.id === parsed.id);
201
201
  if (rule === undefined) return null;
202
- const text = `${frontmatterOf(rule)}\n${rule.body ?? ''}`.trim();
203
- return {
204
- uri,
205
- mimeType: 'text/markdown',
206
- text,
207
- // The body a tool returns is redacted for the same reason: a reader outside this
208
- // estate is told what the rule says and not which directory here it is filed in.
209
- bytes: Buffer.byteLength(text),
210
- };
202
+ // The body a tool returns is redacted for the same reason: a reader outside this
203
+ // estate is told what the rule says and not which directory here it is filed in.
204
+ return { uri, mimeType: 'text/markdown', text: `${frontmatterOf(rule)}\n${rule.body ?? ''}`.trim() };
211
205
  }
212
206
 
213
207
  // `loadDecisions` reads the index, not the records: it reports status, currency and
@@ -218,7 +212,10 @@ export async function readResource(uri, root = process.cwd()) {
218
212
  if (decision === undefined) return null;
219
213
  const raw = await readFile(join(DECISIONS_DIR, decision.file), 'utf8');
220
214
  const text = redactInternalPaths(redactEstate(raw)).trim();
221
- return { uri, mimeType: 'text/markdown', text, bytes: Buffer.byteLength(text) };
215
+ // `bytes` is not a `ResourceContents` field: a client validating the result against
216
+ // the specification sees an unexpected property, and the length is `text.length` away
217
+ // (Q56).
218
+ return { uri, mimeType: 'text/markdown', text };
222
219
  }
223
220
 
224
221
  function frontmatterOf(rule) {