simt-dev 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/README.md +169 -80
  3. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  4. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
  5. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
  6. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
  7. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
  8. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  9. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
  10. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
  11. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
  12. package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
  13. package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
  14. package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
  15. package/ecosystems/bun.json +6 -0
  16. package/ecosystems/cargo.json +25 -0
  17. package/ecosystems/composer.json +6 -0
  18. package/ecosystems/dart.json +24 -0
  19. package/ecosystems/deno.json +21 -0
  20. package/ecosystems/devcontainers.json +6 -0
  21. package/ecosystems/docker-compose.json +6 -0
  22. package/ecosystems/docker.json +6 -0
  23. package/ecosystems/dotnet.json +25 -0
  24. package/ecosystems/elixir.json +23 -0
  25. package/ecosystems/gitsubmodule.json +6 -0
  26. package/ecosystems/go.json +25 -0
  27. package/ecosystems/gradle.json +13 -0
  28. package/ecosystems/maven.json +23 -0
  29. package/ecosystems/node.json +25 -0
  30. package/ecosystems/python.json +20 -0
  31. package/ecosystems/ruby.json +23 -0
  32. package/ecosystems/swift.json +25 -0
  33. package/ecosystems/terraform.json +6 -0
  34. package/ecosystems/uv.json +6 -0
  35. package/harnesses/antigravity.json +162 -0
  36. package/harnesses/claude.json +206 -0
  37. package/harnesses/codex.json +136 -0
  38. package/harnesses/copilot.json +88 -0
  39. package/harnesses/cursor.json +140 -0
  40. package/harnesses/deepseek.json +99 -0
  41. package/harnesses/gemini.json +122 -0
  42. package/harnesses/opencode.json +173 -0
  43. package/package.json +3 -1
  44. package/rules/mdc/intake-questions.mdc +8 -0
  45. package/src/advice/advice.mjs +3 -2
  46. package/src/agenthooks/render.mjs +86 -180
  47. package/src/agents/render.mjs +15 -18
  48. package/src/api.mjs +7 -5
  49. package/src/argv.mjs +19 -0
  50. package/src/bench/ablate.mjs +3 -4
  51. package/src/bench/adapters.json +155 -113
  52. package/src/bench/adapters.mjs +466 -0
  53. package/src/bench/arms.mjs +333 -0
  54. package/src/bench/grade.mjs +12 -26
  55. package/src/bench/graders.mjs +42 -0
  56. package/src/bench/mine.mjs +17 -535
  57. package/src/bench/probe-ledger.mjs +149 -0
  58. package/src/bench/probes.catalog.mjs +918 -0
  59. package/src/bench/probes.mjs +15 -1037
  60. package/src/bench/render.mjs +111 -0
  61. package/src/bench/report.mjs +116 -971
  62. package/src/bench/run.mjs +164 -881
  63. package/src/bench/variants.mjs +4 -5
  64. package/src/bench/workspace.mjs +160 -0
  65. package/src/brief/assemble.mjs +11 -13
  66. package/src/ci/dependabot.mjs +25 -55
  67. package/src/commands/agent.mjs +1 -9
  68. package/src/commands/agenthook.mjs +66 -35
  69. package/src/commands/bench.mjs +2 -2
  70. package/src/commands/ci.mjs +11 -13
  71. package/src/commands/docs.mjs +88 -0
  72. package/src/commands/gate.mjs +10 -13
  73. package/src/commands/harness.mjs +3 -3
  74. package/src/commands/help.mjs +44 -3
  75. package/src/commands/hooks.mjs +20 -20
  76. package/src/commands/ignore.mjs +11 -7
  77. package/src/commands/impact.mjs +4 -8
  78. package/src/commands/index.mjs +49 -9
  79. package/src/commands/init-scan.mjs +90 -0
  80. package/src/commands/init.mjs +122 -104
  81. package/src/commands/lease.mjs +67 -18
  82. package/src/commands/map.mjs +10 -5
  83. package/src/commands/mcp.mjs +2 -17
  84. package/src/commands/repoindex.mjs +8 -8
  85. package/src/commands/rules.mjs +8 -4
  86. package/src/commands/shared.mjs +12 -16
  87. package/src/commands/skills.mjs +5 -6
  88. package/src/commands/skillscompile.mjs +3 -3
  89. package/src/commands/skillsinstall.mjs +5 -4
  90. package/src/commands/slice.mjs +199 -64
  91. package/src/commands/spec.mjs +78 -62
  92. package/src/commands/status.mjs +24 -12
  93. package/src/commands/sync.mjs +14 -14
  94. package/src/commands/tests.mjs +12 -12
  95. package/src/commands/tokens.mjs +19 -24
  96. package/src/commands/upgrade.mjs +8 -4
  97. package/src/commands/why.mjs +6 -13
  98. package/src/compare/hooksstate.mjs +2 -6
  99. package/src/compare/learn.mjs +3 -1
  100. package/src/decisions/index.mjs +3 -2
  101. package/src/docs/commands.mjs +65 -0
  102. package/src/ecosystem/records.mjs +219 -0
  103. package/src/explain.mjs +31 -0
  104. package/src/gates/index.mjs +385 -0
  105. package/src/graph/compile.mjs +8 -4
  106. package/src/graph/load.mjs +2 -1
  107. package/src/graph/spec.mjs +10 -8
  108. package/src/harness/records.mjs +143 -0
  109. package/src/hooks/capability.mjs +5 -3
  110. package/src/hooks/dispatcher.mjs +26 -32
  111. package/src/hooks/gates.mjs +12 -2
  112. package/src/hooks/resolve.mjs +2 -7
  113. package/src/hooks/where.mjs +2 -7
  114. package/src/identity.mjs +2 -11
  115. package/src/index/coverage.mjs +58 -0
  116. package/src/index/extract.mjs +6 -16
  117. package/src/index/gate.mjs +1 -1
  118. package/src/index/govern.mjs +26 -9
  119. package/src/index/impact.mjs +4 -8
  120. package/src/index/map.mjs +14 -15
  121. package/src/index/open.mjs +173 -0
  122. package/src/index/persist.mjs +224 -57
  123. package/src/index/store.mjs +82 -39
  124. package/src/index/tests.mjs +2 -6
  125. package/src/journal.mjs +16 -11
  126. package/src/kernel/card.mjs +37 -63
  127. package/src/kernel/checks.mjs +4 -10
  128. package/src/lease.mjs +102 -7
  129. package/src/ledger/index.mjs +124 -17
  130. package/src/mcp/paginate.mjs +33 -0
  131. package/src/mcp/schemas.mjs +512 -0
  132. package/src/mcp/server.mjs +49 -9
  133. package/src/mcp/tools-facts.mjs +326 -0
  134. package/src/mcp/tools-read.mjs +221 -0
  135. package/src/mcp/tools.mjs +4 -1005
  136. package/src/{bench → measure}/corpus.mjs +1 -1
  137. package/src/measure/metrics.mjs +236 -0
  138. package/src/measure/records.mjs +211 -0
  139. package/src/measure/stats.mjs +538 -0
  140. package/src/measure/task-schema.mjs +125 -0
  141. package/src/measure/tasks.mjs +69 -0
  142. package/src/memory/decide.mjs +6 -15
  143. package/src/memory/lesson-text.mjs +61 -0
  144. package/src/memory/lessons.mjs +29 -82
  145. package/src/name.mjs +7 -0
  146. package/src/output/index.mjs +122 -31
  147. package/src/packs/manage.mjs +15 -9
  148. package/src/plan/index.mjs +1 -1
  149. package/src/plugins.mjs +22 -16
  150. package/src/prbody.mjs +7 -11
  151. package/src/projection/loaders.mjs +7 -692
  152. package/src/projection/manifest.mjs +10 -435
  153. package/src/projection/mdc.mjs +7 -8
  154. package/src/projection/project.mjs +126 -221
  155. package/src/projection/region.mjs +561 -0
  156. package/src/projection/rules.mjs +16 -547
  157. package/src/projection/surfaces.mjs +43 -210
  158. package/src/projection/targets.mjs +5 -5
  159. package/src/repo-root.mjs +13 -1
  160. package/src/resolve/eval.mjs +2 -1
  161. package/src/resolve/fingerprint.mjs +0 -7
  162. package/src/resolve/resolve.mjs +1 -41
  163. package/src/review/critic.mjs +8 -12
  164. package/src/run.mjs +98 -0
  165. package/src/secrets.mjs +14 -22
  166. package/src/state/events.mjs +129 -0
  167. package/src/status-snapshot.mjs +233 -0
  168. package/src/status.mjs +2 -6
  169. package/src/sys/git.mjs +82 -0
  170. package/src/{bench/process.mjs → sys/proc.mjs} +28 -5
  171. package/src/testing/parsers.mjs +265 -0
  172. package/src/testing/patches.mjs +23 -0
  173. package/src/testing/paths.mjs +49 -0
  174. package/src/testing/runners.mjs +186 -0
  175. package/src/testing/vault.mjs +68 -0
  176. package/src/text/frontmatter.mjs +49 -0
  177. package/src/text/tokens.mjs +52 -0
  178. package/src/toolwords.mjs +1 -1
  179. package/src/util/fs.mjs +20 -0
  180. package/src/util/sort.mjs +12 -0
  181. package/src/workspace.mjs +3 -1
  182. package/src/write/plan.mjs +82 -0
  183. package/templates/plan/tracker.json +1 -0
  184. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
  185. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
  186. package/src/bench/tasks.mjs +0 -36
