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
package/src/run.mjs ADDED
@@ -0,0 +1,98 @@
1
+ // `run(command, input)` - one command, as a result (review 0003 Q63).
2
+ //
3
+ // The CLI prints and returns an exit code; that is right for a terminal and useless for a
4
+ // library, an MCP tool or a harness that wants the answer. Rather than rewrite seventy
5
+ // command modules to return values they do not currently compute, this runs the same
6
+ // handler through the output sink: the handler is unchanged, and what it would have
7
+ // written comes back as data.
8
+ //
9
+ // That is a boundary, not a second implementation. `dispatch` and `run` resolve the same
10
+ // registry, apply the same flag guard rails and call the same function, so a command
11
+ // cannot work in one and not the other - the failure would be invisible in the CLI, which
12
+ // is why `test/run.test.mjs` drives both surfaces.
13
+ //
14
+ // What a caller gets is not a rendering decision made for them: `text` and `documents` are
15
+ // both there when the command produced both, and `renderText`/`renderJson` are the two
16
+ // answers the CLI had. A caller that wants neither reads the fields a command exported
17
+ // (`src/api.mjs`).
18
+
19
+ import { EXIT, clearWarnings, jsonCommand, jsonFailMode, jsonIndentDefault, maxBytesLimit, warnings, withOutputState, withSink } from './output/index.mjs';
20
+ import { CLI } from './name.mjs';
21
+ import { GLOBAL_FLAGS, resolveCommand } from './commands/index.mjs';
22
+
23
+ /** `--some-flag` for a flag key the parser handed back as `someFlag`. */
24
+ function spelled(key) {
25
+ return key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
26
+ }
27
+
28
+ function failure(command, code, message, remedy) {
29
+ return {
30
+ command,
31
+ ok: false,
32
+ code,
33
+ text: '',
34
+ documents: [],
35
+ notes: [],
36
+ warnings: [],
37
+ error: { message, remedy: remedy ?? `run: ${CLI} --help`, exitCode: code },
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Run one command path and answer with what it produced.
43
+ *
44
+ * @param {string} command the dispatch key: `status`, `plan next`, `hooks install`
45
+ * @param {{args?: string[], flags?: object, root?: string}} [input]
46
+ */
47
+ export async function run(command, { args = [], flags = {}, root } = {}) {
48
+ const resolved = await resolveCommand(command);
49
+ if (resolved === null) return failure(command, EXIT.USAGE, `unknown command \`${command}\``);
50
+
51
+ // The guard rails the dispatcher applies, applied here for the same reason: a library
52
+ // caller that passes `--frozen` to `status` must be told, not quietly served a default.
53
+ for (const key of Object.keys(flags)) {
54
+ if (GLOBAL_FLAGS.has(key) || resolved.declared.has(spelled(key))) continue;
55
+ return failure(command, EXIT.USAGE, `--${spelled(key)} is not a flag of \`${command}\``);
56
+ }
57
+ for (const [flag, allowed] of Object.entries(resolved.values)) {
58
+ const value = flags[flag.replace(/-([a-z])/g, (_, c) => c.toUpperCase())];
59
+ if (value !== undefined && !allowed.includes(value)) {
60
+ return failure(command, EXIT.USAGE, `--${flag} takes ${allowed.join(', ')}; received ${value}`);
61
+ }
62
+ }
63
+
64
+ clearWarnings();
65
+ jsonCommand(command);
66
+ jsonIndentDefault(flags.pretty === true);
67
+ maxBytesLimit(flags.maxBytes);
68
+ jsonFailMode(flags.json === true);
69
+
70
+ const effective = root === undefined ? flags : { root, ...flags };
71
+ const produced = await withOutputState(() => withSink(() => resolved.run(args, effective)));
72
+ return {
73
+ command,
74
+ ok: produced.code === EXIT.OK,
75
+ code: produced.code,
76
+ text: produced.text,
77
+ documents: produced.documents,
78
+ notes: produced.notes,
79
+ warnings: warnings(),
80
+ error: produced.error,
81
+ };
82
+ }
83
+
84
+ /** The text the command printed, as one string. Empty when it answered in JSON. */
85
+ export function renderText(result) {
86
+ return result.text;
87
+ }
88
+
89
+ /**
90
+ * The document the command emitted, or null when it answered as text.
91
+ *
92
+ * A command that emits a stream (NDJSON) has more than one document and gets an array:
93
+ * collapsing a stream to its first element is how a caller silently loses rows.
94
+ */
95
+ export function renderJson(result) {
96
+ if (result.documents.length === 0) return null;
97
+ return result.documents.length === 1 ? result.documents[0] : result.documents;
98
+ }
package/src/secrets.mjs CHANGED
@@ -23,10 +23,7 @@
23
23
  // is documentation, and a gate that blocks it teaches the exact `--no-verify`
24
24
  // habit the dispatcher's fail-closed rule exists to prevent.
25
25
 
26
- import { execFile } from 'node:child_process';
27
- import { promisify } from 'node:util';
28
-
29
- const exec = promisify(execFile);
26
+ import { git } from './sys/git.mjs';
30
27
 
31
28
  /** The length at which a token is worth measuring, and the entropy it must reach. */
32
29
  const ENTROPY_MIN_LENGTH = 32;
@@ -294,24 +291,19 @@ export async function scanStaged(root, options = {}) {
294
291
  // program that prints nothing, `-diff` in `.gitattributes` and genuinely binary
295
292
  // files make git print no text at all, a textconv rewrites the bytes on the way
296
293
  // out, `diff.mnemonicPrefix` renames the header prefixes the attribution below
297
- // reads, and C-quoting mangles a non-ASCII name. The scan asks git for the staged
298
- // bytes with its own flags and ignores what the repository asked for.
299
- ({ stdout } = await exec(
300
- 'git',
301
- [
302
- '-c', 'core.quotePath=false',
303
- 'diff',
304
- '--cached',
305
- '--no-ext-diff',
306
- '--no-textconv',
307
- '--text',
308
- '--unified=0',
309
- '--no-color',
310
- '--src-prefix=a/',
311
- '--dst-prefix=b/',
312
- ],
313
- { cwd: root, maxBuffer: 32 * 1024 * 1024 },
314
- ));
294
+ // reads. The scan asks git for the staged bytes with its own flags and ignores
295
+ // what the repository asked for.
296
+ stdout = await git(root, [
297
+ 'diff',
298
+ '--cached',
299
+ '--no-ext-diff',
300
+ '--no-textconv',
301
+ '--text',
302
+ '--unified=0',
303
+ '--no-color',
304
+ '--src-prefix=a/',
305
+ '--dst-prefix=b/',
306
+ ]);
315
307
  } catch (error) {
316
308
  const stderr = String(error.stderr ?? '').trim();
317
309
  const reason = (stderr || String(error.message ?? error)).split('\n')[0];
@@ -0,0 +1,129 @@
1
+ // One append-only event log for everything this tool observes about itself (Q73).
2
+ //
3
+ // The stores that record what happened - the evidence ledger, the lessons file, the lease
4
+ // file, the check cache - were five files with three version keys and no shared shape, so
5
+ // nothing could tail them and no UI could show a run. An event is one line with one
6
+ // envelope, appended with `O_APPEND` so two writers never rewrite each other's history.
7
+ //
8
+ // The log is the record; the ledger and the lessons file are projections of it. A reader
9
+ // that still reads the old file gets the same answer, because the projection is written to
10
+ // the same place it always was - but a line lost to rotation is recoverable here, and a
11
+ // consumer that wants to watch a run live has one file to follow.
12
+ //
13
+ // `O_APPEND` matters more than it looks: a lease write and a gate run in two processes can
14
+ // land at the same moment, and a read-modify-write of a shared log would drop one of them.
15
+ // That is the same defect the lease lock exists for, and an append does not need the lock.
16
+
17
+ import { randomBytes } from 'node:crypto';
18
+ import { appendFile, mkdir } from 'node:fs/promises';
19
+ import { dirname, join } from 'node:path';
20
+
21
+ import { localRel, readStateFile } from '../state.mjs';
22
+
23
+ /** Where the log lives: machine-local state, so no run changes the tree it describes. */
24
+ export const EVENTS_FILE = localRel('events.jsonl');
25
+ const EVENTS_LEGACY = '.simt/events.jsonl';
26
+
27
+ /**
28
+ * The closed set of kinds. A kind that is not here is a typo that would otherwise sit in
29
+ * the log forever, unread by every consumer and silently meaningless.
30
+ */
31
+ export const EVENT_KINDS = Object.freeze([
32
+ 'gate.started',
33
+ 'gate.finished',
34
+ 'policy.decided',
35
+ 'lease.acquired',
36
+ 'lease.released',
37
+ 'lease.expired',
38
+ 'slice.transitioned',
39
+ 'write.committed',
40
+ 'write.undone',
41
+ 'lesson.captured',
42
+ ]);
43
+
44
+ /** Crockford base32, the alphabet ULID uses: no I, L, O or U, so a hand-copied id is unambiguous. */
45
+ const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
46
+
47
+ /**
48
+ * A ULID: 48 bits of milliseconds, then 80 bits of randomness.
49
+ *
50
+ * Chosen over a UUID because it sorts by time as bytes, which is what makes a log
51
+ * appendable and a tail read meaningful without parsing timestamps.
52
+ */
53
+ export function ulid(now = Date.now()) {
54
+ let time = '';
55
+ let left = now;
56
+ for (let i = 0; i < 10; i += 1) {
57
+ time = CROCKFORD[left % 32] + time;
58
+ left = Math.floor(left / 32);
59
+ }
60
+ const bytes = randomBytes(16);
61
+ let random = '';
62
+ for (let i = 0; i < 16; i += 1) random += CROCKFORD[bytes[i] % 32];
63
+ return `${time}${random}`;
64
+ }
65
+
66
+ /**
67
+ * Append one event, or throw.
68
+ *
69
+ * A caller that cannot record what it did has to hear about it: a swallowed failure here
70
+ * is a gate that ran and left no evidence, which is the state the ledger exists to make
71
+ * impossible.
72
+ */
73
+ export async function appendEvent(root, { kind, session = null, harness = null, tree = null, subject = null, data = {} }) {
74
+ if (!EVENT_KINDS.includes(kind)) {
75
+ throw new Error(`unknown event kind: ${kind}`);
76
+ }
77
+ const event = {
78
+ v: 1,
79
+ id: ulid(),
80
+ ts: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
81
+ kind,
82
+ session,
83
+ harness,
84
+ tree,
85
+ subject,
86
+ data,
87
+ };
88
+ const path = join(root, EVENTS_FILE);
89
+ await mkdir(dirname(path), { recursive: true });
90
+ await appendFile(path, `${JSON.stringify(event)}\n`);
91
+ return event;
92
+ }
93
+
94
+ /**
95
+ * Best-effort append for a caller whose primary job must not fail on a log write.
96
+ *
97
+ * Used by `undo` and by lesson capture: the work those commands do is already done, and
98
+ * throwing afterwards would report a failure the user cannot act on. The error is returned
99
+ * rather than swallowed so a caller that wants to surface it can.
100
+ */
101
+ export async function noteEvent(root, event) {
102
+ try {
103
+ return { event: await appendEvent(root, event), error: null };
104
+ } catch (error) {
105
+ return { event: null, error: error.message };
106
+ }
107
+ }
108
+
109
+ /** Every well-formed event, oldest first; damaged lines are reported, never hidden. */
110
+ export async function readEvents(root, { limit = 0 } = {}) {
111
+ const { text } = await readStateFile(root, EVENTS_FILE, [EVENTS_LEGACY]);
112
+ if (text === null) return { events: [], errors: [] };
113
+ const events = [];
114
+ const errors = [];
115
+ for (const [i, raw] of text.split('\n').entries()) {
116
+ if (raw.trim() === '') continue;
117
+ try {
118
+ const event = JSON.parse(raw);
119
+ if (typeof event?.id !== 'string' || typeof event?.kind !== 'string') {
120
+ errors.push(`line ${i + 1} has no id or kind`);
121
+ continue;
122
+ }
123
+ events.push(event);
124
+ } catch (error) {
125
+ errors.push(`line ${i + 1} does not parse (${error.message})`);
126
+ }
127
+ }
128
+ return { events: limit > 0 ? events.slice(-limit) : events, errors };
129
+ }
@@ -0,0 +1,233 @@
1
+ // The aggregate behind bare `simt status` (review 0003 Q82).
2
+ //
3
+ // `status --line` answers one question in milliseconds: the gate state as of the last
4
+ // check. Bare `status` was a usage error, and its comment justified that by claiming
5
+ // `plan` without a subcommand behaved the same way - `plan` exits 0 and prints its
6
+ // tracker. The question bare `status` was refusing is the one a UI's home view and a
7
+ // harness status line ask: what is the state of this repository.
8
+ //
9
+ // Every slot is read through the module that owns it - the check cache, hook routing,
10
+ // the agent-hook table, the ledger, the lease file, the plan tracker, the journal -
11
+ // because a second implementation of any of them is a second answer to the same
12
+ // question, and the quiet one is the one a reader acts on. A slot that cannot answer
13
+ // carries its error and leaves the rest of the snapshot intact: an unreadable manifest
14
+ // must not blind a caller to the evidence and the leases that did answer.
15
+
16
+ import { readFile } from 'node:fs/promises';
17
+ import { join } from 'node:path';
18
+
19
+ import { adapters, hookPath, installedIn } from './agenthooks/render.mjs';
20
+ import { readHooksState, wantsShim } from './compare/hooksstate.mjs';
21
+ import { repoContext } from './context.mjs';
22
+ import { listJournalSets } from './journal.mjs';
23
+ import { activeLeases, readLeases } from './lease.mjs';
24
+ import { hasTestEvidence, readLedger } from './ledger/index.mjs';
25
+ import { CLI } from './name.mjs';
26
+ import { planStatus } from './plan/index.mjs';
27
+ import { currentHead, readCheckCache, renderStatusLine } from './status.mjs';
28
+ import { gitText } from './sys/git.mjs';
29
+
30
+ /** One slot's failure is that slot's answer, never the snapshot's. */
31
+ async function slot(compute) {
32
+ try {
33
+ return await compute();
34
+ } catch (error) {
35
+ return { error: error.message };
36
+ }
37
+ }
38
+
39
+ async function checkSlot(root) {
40
+ const record = await readCheckCache(root);
41
+ if (record === null) return null;
42
+ const head = await currentHead(root);
43
+ const stale = head !== null && record.head !== null && head !== record.head;
44
+ return {
45
+ at: record.at,
46
+ head: record.head,
47
+ stale,
48
+ line: renderStatusLine(record, stale),
49
+ rulesOk: record.rulesOk,
50
+ hooksOk: record.hooksOk,
51
+ gatesRun: record.gatesRun,
52
+ gatesTotal: record.gatesTotal,
53
+ blocking: record.blocking,
54
+ warnings: record.warnings,
55
+ };
56
+ }
57
+
58
+ async function gitHooksSlot(root) {
59
+ const { manifest, exists, error } = await repoContext(root).manifest();
60
+ if (error) return { error };
61
+ const state = await readHooksState(root, { declaresGate: (event) => wantsShim(manifest, exists, event) });
62
+ return {
63
+ hooksPath: state.current,
64
+ liveDir: state.liveDir,
65
+ events: state.installed.map((i) => ({ event: i.event, present: i.present, stale: i.stale })),
66
+ foreign: state.foreign.length,
67
+ gates: exists ? { preCommit: manifest?.gates?.preCommit ?? [], prePush: manifest?.gates?.prePush ?? [] } : null,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Which harnesses will decide a tool call.
73
+ *
74
+ * The summary, not the report: `agent-hooks status` owns the detail, including whether a
75
+ * file drifted from what `install` would write and whether the prompt event is opted
76
+ * into. What is shared is the definition of "installed" - `installedIn` reads the same
77
+ * document the hook runs from, so this slot cannot say present about an entry that
78
+ * `install` did not write.
79
+ */
80
+ async function agentHooksSlot(root) {
81
+ const harnesses = [];
82
+ for (const row of adapters()) {
83
+ const path = hookPath(row.id);
84
+ let existing = null;
85
+ try {
86
+ existing = await readFile(join(root, path), 'utf8');
87
+ } catch (error) {
88
+ if (error.code !== 'ENOENT') throw error;
89
+ }
90
+ if (existing === null) {
91
+ harnesses.push({ id: row.id, path, state: 'absent' });
92
+ continue;
93
+ }
94
+ if (row.file) {
95
+ harnesses.push({ id: row.id, path, state: 'present' });
96
+ continue;
97
+ }
98
+ let config;
99
+ try {
100
+ config = JSON.parse(existing);
101
+ } catch {
102
+ harnesses.push({ id: row.id, path, state: 'unreadable' });
103
+ continue;
104
+ }
105
+ if (config === null || typeof config !== 'object' || Array.isArray(config)) {
106
+ harnesses.push({ id: row.id, path, state: 'unreadable' });
107
+ continue;
108
+ }
109
+ harnesses.push({ id: row.id, path, state: installedIn(row, config).length > 0 ? 'present' : 'absent' });
110
+ }
111
+ return {
112
+ wired: harnesses.filter((h) => h.state === 'present').length,
113
+ total: harnesses.length,
114
+ harnesses,
115
+ };
116
+ }
117
+
118
+ async function evidenceSlot(root) {
119
+ const { entries, errors } = await readLedger(root);
120
+ let tree = null;
121
+ try {
122
+ tree = await gitText(root, ['rev-parse', 'HEAD^{tree}']);
123
+ } catch {
124
+ // No repository, no commits: there is no tree to have evidence for, and `null`
125
+ // says that rather than \"no evidence\", which would read as a missing run.
126
+ tree = null;
127
+ }
128
+ return {
129
+ entries: entries.length,
130
+ errors: errors.length,
131
+ headTree: tree,
132
+ testsRecorded: tree === null ? null : hasTestEvidence(entries, tree),
133
+ };
134
+ }
135
+
136
+ async function leasesSlot(root) {
137
+ const { leases, error } = await readLeases(root);
138
+ if (error) return { error };
139
+ const active = activeLeases({ leases });
140
+ return { active: active.length, paths: active.map((lease) => lease.path) };
141
+ }
142
+
143
+ async function slicesSlot(root) {
144
+ const status = await planStatus(root);
145
+ if (status.error) return { error: status.error };
146
+ return { ready: status.ready, totals: status.totals };
147
+ }
148
+
149
+ async function journalSlot(root) {
150
+ const sets = await listJournalSets(root);
151
+ return { sets: sets.length, last: sets[0] ?? null };
152
+ }
153
+
154
+ /**
155
+ * The repository's state in one document.
156
+ *
157
+ * `ok` is the gate's verdict, not the snapshot's: a slot that failed to read is a fact
158
+ * about this repository (unreadable state is a real condition), and folding it into
159
+ * `ok` would turn a stale manifest into a failing gate no `sync` can fix.
160
+ */
161
+ export async function statusSnapshot(root) {
162
+ const [check, gitHooks, agentHooks, evidence, leases, slices, journal] = await Promise.all([
163
+ slot(() => checkSlot(root)),
164
+ slot(() => gitHooksSlot(root)),
165
+ slot(() => agentHooksSlot(root)),
166
+ slot(() => evidenceSlot(root)),
167
+ slot(() => leasesSlot(root)),
168
+ slot(() => slicesSlot(root)),
169
+ slot(() => journalSlot(root)),
170
+ ]);
171
+ return {
172
+ ok: (check?.blocking ?? 0) === 0,
173
+ command: 'status',
174
+ root,
175
+ at: Date.now(),
176
+ head: await currentHead(root),
177
+ check,
178
+ gitHooks,
179
+ agentHooks,
180
+ evidence,
181
+ leases,
182
+ slices,
183
+ journal,
184
+ };
185
+ }
186
+
187
+ function unavailable(label, slot) {
188
+ return `${label.padEnd(9)}${slot.error}`;
189
+ }
190
+
191
+ /** The same snapshot, read as a person: one line per slot, and a pointer for the detail. */
192
+ export function renderSnapshot(snapshot) {
193
+ const lines = [`repo ${snapshot.root}`];
194
+ if (snapshot.head !== null) lines.push(`head ${snapshot.head}`);
195
+
196
+ if (snapshot.check === null) lines.push(`check no cache - run ${CLI} check`);
197
+ else if (snapshot.check.error) lines.push(unavailable('check', snapshot.check));
198
+ else lines.push(`check ${snapshot.check.line}`);
199
+
200
+ const hooks = snapshot.gitHooks;
201
+ if (hooks.error) lines.push(unavailable('githooks', hooks));
202
+ else {
203
+ const wired = hooks.events.filter((e) => e.present && !e.stale).length;
204
+ const foreign = hooks.foreign > 0 ? `, ${hooks.foreign} foreign hook(s)` : '';
205
+ lines.push(`githooks ${wired}/${hooks.events.length} events wired${foreign}${hooks.hooksPath === null ? ` (git default: ${hooks.liveDir})` : ''}`);
206
+ }
207
+
208
+ const agent = snapshot.agentHooks;
209
+ if (agent.error) lines.push(unavailable('agenthooks', agent));
210
+ else lines.push(`agenthooks ${agent.wired}/${agent.total} harnesses wired - ${CLI} agent-hooks status`);
211
+
212
+ const evidence = snapshot.evidence;
213
+ if (evidence.error) lines.push(unavailable('evidence', evidence));
214
+ else if (evidence.testsRecorded === null) lines.push(`evidence ${evidence.entries} run(s), no head tree to compare`);
215
+ else lines.push(`evidence ${evidence.entries} run(s), ${evidence.testsRecorded ? 'a test run is recorded for this tree' : 'no test run for this tree'}`);
216
+
217
+ const leases = snapshot.leases;
218
+ if (leases.error) lines.push(unavailable('leases', leases));
219
+ else lines.push(`leases ${leases.active} active${leases.paths.length > 0 ? `: ${leases.paths.join(', ')}` : ''}`);
220
+
221
+ const slices = snapshot.slices;
222
+ if (slices.error) lines.push(unavailable('slices', slices));
223
+ else {
224
+ const counts = Object.entries(slices.totals).map(([state, count]) => `${count} ${state.toLowerCase()}`);
225
+ lines.push(`slices ${slices.ready} ready${counts.length > 0 ? ` - ${counts.join(', ')}` : ''}`);
226
+ }
227
+
228
+ const journal = snapshot.journal;
229
+ if (journal.error) lines.push(unavailable('journal', journal));
230
+ else lines.push(`journal ${journal.sets} set(s)${journal.last === null ? '' : `, last ${journal.last}`}`);
231
+
232
+ return lines.join('\n');
233
+ }
package/src/status.mjs CHANGED
@@ -25,15 +25,12 @@
25
25
  // booleans are stored, never findings, so a huge gate still leaves a
26
26
  // ~200-byte file and a killed `check` never leaves half a document.
27
27
 
28
- import { execFile } from 'node:child_process';
29
28
  import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
30
29
  import { join } from 'node:path';
31
- import { promisify } from 'node:util';
32
30
 
33
31
  import { readHooksState, wantsShim } from './compare/hooksstate.mjs';
34
32
  import { LOCAL_DIR } from './state.mjs';
35
-
36
- const exec = promisify(execFile);
33
+ import { gitText } from './sys/git.mjs';
37
34
 
38
35
  const CACHE_FILE = join(LOCAL_DIR, 'check.json');
39
36
  const CACHE_FILE_LEGACY = '.simt/check.json';
@@ -47,8 +44,7 @@ const CACHE_VERSION = 1;
47
44
  */
48
45
  export async function currentHead(root) {
49
46
  try {
50
- const { stdout } = await exec('git', ['rev-parse', 'HEAD'], { cwd: root });
51
- return stdout.trim() || null;
47
+ return await gitText(root, ['rev-parse', 'HEAD']);
52
48
  } catch {
53
49
  return null;
54
50
  }
@@ -0,0 +1,82 @@
1
+ // The one git runner (Q72).
2
+ //
3
+ // Thirty modules imported `child_process` and grew their own `git()`: some threw, some
4
+ // returned null, some trimmed, some split on NUL, and each of them decided for itself
5
+ // whether `core.quotePath` was the caller's problem. The copies disagreed in ways that
6
+ // only showed up on a path with a non-ASCII byte in it.
7
+ //
8
+ // Three verbs, and the difference between them is the return type, not the flags:
9
+ // `git` answers the raw stdout, `gitText` answers it trimmed or null, and `gitPaths`
10
+ // answers NUL-separated paths. `allowFailure` is explicit because "this repository is not
11
+ // a git repository" is sometimes an answer and sometimes an error, and only the caller
12
+ // knows which.
13
+
14
+ import { execFile } from 'node:child_process';
15
+
16
+ /** How much stdout one git call may buffer before the call is refused. */
17
+ const GIT_MAX_BUFFER = 32 << 20;
18
+
19
+ /** A failed git call, carrying the arguments and git's own stderr.
20
+ * `stdout` is kept too: a call that fails part-way (a `diff` in a tree with no HEAD)
21
+ * can still have printed, and a caller that salvages the partial output must not lose
22
+ * it to the wrap. */
23
+ class GitError extends Error {
24
+ constructor(args, cause) {
25
+ const stderr = String(cause?.stderr ?? '').trim();
26
+ super(`git ${args.join(' ')} failed${stderr ? `: ${stderr}` : ''}`);
27
+ this.name = 'GitError';
28
+ this.args = args;
29
+ this.stderr = stderr;
30
+ this.stdout = cause?.stdout;
31
+ this.code = cause?.code;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Run git in `root` and answer stdout.
37
+ *
38
+ * `core.quotePath=false` is set on every call so a path with a non-ASCII byte comes back
39
+ * as itself; the copies that relied on a caller remembering `-z` did not.
40
+ *
41
+ * @param {string} root
42
+ * @param {string[]} args
43
+ * @param {{allowFailure?: boolean, maxBuffer?: number, input?: string, env?: NodeJS.ProcessEnv}} [options]
44
+ * @returns {Promise<string|null>} stdout, or null when `allowFailure` and the call failed
45
+ */
46
+ export function git(
47
+ root,
48
+ args,
49
+ { allowFailure = false, maxBuffer = GIT_MAX_BUFFER, input, env = process.env } = {},
50
+ ) {
51
+ return new Promise((resolve, reject) => {
52
+ const child = execFile(
53
+ 'git',
54
+ ['-c', 'core.quotePath=false', ...args],
55
+ { cwd: root, encoding: 'utf8', maxBuffer, env },
56
+ (error, stdout, stderr) => {
57
+ if (error) {
58
+ if (allowFailure) resolve(null);
59
+ else reject(new GitError(args, Object.assign(error, { stderr })));
60
+ return;
61
+ }
62
+ resolve(stdout);
63
+ },
64
+ );
65
+ // `execFile` has no `input` option and leaves stdin a pipe: a child like `hash-object
66
+ // --stdin` waits for an EOF that never comes, so the call hangs rather than fails.
67
+ if (input !== undefined) child.stdin.end(input);
68
+ });
69
+ }
70
+
71
+ /** stdout trimmed, or null when `allowFailure` and the call failed. */
72
+ export async function gitText(root, args, options = {}) {
73
+ const stdout = await git(root, args, options);
74
+ return stdout === null ? null : stdout.trim();
75
+ }
76
+
77
+ /** NUL-separated stdout split into paths. `-z` is added here, so a caller cannot forget it. */
78
+ export async function gitPaths(root, args, options = {}) {
79
+ const stdout = await git(root, [...args, '-z'], options);
80
+ if (stdout === null) return null;
81
+ return stdout.split('\0').filter(Boolean);
82
+ }