simt-dev 0.6.2 → 0.7.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 (170) hide show
  1. package/CHANGELOG.md +83 -1
  2. package/README.md +46 -17
  3. package/agents/simt-agent.md +84 -0
  4. package/bin/simt.mjs +19 -6
  5. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  6. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  7. package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
  8. package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
  9. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
  10. package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
  11. package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
  12. package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
  13. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  14. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  15. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  16. package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
  17. package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
  18. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  19. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  20. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  21. package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
  22. package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
  23. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  24. package/docs/decisions/README.md +53 -0
  25. package/package.json +4 -2
  26. package/rules/core.md +2 -2
  27. package/rules/mdc/preview-postflight.mdc +5 -6
  28. package/rules/mdc/test-gate.mdc +2 -0
  29. package/rules/stacks/android.md +10 -11
  30. package/rules/stacks/cloudflare-workers.md +1 -1
  31. package/rules/stacks/connectiq.md +2 -2
  32. package/rules/stacks/container.md +2 -0
  33. package/rules/stacks/dart.md +1 -1
  34. package/rules/stacks/deno.md +1 -1
  35. package/rules/stacks/dotnet.md +1 -1
  36. package/rules/stacks/elixir.md +1 -1
  37. package/rules/stacks/go.md +1 -1
  38. package/rules/stacks/jvm.md +4 -3
  39. package/rules/stacks/node.md +2 -2
  40. package/rules/stacks/python.md +7 -6
  41. package/rules/stacks/react-native.md +3 -5
  42. package/rules/stacks/react-web.md +9 -9
  43. package/rules/stacks/ruby.md +1 -1
  44. package/rules/stacks/rust.md +1 -1
  45. package/rules/stacks/swift.md +1 -1
  46. package/rules/stacks/typescript.md +2 -2
  47. package/rules/stacks/uv.md +3 -2
  48. package/skills/simt-commands/SKILL.md +70 -0
  49. package/src/advice/advice.mjs +7 -3
  50. package/src/agenthooks/decide.mjs +238 -15
  51. package/src/agenthooks/opencode.mjs +72 -0
  52. package/src/agenthooks/policy.mjs +81 -5
  53. package/src/agenthooks/prompt.mjs +6 -2
  54. package/src/agenthooks/render.mjs +186 -19
  55. package/src/agenthooks/shell.mjs +523 -0
  56. package/src/agenthooks/shim.mjs +55 -0
  57. package/src/agents/render.mjs +277 -0
  58. package/src/argv.mjs +62 -9
  59. package/src/bench/ablate.mjs +31 -14
  60. package/src/bench/mine.mjs +48 -1
  61. package/src/bench/probes.mjs +5 -6
  62. package/src/bench/report.mjs +243 -80
  63. package/src/bench/run.mjs +12 -11
  64. package/src/bench/variants.mjs +112 -40
  65. package/src/brief/assemble.mjs +6 -0
  66. package/src/ci/workflow.mjs +16 -7
  67. package/src/commands/agent.mjs +206 -0
  68. package/src/commands/agenthook.mjs +133 -17
  69. package/src/commands/bench.mjs +2 -1
  70. package/src/commands/ci.mjs +28 -3
  71. package/src/commands/compare.mjs +8 -7
  72. package/src/commands/completions.mjs +27 -0
  73. package/src/commands/decide.mjs +20 -6
  74. package/src/commands/doctor.mjs +367 -0
  75. package/src/commands/evidence.mjs +13 -3
  76. package/src/commands/explain.mjs +52 -0
  77. package/src/commands/gate.mjs +85 -19
  78. package/src/commands/graph.mjs +18 -5
  79. package/src/commands/help.mjs +96 -26
  80. package/src/commands/hooks.mjs +258 -67
  81. package/src/commands/ignore.mjs +152 -0
  82. package/src/commands/impact.mjs +4 -2
  83. package/src/commands/index.mjs +69 -102
  84. package/src/commands/init.mjs +239 -37
  85. package/src/commands/lease.mjs +304 -0
  86. package/src/commands/lessons.mjs +77 -9
  87. package/src/commands/map.mjs +33 -18
  88. package/src/commands/mcp.mjs +2 -2
  89. package/src/commands/pack.mjs +9 -0
  90. package/src/commands/plan.mjs +13 -1
  91. package/src/commands/plugin.mjs +17 -2
  92. package/src/commands/prbody.mjs +45 -0
  93. package/src/commands/repoindex.mjs +4 -0
  94. package/src/commands/rules.mjs +219 -105
  95. package/src/commands/shared.mjs +36 -5
  96. package/src/commands/skills.mjs +329 -206
  97. package/src/commands/skillscompile.mjs +8 -7
  98. package/src/commands/skillsinstall.mjs +112 -30
  99. package/src/commands/slice.mjs +712 -0
  100. package/src/commands/status.mjs +62 -0
  101. package/src/commands/sync.mjs +146 -36
  102. package/src/commands/tests.mjs +5 -2
  103. package/src/commands/undo.mjs +114 -0
  104. package/src/commands/upgrade.mjs +103 -9
  105. package/src/commands/why.mjs +10 -4
  106. package/src/compare/hooksstate.mjs +23 -1
  107. package/src/compare/learn.mjs +82 -57
  108. package/src/compare/parallel.mjs +50 -0
  109. package/src/compare/row.mjs +26 -1
  110. package/src/completions.mjs +196 -0
  111. package/src/decisions/dir.mjs +84 -0
  112. package/src/decisions/index.mjs +109 -11
  113. package/src/diff.mjs +254 -0
  114. package/src/distill/distill.mjs +3 -1
  115. package/src/ecosystem/node-runner.mjs +144 -0
  116. package/src/explain.mjs +270 -0
  117. package/src/federation/config.mjs +44 -0
  118. package/src/graph/load.mjs +52 -24
  119. package/src/hooks/capability.mjs +14 -1
  120. package/src/hooks/dispatcher.mjs +441 -9
  121. package/src/hooks/gates.mjs +14 -3
  122. package/src/hooks/resolve.mjs +23 -3
  123. package/src/hooks/shim.mjs +12 -7
  124. package/src/identity.mjs +67 -14
  125. package/src/ignore.mjs +133 -0
  126. package/src/index/extract.mjs +192 -11
  127. package/src/index/persist.mjs +372 -55
  128. package/src/index/store.mjs +263 -60
  129. package/src/index/tests.mjs +6 -35
  130. package/src/journal.mjs +545 -0
  131. package/src/kernel/card.mjs +37 -12
  132. package/src/kernel/checks.mjs +44 -9
  133. package/src/kernel/variants.mjs +27 -16
  134. package/src/lease.mjs +195 -0
  135. package/src/ledger/index.mjs +91 -25
  136. package/src/mcp/resources.mjs +5 -3
  137. package/src/mcp/server.mjs +86 -13
  138. package/src/mcp/tools.mjs +340 -264
  139. package/src/memory/decide.mjs +98 -38
  140. package/src/memory/lessons.mjs +180 -33
  141. package/src/name.mjs +9 -0
  142. package/src/output/index.mjs +82 -12
  143. package/src/package-version.mjs +49 -1
  144. package/src/packs/manage.mjs +13 -4
  145. package/src/plan/index.mjs +37 -8
  146. package/src/plan/template.mjs +27 -5
  147. package/src/plugins.mjs +33 -8
  148. package/src/prbody.mjs +253 -0
  149. package/src/projection/baselines.mjs +1 -1
  150. package/src/projection/disclosure.mjs +2 -2
  151. package/src/projection/loaders.mjs +41 -1
  152. package/src/projection/lock.mjs +21 -14
  153. package/src/projection/manifest.mjs +82 -21
  154. package/src/projection/markers.mjs +43 -3
  155. package/src/projection/mdc.mjs +34 -7
  156. package/src/projection/project.mjs +162 -18
  157. package/src/projection/rules.mjs +54 -3
  158. package/src/projection/shared.mjs +4 -1
  159. package/src/projection/surfaces.mjs +9 -1
  160. package/src/repo-root.mjs +69 -0
  161. package/src/resolve/body.mjs +4 -0
  162. package/src/resolve/fingerprint.mjs +5 -0
  163. package/src/resolve/local.mjs +58 -0
  164. package/src/review/critic.mjs +4 -2
  165. package/src/secrets.mjs +374 -0
  166. package/src/state.mjs +76 -0
  167. package/src/status.mjs +157 -0
  168. package/src/toolwords.mjs +5 -0
  169. package/src/tsconfig.mjs +39 -99
  170. package/templates/plan/tracker.json +1 -1