@@ -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,6 +69,8 @@ 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
@@ -55,9 +82,8 @@ export async function treeId(root) {
55
82
  // carry only the paths git does not already ignore.
56
83
  const ignored = await ignoredSet(root, TREE_EXCLUDES);
57
84
  const excludes = TREE_EXCLUDES.filter((p) => !ignored.has(p)).map((p) => `:(exclude)${p}`);
58
- await exec('git', ['add', '-A', '--', '.', ...excludes], { cwd: root, env });
59
- const { stdout } = await exec('git', ['write-tree'], { cwd: root, env });
60
- return stdout.trim();
85
+ await git(root, ['add', '-A', '--', '.', ...excludes], { env });
86
+ return await gitText(root, ['write-tree'], { env });
61
87
  } finally {
62
88
  await rm(dir, { recursive: true, force: true });
63
89
  }
@@ -74,10 +100,7 @@ export async function treeId(root) {
74
100
  */
75
101
  async function ignoredSet(root, paths) {
76
102
  try {
77
- // Paths as argv, not `--stdin`: `promisify(execFile)` drops the `input`
78
- // option, so a stdin-fed `check-ignore` waits for input that never comes
79
- // and the promise never settles.
80
- const { stdout } = await exec('git', ['check-ignore', ...paths], { cwd: root });
103
+ const stdout = await git(root, ['check-ignore', ...paths]);
81
104
  return new Set(String(stdout).split('\n').filter(Boolean));
82
105
  } catch {
83
106
  // `check-ignore` exits 1 when nothing is ignored - the common case, not an
@@ -95,8 +118,7 @@ async function ignoredSet(root, paths) {
95
118
  * Null when git cannot answer; the caller then falls back to the worktree tree. */
96
119
  export async function indexTreeId(root) {
97
120
  try {
98
- const { stdout } = await exec('git', ['write-tree'], { cwd: root });
99
- const tree = String(stdout ?? '').trim();
121
+ const tree = await gitText(root, ['write-tree']);
100
122
  return /^[0-9a-f]{40}$/.test(tree) ? tree : null;
101
123
  } catch {
102
124
  return null;
@@ -116,8 +138,10 @@ export async function recordRun(root, { cmd, exit, ms, output, gate = null, kind
116
138
  ...(typeof gate === 'string' ? { gate } : {}),
117
139
  ...(typeof kind === 'string' ? { kind } : {}),
118
140
  cmd,
141
+ command: cmd,
119
142
  exit,
120
143
  ms,
144
+ wallMs: ms,
121
145
  outputDigest: `sha256-${createHash('sha256').update(output).digest('hex')}`,
122
146
  summary: summarise(output, exit),
123
147
  ...(exit !== 0
@@ -129,6 +153,14 @@ export async function recordRun(root, { cmd, exit, ms, output, gate = null, kind
129
153
  };
130
154
  await mkdir(join(root, '.simt', 'local'), { recursive: true });
131
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
+ });
132
164
 
133
165
  if (exit === 0 && typeof cmd === 'string') {
134
166
  let pendingFail = null;
@@ -145,9 +177,84 @@ export async function recordRun(root, { cmd, exit, ms, output, gate = null, kind
145
177
  }
146
178
  }
147
179
 
180
+ await rotateLedger(root).catch(() => {});
148
181
  return line;
149
182
  }
150
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
+
151
258
  /** Every well-formed line. A line that does not parse is returned as an error so a
152
259
  * damaged ledger cannot look empty. */
153
260
  export async function readLedger(root) {
@@ -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
+ }