@@ -0,0 +1,374 @@
1
+ // A zero-dependency scan of the staged diff for secrets (plan 0003 R52).
2
+ //
3
+ // Two finding sources, deliberately both. A pattern library catches the shapes
4
+ // vendors publish — an AWS key id, a private key header, a token prefix — and
5
+ // Shannon entropy catches the high-entropy blobs that carry no prefix at all:
6
+ // a 32-character random base64 string is a secret whether or not a vendor named
7
+ // it. Neither needs a network call or an install step, which is the point. A
8
+ // gate that needs an install step is a gate a fresh clone does not have
9
+ // (ADR-012, zero runtime dependencies).
10
+ //
11
+ // Three boundaries, each load-bearing:
12
+ //
13
+ // - Added lines only. The scan reads `git diff --cached`, so a secret already
14
+ // sitting in history is not this gate's finding: the commit about to be made
15
+ // is. Flagging context lines would block commits for work the author did not
16
+ // stage, which is the failure `format`'s staged-only rule exists to prevent.
17
+ // - Findings never print the matched bytes. The hook output lands in a
18
+ // terminal, a CI log and often a transcript an agent reads back: echoing the
19
+ // secret into a report about the secret is the leak with extra steps. A
20
+ // finding carries a four-character prefix (the part a vendor publishes as a
21
+ // type — `AKIA`, `sk-`, `ghp_`) and nothing else.
22
+ // - Placeholders are not secrets. `password = "changeme-example"` in a README
23
+ // is documentation, and a gate that blocks it teaches the exact `--no-verify`
24
+ // habit the dispatcher's fail-closed rule exists to prevent.
25
+
26
+ import { execFile } from 'node:child_process';
27
+ import { promisify } from 'node:util';
28
+
29
+ const exec = promisify(execFile);
30
+
31
+ /** The length at which a token is worth measuring, and the entropy it must reach. */
32
+ const ENTROPY_MIN_LENGTH = 32;
33
+ export const ENTROPY_MIN_BITS = 4.5;
34
+
35
+ /**
36
+ * A value is a placeholder when the WHOLE value is placeholder-shaped, not when
37
+ * it contains a placeholder word: a real secret may contain "example" and still
38
+ * be a secret, and the substring test blocked exactly that commit (review 0003 Q24).
39
+ */
40
+ const PLACEHOLDER_WORD = /^(?:example|sample|placeholder|changeme|change[-_]?me|dummy|fake|redacted|notasecret|undefined|null|none|true|false|x+)$/i;
41
+ const PLACEHOLDER_SLOT = /^(?:<[^<>]*>|\{\{?[a-z0-9_.-]+\}\}?|your[-_][a-z0-9_-]*|my[-_][a-z0-9_-]*)$/i;
42
+
43
+ /**
44
+ * @param {string} value the candidate secret
45
+ * @returns {boolean} true when the whole value is documentation, not a leak
46
+ */
47
+ function isPlaceholder(value) {
48
+ const v = String(value).trim();
49
+ if (v === '') return true;
50
+ if (PLACEHOLDER_SLOT.test(v)) return true;
51
+ const words = v.split(/[^a-zA-Z]+/).filter(Boolean);
52
+ if (words.length === 0) return true; // punctuation only: `…`, `-`
53
+ return words.every((w) => PLACEHOLDER_WORD.test(w));
54
+ }
55
+
56
+ /**
57
+ * The pattern library. Each row names itself so the finding tells the reader
58
+ * WHICH shape matched — "a secret was found" sends them hunting for it;
59
+ * "aws-access-key-id on line 12" says what to revoke.
60
+ *
61
+ * `groups`, when set, lists the captures that may hold the VALUE — what the
62
+ * placeholder check and the redaction run on; the first defined capture wins.
63
+ * Without it the whole match is the value.
64
+ */
65
+ const PATTERNS = [
66
+ { id: 'aws-access-key-id', re: /\b(?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}\b/g },
67
+ // The type word is not the discriminator, "PRIVATE KEY" is: RSA, EC, DSA,
68
+ // OPENSSH, PGP and ENCRYPTED all carry the same header shape (review 0003 Q24).
69
+ { id: 'private-key-block', re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/g },
70
+ { id: 'github-token', re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b|\bgithub_pat_[A-Za-z0-9_]{22,}\b/g },
71
+ { id: 'gitlab-token', re: /\bglpat-[A-Za-z0-9_-]{20,}\b/g },
72
+ { id: 'npm-token', re: /\bnpm_[A-Za-z0-9]{36}\b/g },
73
+ { id: 'slack-token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
74
+ { id: 'stripe-live-key', re: /\b(?:sk|rk)_live_[0-9a-zA-Z]{24,}\b/g },
75
+ { id: 'google-api-key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
76
+ { id: 'openai-style-key', re: /\bsk-[A-Za-z0-9_-]{20,}\b/g },
77
+ { id: 'jwt', re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g },
78
+ // The key name is prefix-aware — DB_PASSWORD, aws_secret_access_key — and the
79
+ // value may be quoted or bare, because .env and YAML do not quote (review 0003 Q24).
80
+ {
81
+ id: 'assigned-secret',
82
+ re: /\b[A-Za-z0-9_.-]*(?:password|passwd|secret|token|api[_-]?key|access[_-]?key|client[_-]?secret)\b\s*[:=]\s*(?:"([^"]{8,})"|'([^']{8,})'|([A-Za-z0-9+/=_-]{8,}))/gi,
83
+ groups: [1, 2, 3],
84
+ },
85
+ // The password half of https://user:pw@host: the only place a URL carries one.
86
+ {
87
+ id: 'url-userinfo',
88
+ re: /\b[a-z][a-z0-9+.-]*:\/\/[^:/\s@]+:([A-Za-z0-9._~+-]{8,})@/g,
89
+ groups: [1],
90
+ },
91
+ ];
92
+
93
+ /** Longest base64ish run worth measuring for entropy. */
94
+ const ENTROPY_CANDIDATE = /[A-Za-z0-9+/=_-]{32,}/g;
95
+
96
+ /**
97
+ * Subresource-integrity hashes: `sha256-<base64>` and kin. A deterministic
98
+ * digest of file content, present in every lockfile and build manifest —
99
+ * high-entropy bytes that are never a secret (review 0003 Q24).
100
+ */
101
+ const SRI_HASH = /^sha(1|256|384|512)-[A-Za-z0-9+/]+={0,2}$/;
102
+
103
+ /**
104
+ * Shannon entropy of a string, in bits per character.
105
+ *
106
+ * The detector's whole discriminator in one function: a uniformly random token
107
+ * approaches log2(alphabet size) — near 6 bits for base64 — while English,
108
+ * hex digests and repeated identifiers fall below 4.5. A sha256 hex digest tops
109
+ * out at 4.0 over sixteen symbols, which is exactly why the threshold sits above
110
+ * it: a commit hash is not a secret, and a gate that calls it one gets disabled.
111
+ */
112
+ export function entropy(text) {
113
+ const freq = new Map();
114
+ for (const ch of text) freq.set(ch, (freq.get(ch) ?? 0) + 1);
115
+ let bits = 0;
116
+ for (const n of freq.values()) {
117
+ const p = n / text.length;
118
+ bits -= p * Math.log2(p);
119
+ }
120
+ return bits;
121
+ }
122
+
123
+ /** The four characters a finding may show: the prefix a vendor publishes as a type. */
124
+ function redact(value) {
125
+ return `${value.slice(0, 4)}…`;
126
+ }
127
+
128
+ /**
129
+ * Every secret span in the text: pattern matches in library order, then entropy
130
+ * over whatever no pattern claimed. One function because the gate report and
131
+ * the redactor must agree on what a secret is: two matchers drift, and the
132
+ * drift is a token one of them stores (review 0003 Q36).
133
+ *
134
+ * @param {string} text
135
+ * @returns {{start: number, end: number, kind: string, preview: string, entropy?: number}[]}
136
+ */
137
+ function findSpans(text) {
138
+ const found = [];
139
+ const claimed = [];
140
+
141
+ for (const pattern of PATTERNS) {
142
+ pattern.re.lastIndex = 0;
143
+ for (const match of text.matchAll(pattern.re)) {
144
+ const groups = pattern.groups ?? [0];
145
+ let value = null;
146
+ for (const g of groups) {
147
+ if (match[g] !== undefined) {
148
+ value = match[g];
149
+ break;
150
+ }
151
+ }
152
+ if (value === null) continue;
153
+ if (isPlaceholder(value)) continue;
154
+ const at = match.index + match[0].indexOf(value);
155
+ // Overlap, not equality: a token a pattern named is one finding, and the
156
+ // entropy reading of the same bytes would double the count a gate reports.
157
+ if (claimed.some(([a, b]) => at < b && a < at + value.length)) continue;
158
+ claimed.push([at, at + value.length]);
159
+ found.push({ start: at, end: at + value.length, kind: pattern.id, preview: redact(value) });
160
+ }
161
+ }
162
+
163
+ ENTROPY_CANDIDATE.lastIndex = 0;
164
+ for (const match of text.matchAll(ENTROPY_CANDIDATE)) {
165
+ const value = match[0];
166
+ if (value.length < ENTROPY_MIN_LENGTH) continue;
167
+ if (SRI_HASH.test(value)) continue;
168
+ if (isPlaceholder(value)) continue;
169
+ if (claimed.some(([a, b]) => match.index < b && a < match.index + value.length)) continue;
170
+ const bits = entropy(value);
171
+ if (bits < ENTROPY_MIN_BITS) continue;
172
+ found.push({
173
+ start: match.index,
174
+ end: match.index + value.length,
175
+ kind: 'high-entropy',
176
+ preview: redact(value),
177
+ entropy: Number(bits.toFixed(1)),
178
+ });
179
+ }
180
+
181
+ return found;
182
+ }
183
+
184
+ /**
185
+ * Scan one line's worth of added text.
186
+ *
187
+ * @param {string} text the added line, without the leading `+`
188
+ * @returns {{kind: string, preview: string, entropy?: number}[]} findings in
189
+ * pattern order, then entropy over whatever no pattern claimed
190
+ */
191
+ export function scanText(text) {
192
+ return findSpans(text).map(({ kind, preview, entropy }) =>
193
+ entropy === undefined ? { kind, preview } : { kind, preview, entropy },
194
+ );
195
+ }
196
+
197
+ /**
198
+ * Replace every secret span with `[redacted:<kind>]`, leaving the rest of the
199
+ * text byte-identical. Lesson candidates store the commands that failed, and a
200
+ * command can carry a token (`curl -H "Authorization: Bearer …"`); storing it
201
+ * verbatim writes the secret into a JSONL file that is then read, rendered and
202
+ * injected into prompts (review 0003 Q36).
203
+ *
204
+ * @param {string} text
205
+ * @returns {string} the text with secret values replaced
206
+ */
207
+ export function redactText(text) {
208
+ const spans = findSpans(String(text ?? '')).sort((a, b) => b.start - a.start);
209
+ let out = String(text ?? '');
210
+ for (const span of spans) {
211
+ out = `${out.slice(0, span.start)}[redacted:${span.kind}]${out.slice(span.end)}`;
212
+ }
213
+ return out;
214
+ }
215
+
216
+ /**
217
+ * Lockfiles are skipped by default (review 0003 Q24): their high-entropy bytes
218
+ * are integrity hashes and version pins, and a dependency bump is a commit the
219
+ * gate must not block. The SRI allow-list above covers the same shapes wherever
220
+ * they appear outside a lockfile.
221
+ */
222
+ const LOCKFILES = new Set([
223
+ 'package-lock.json',
224
+ 'npm-shrinkwrap.json',
225
+ 'yarn.lock',
226
+ 'bun.lockb',
227
+ 'pnpm-lock.yaml',
228
+ 'Cargo.lock',
229
+ 'go.sum',
230
+ 'composer.lock',
231
+ 'Gemfile.lock',
232
+ 'Pipfile.lock',
233
+ 'poetry.lock',
234
+ 'uv.lock',
235
+ 'pubspec.lock',
236
+ 'mix.lock',
237
+ ]);
238
+
239
+ /** A line carrying this marker is the author's explicit exemption for that line. */
240
+ const PRAGMA = 'simt:allow-secret';
241
+
242
+ /**
243
+ * One glob to one anchored regex: `**` crosses separators, `*` and `?` do not.
244
+ * The same small matcher the ignore lists render with, because a repository
245
+ * that writes `vendor/**` in `.simtignore` expects the same reading here.
246
+ */
247
+ function globToRegExp(glob) {
248
+ let re = '';
249
+ for (let i = 0; i < glob.length; i += 1) {
250
+ const c = glob[i];
251
+ if (c === '*') {
252
+ if (glob[i + 1] === '*') {
253
+ re += '.*';
254
+ i += 1;
255
+ } else {
256
+ re += '[^/]*';
257
+ }
258
+ } else if (c === '?') {
259
+ re += '[^/]';
260
+ } else {
261
+ re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
262
+ }
263
+ }
264
+ return new RegExp(`^${re}$`);
265
+ }
266
+
267
+ /**
268
+ * Scan the staged diff: the added lines of what the next commit would record.
269
+ *
270
+ * File attribution is a hunk state machine rather than a prefix test, because
271
+ * the two are indistinguishable by prefix alone: an added line whose content
272
+ * starts with `++ ` renders as `+++ something`, which looks exactly like a
273
+ * `+++ b/…` file header. Headers appear only outside hunks and content only
274
+ * inside them, so the `diff --git` boundary and the `@@` header decide which
275
+ * one a line is.
276
+ *
277
+ * @param {string} root repository root
278
+ * @param {{ignore?: string[]}} [options] `ignore` is the manifest's
279
+ * `gates.secretScan.ignore`: globs of repository paths to skip (review 0003 Q24)
280
+ * @returns {Promise<{findings: {file: string, line: number, kind: string,
281
+ * preview: string, entropy?: number}[], lines: number, error: string|null}>}
282
+ * `error` is set only when git itself could not answer; callers fail closed on
283
+ * it rather than reading silence as a clean diff.
284
+ */
285
+ export async function scanStaged(root, options = {}) {
286
+ const ignore = (Array.isArray(options.ignore) ? options.ignore : [])
287
+ .filter((g) => typeof g === 'string' && g !== '')
288
+ .map(globToRegExp);
289
+
290
+ let stdout;
291
+ try {
292
+ // Every flag here defeats a way a repository can keep the scan from seeing its
293
+ // own staged bytes (review 0003 Q02): `diff.external` routes the diff to a
294
+ // program that prints nothing, `-diff` in `.gitattributes` and genuinely binary
295
+ // files make git print no text at all, a textconv rewrites the bytes on the way
296
+ // 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
+ ));
315
+ } catch (error) {
316
+ const stderr = String(error.stderr ?? '').trim();
317
+ const reason = (stderr || String(error.message ?? error)).split('\n')[0];
318
+ return { findings: [], lines: 0, error: reason };
319
+ }
320
+
321
+ const findings = [];
322
+ let lines = 0;
323
+ let file = null;
324
+ let lineNo = 0;
325
+ let inHunk = false;
326
+ let fileSkipped = false;
327
+
328
+ for (const raw of stdout.split('\n')) {
329
+ if (raw.startsWith('diff --git ')) {
330
+ inHunk = false;
331
+ continue;
332
+ }
333
+ if (raw.startsWith('@@ ')) {
334
+ const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
335
+ if (hunk) {
336
+ lineNo = Number(hunk[1]);
337
+ inHunk = true;
338
+ }
339
+ continue;
340
+ }
341
+ if (!inHunk) {
342
+ // Outside a hunk: headers only. `+++ /dev/null` is the removal side of a
343
+ // deletion; nothing is added under it.
344
+ if (raw.startsWith('+++ ')) {
345
+ const path = raw.slice(4).trim();
346
+ file = path === '/dev/null' ? null : path.replace(/^b\//, '');
347
+ const name = file === null ? '' : file.slice(file.lastIndexOf('/') + 1);
348
+ fileSkipped = file !== null && (LOCKFILES.has(name) || ignore.some((re) => re.test(file)));
349
+ }
350
+ continue;
351
+ }
352
+ if (!raw.startsWith('+')) continue; // -U0 has no context; this is `\ No newline…`
353
+ lines += 1;
354
+ if (file !== null && !fileSkipped && !raw.includes(PRAGMA)) {
355
+ for (const hit of scanText(raw.slice(1))) {
356
+ findings.push({ file, line: lineNo, ...hit });
357
+ }
358
+ }
359
+ lineNo += 1;
360
+ }
361
+
362
+ return { findings, lines, error: null };
363
+ }
364
+
365
+ /** The report a failing gate prints: one finding per line, redacted. */
366
+ export function renderFindings(findings) {
367
+ return findings
368
+ .map(
369
+ (f) =>
370
+ ` ${f.file}:${f.line} ${f.kind} ${f.preview}` +
371
+ (f.entropy !== undefined ? ` (entropy ${f.entropy} bits)` : ''),
372
+ )
373
+ .join('\n');
374
+ }
package/src/state.mjs ADDED
@@ -0,0 +1,76 @@
1
+ // Machine-local state lives here, not in the tree (review 0003 Q19).
2
+ //
3
+ // Everything this tool writes for itself — the ledger, the check cache, the
4
+ // index, leases, the journal, briefs, gate logs, bench runs — used to live
5
+ // directly under `.simt/`, so `simt check` changed the tree id its own
6
+ // evidence was measured against, and a slice worktree landed in the main
7
+ // checkout as an embedded gitlink. The fix is one directory: local state
8
+ // goes under `.simt/local/`, which is ignored and excluded from the tree,
9
+ // while committed content (`packs/`, `bin/`, `bench/tasks/`, profiles) stays
10
+ // where it was.
11
+ //
12
+ // Readers try the new path first and fall back to the old one for one
13
+ // release, so a repository whose state predates the move keeps working until
14
+ // the next write migrates it. Writers always write the new path.
15
+
16
+ import { readFile } from 'node:fs/promises';
17
+ import { join } from 'node:path';
18
+
19
+ import { STATE_DIR } from './name.mjs';
20
+
21
+ /** The ignored directory every machine-local write goes under. */
22
+ export const LOCAL_DIR = join(STATE_DIR, 'local');
23
+
24
+ /** A machine-local path, relative to the repository root. */
25
+ export function localRel(...parts) {
26
+ return join(LOCAL_DIR, ...parts);
27
+ }
28
+
29
+ /**
30
+ * Where a machine-local file is read from: the new path first, then each
31
+ * legacy path in order. Writers use only the first entry.
32
+ */
33
+ function readCandidates(root, rel, legacy = []) {
34
+ return [join(root, rel), ...legacy.map((l) => join(root, l))];
35
+ }
36
+
37
+ /** The first readable candidate, or null when none exists. */
38
+ export async function readStateFile(root, rel, legacy = []) {
39
+ for (const path of readCandidates(root, rel, legacy)) {
40
+ try {
41
+ return { text: await readFile(path, 'utf8'), path };
42
+ } catch (error) {
43
+ if (error.code !== 'ENOENT') throw error;
44
+ }
45
+ }
46
+ return { text: null, path: join(root, rel) };
47
+ }
48
+
49
+ /**
50
+ * Every machine-local path the tree id must exclude: the whole local area
51
+ * plus the legacy paths a pre-move checkout may still carry, so an old
52
+ * `check.json` or worktree can neither change the id nor be committed.
53
+ */
54
+ export const TREE_EXCLUDES = [
55
+ LOCAL_DIR,
56
+ join(STATE_DIR, 'ledger.jsonl'),
57
+ join(STATE_DIR, 'lessons.jsonl'),
58
+ join(STATE_DIR, 'leases.json'),
59
+ join(STATE_DIR, 'check.json'),
60
+ join(STATE_DIR, 'index.sqlite'),
61
+ join(STATE_DIR, 'index.jsonl'),
62
+ join(STATE_DIR, 'brief.md'),
63
+ join(STATE_DIR, 'journal'),
64
+ join(STATE_DIR, 'worktrees'),
65
+ join(STATE_DIR, 'gate'),
66
+ join(STATE_DIR, 'distilled'),
67
+ join(STATE_DIR, 'reviews'),
68
+ join(STATE_DIR, 'proposals'),
69
+ join(STATE_DIR, 'compiled'),
70
+ join(STATE_DIR, 'critic-labels.jsonl'),
71
+ join(STATE_DIR, 'bench', 'runs'),
72
+ join(STATE_DIR, 'bench', 'vault'),
73
+ join(STATE_DIR, 'bench', 'prices.json'),
74
+ join(STATE_DIR, 'bench', 'ablation.json'),
75
+ join(STATE_DIR, 'bench', 'probes'),
76
+ ];
package/src/status.mjs ADDED
@@ -0,0 +1,157 @@
1
+ // The `--line` status cache (plan 0003 R48).
2
+ //
3
+ // `status --line` is for shell prompts, tmux and agent status lines: it must
4
+ // answer in well under 50 ms, which rules out everything `check` does (rule
5
+ // loading, projection, gate probing). So `check` — the one command that
6
+ // already measures all of it — writes `.simt/check.json` on every run, and
7
+ // `--line` reads only that file plus one `git rev-parse HEAD` to notice new
8
+ // commits. No fingerprint, no rule reads, no capability probe.
9
+ //
10
+ // Three honesties, each a failure this module refuses to be:
11
+ //
12
+ // - The cache is local state, never content (the same standing as the
13
+ // journal, the ledger and the index). Writers that change files `check`
14
+ // reads (`init`, `sync`, `upgrade`, `hooks install`, `undo`) delete it
15
+ // when they apply, so the line goes back to "no cache" instead of
16
+ // reporting the tree as it was. Hand edits between checks are the
17
+ // documented blind spot: the line is the gate state *as of the last
18
+ // check*, and says `stale` only when HEAD moved.
19
+ // - A blocking finding that fits no slot still fails the line. The version
20
+ // pin, an invalid rule, a bad gate cwd — none of these is rules, hooks
21
+ // or gates-count, and a line that showed all slots green while the gate
22
+ // fails would be the lie prompts are for. So PASS/FAIL is carried
23
+ // explicitly rather than inferred from the symbols.
24
+ // - The write is atomic (tmp + rename) and fixed-size: only counts and
25
+ // booleans are stored, never findings, so a huge gate still leaves a
26
+ // ~200-byte file and a killed `check` never leaves half a document.
27
+
28
+ import { execFile } from 'node:child_process';
29
+ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
30
+ import { join } from 'node:path';
31
+ import { promisify } from 'node:util';
32
+
33
+ import { readHooksState, wantsShim } from './compare/hooksstate.mjs';
34
+ import { LOCAL_DIR } from './state.mjs';
35
+
36
+ const exec = promisify(execFile);
37
+
38
+ const CACHE_FILE = join(LOCAL_DIR, 'check.json');
39
+ const CACHE_FILE_LEGACY = '.simt/check.json';
40
+ const CACHE_VERSION = 1;
41
+
42
+ /**
43
+ * The commit the cached state was measured at, or null when git cannot say
44
+ * (not a repository, no commits yet, git missing). Null is trust, not failure:
45
+ * without a HEAD there is nothing to compare, and refusing the line would
46
+ * make exported trees — the ones with no `.git` at all — unreportable.
47
+ */
48
+ export async function currentHead(root) {
49
+ try {
50
+ const { stdout } = await exec('git', ['rev-parse', 'HEAD'], { cwd: root });
51
+ return stdout.trim() || null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ function cachePath(root) {
58
+ return join(root, CACHE_FILE);
59
+ }
60
+
61
+ /**
62
+ * Add the when and the where to a check-measured summary. The summary itself
63
+ * is built by `check`, which is the only command that measures all of it;
64
+ * this only stamps it.
65
+ */
66
+ export async function buildCacheRecord(root, summary) {
67
+ return {
68
+ version: CACHE_VERSION,
69
+ at: Date.now(),
70
+ head: await currentHead(root),
71
+ rulesOk: summary.rulesOk,
72
+ hooksOk: summary.hooksOk,
73
+ gatesRun: summary.gatesRun,
74
+ gatesTotal: summary.gatesTotal,
75
+ blocking: summary.blocking,
76
+ warnings: summary.warnings,
77
+ };
78
+ }
79
+
80
+ /** Atomically replace the cache. A killed `check` leaves the previous record, not half of one. */
81
+ export async function writeCheckCache(root, record) {
82
+ const path = cachePath(root);
83
+ await mkdir(join(root, LOCAL_DIR), { recursive: true });
84
+ const body = `${JSON.stringify(record)}\n`;
85
+ const tmp = `${path}.tmp-${process.pid}`;
86
+ await writeFile(tmp, body, 'utf8');
87
+ await rename(tmp, path);
88
+ }
89
+
90
+ /** The last record `check` wrote, or null when it never ran (or a writer invalidated it). */
91
+ export async function readCheckCache(root) {
92
+ // The pre-move path is a fallback for one release (review 0003 Q19).
93
+ let raw;
94
+ try {
95
+ raw = await readFile(cachePath(root), 'utf8').catch(async (error) => {
96
+ if (error.code !== 'ENOENT') throw error;
97
+ return readFile(join(root, CACHE_FILE_LEGACY), 'utf8');
98
+ });
99
+ } catch (error) {
100
+ if (error.code === 'ENOENT') return null;
101
+ throw error;
102
+ }
103
+ try {
104
+ const record = JSON.parse(raw);
105
+ if (record.version !== CACHE_VERSION) return null;
106
+ return record;
107
+ } catch {
108
+ // A damaged cache is no cache: the next `check` rewrites it, and a prompt
109
+ // that refused to render over a corrupt byte would stay broken until then.
110
+ return null;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Forget the cached state. Called by writers that change files `check` reads,
116
+ * after they apply — never on preview or dry-run, where nothing changed and
117
+ * the record is still true.
118
+ */
119
+ export async function invalidateCheckCache(root) {
120
+ try {
121
+ await unlink(cachePath(root));
122
+ } catch (error) {
123
+ if (error.code === 'ENOENT') return;
124
+ throw error;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * The hooks slot of the line: every declared gate routed through a current
130
+ * shim. A repository that declares no gate has nothing to enforce, so the
131
+ * slot is vacuously true rather than forever red (the same answer `init`
132
+ * prints in prose). Gates run/total is counted by `check` from the runnable
133
+ * verdicts, not here: routing and runnability are different slots.
134
+ */
135
+ export async function hooksSummary(root, manifest, exists) {
136
+ const declared = [...(manifest?.gates?.preCommit ?? []), ...(manifest?.gates?.prePush ?? [])];
137
+ const evidence = manifest?.gates?.evidence === 'warn' || manifest?.gates?.evidence === 'block';
138
+ if (declared.length === 0 && !evidence) return { ok: true };
139
+ const state = await readHooksState(root, { declaresGate: (event) => wantsShim(manifest, exists, event) });
140
+ return { ok: state.complete };
141
+ }
142
+
143
+ /**
144
+ * One line, always: `simt PASS ✓rules ✓hooks 3/3gates`, longest honest case
145
+ * `simt FAIL ✗rules ✗hooks 0/12gates ⚠9 stale`. Counts stay small because they
146
+ * count gates and warnings, never findings; the ≤ 60-char budget is asserted
147
+ * on a synthetic worst case, not on the hope that gates stay few.
148
+ */
149
+ export function renderStatusLine(record, stale) {
150
+ const outcome = record.blocking === 0 ? 'PASS' : 'FAIL';
151
+ const rules = record.rulesOk ? '✓rules' : '✗rules';
152
+ const hooks = record.hooksOk ? '✓hooks' : '✗hooks';
153
+ let line = `simt ${outcome} ${rules} ${hooks} ${record.gatesRun}/${record.gatesTotal}gates`;
154
+ if (record.warnings > 0) line += ` ⚠${record.warnings}`;
155
+ if (stale) line += ' stale';
156
+ return line;
157
+ }
package/src/toolwords.mjs CHANGED
@@ -22,6 +22,7 @@ import { VARIANTS_MARKER } from './kernel/variants.mjs';
22
22
  import { LESSON_KEYS } from './memory/lessons.mjs';
23
23
  import { DECISION_KEYS } from './memory/decide.mjs';
24
24
  import { PROMPT_WORDS } from './mcp/prompts.mjs';
25
+ import { AGENT_STEM, SKILL_STEM } from './agents/render.mjs';
25
26
 
26
27
  /**
27
28
  * `simt-` names that belong to this tool and never to a repository.
@@ -45,6 +46,10 @@ export const TOOL_WORDS = new Set([
45
46
  'simt-dev',
46
47
  'simt-dev-check',
47
48
  'simt-hooks',
49
+ // The one agent this tool ships, named by the stem every harness's agents file shares.
50
+ AGENT_STEM,
51
+ // The companion skill the agent reads on demand; its stem is the directory name.
52
+ SKILL_STEM,
48
53
  ]);
49
54
 
50
55
  /**