simt-dev 0.6.3 → 0.7.1

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 (187) hide show
  1. package/CHANGELOG.md +162 -841
  2. package/README.md +21 -135
  3. package/agents/simt-agent.md +84 -0
  4. package/bin/simt.mjs +19 -55
  5. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  6. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  7. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  8. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  9. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  10. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  11. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  12. 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
  13. 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
  14. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  15. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  16. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  17. 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
  18. 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
  19. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  20. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  21. 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
  22. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  23. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  24. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  25. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  26. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  27. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  28. package/package.json +13 -3
  29. package/rules/core.md +9 -2
  30. package/rules/mdc/slice-commit.mdc +57 -4
  31. package/rules/mdc/test-gate.mdc +2 -0
  32. package/rules/stacks/android.md +3 -3
  33. package/rules/stacks/connectiq.md +2 -2
  34. package/rules/stacks/dart.md +1 -1
  35. package/rules/stacks/deno.md +1 -1
  36. package/rules/stacks/dotnet.md +1 -1
  37. package/rules/stacks/elixir.md +1 -1
  38. package/rules/stacks/go.md +1 -1
  39. package/rules/stacks/jvm.md +3 -2
  40. package/rules/stacks/node.md +2 -2
  41. package/rules/stacks/python.md +7 -6
  42. package/rules/stacks/ruby.md +1 -1
  43. package/rules/stacks/rust.md +1 -1
  44. package/rules/stacks/swift.md +1 -1
  45. package/rules/stacks/typescript.md +2 -2
  46. package/rules/stacks/uv.md +3 -2
  47. package/skills/simt-commands/SKILL.md +70 -0
  48. package/src/advice/advice.mjs +7 -3
  49. package/src/agenthooks/decide.mjs +141 -9
  50. package/src/agenthooks/opencode.mjs +72 -0
  51. package/src/agenthooks/prompt.mjs +10 -3
  52. package/src/agenthooks/render.mjs +186 -19
  53. package/src/agenthooks/shim.mjs +55 -0
  54. package/src/agents/render.mjs +277 -0
  55. package/src/api.mjs +29 -0
  56. package/src/argv.mjs +37 -3
  57. package/src/bench/ablate.mjs +31 -14
  58. package/src/bench/adapters.json +16 -0
  59. package/src/bench/corpus.mjs +150 -0
  60. package/src/bench/grade.mjs +23 -49
  61. package/src/bench/mine.mjs +247 -36
  62. package/src/bench/probes.mjs +104 -68
  63. package/src/bench/process.mjs +128 -0
  64. package/src/bench/report.mjs +260 -84
  65. package/src/bench/run.mjs +251 -150
  66. package/src/bench/variants.mjs +112 -40
  67. package/src/brief/assemble.mjs +44 -3
  68. package/src/ci/workflow.mjs +46 -14
  69. package/src/cli.mjs +45 -0
  70. package/src/commands/agent.mjs +205 -0
  71. package/src/commands/agenthook.mjs +113 -20
  72. package/src/commands/bench.mjs +192 -18
  73. package/src/commands/brief.mjs +5 -4
  74. package/src/commands/ci.mjs +2 -1
  75. package/src/commands/compare.mjs +50 -49
  76. package/src/commands/completions.mjs +2 -6
  77. package/src/commands/decide.mjs +10 -9
  78. package/src/commands/distill.mjs +4 -3
  79. package/src/commands/doctor.mjs +11 -7
  80. package/src/commands/evidence.mjs +19 -12
  81. package/src/commands/explain.mjs +0 -1
  82. package/src/commands/gate.mjs +98 -28
  83. package/src/commands/graph.mjs +20 -8
  84. package/src/commands/harness.mjs +3 -2
  85. package/src/commands/help.mjs +160 -31
  86. package/src/commands/hooks.mjs +163 -50
  87. package/src/commands/ignore.mjs +2 -1
  88. package/src/commands/impact.mjs +8 -9
  89. package/src/commands/index.mjs +278 -180
  90. package/src/commands/init.mjs +171 -38
  91. package/src/commands/lease.mjs +206 -39
  92. package/src/commands/lessons.mjs +72 -15
  93. package/src/commands/local.mjs +3 -3
  94. package/src/commands/map.mjs +47 -27
  95. package/src/commands/mcp.mjs +0 -1
  96. package/src/commands/pack.mjs +9 -6
  97. package/src/commands/plan.mjs +6 -5
  98. package/src/commands/plugin.mjs +3 -3
  99. package/src/commands/prbody.mjs +3 -3
  100. package/src/commands/repoindex.mjs +5 -3
  101. package/src/commands/review.mjs +2 -2
  102. package/src/commands/rules.mjs +124 -33
  103. package/src/commands/shared.mjs +2 -3
  104. package/src/commands/skills.mjs +82 -45
  105. package/src/commands/skillscompile.mjs +12 -12
  106. package/src/commands/skillsinstall.mjs +14 -2
  107. package/src/commands/slice.mjs +396 -26
  108. package/src/commands/spec.mjs +151 -0
  109. package/src/commands/status.mjs +4 -7
  110. package/src/commands/sync.mjs +50 -33
  111. package/src/commands/tests.mjs +11 -11
  112. package/src/commands/tokens.mjs +2 -2
  113. package/src/commands/undo.mjs +29 -31
  114. package/src/commands/upgrade.mjs +40 -4
  115. package/src/commands/why.mjs +19 -12
  116. package/src/compare/hooksstate.mjs +23 -1
  117. package/src/compare/learn.mjs +82 -57
  118. package/src/compare/parallel.mjs +50 -0
  119. package/src/compare/row.mjs +26 -3
  120. package/src/completions.mjs +5 -0
  121. package/src/context.mjs +64 -0
  122. package/src/decisions/dir.mjs +84 -0
  123. package/src/decisions/index-doc.mjs +46 -0
  124. package/src/decisions/index.mjs +109 -11
  125. package/src/diff.mjs +1 -1
  126. package/src/distill/distill.mjs +17 -2
  127. package/src/distill/engine.mjs +5 -1
  128. package/src/ecosystem/node-runner.mjs +144 -0
  129. package/src/explain.mjs +18 -6
  130. package/src/graph/load.mjs +42 -20
  131. package/src/hooks/capability.mjs +17 -4
  132. package/src/hooks/dispatcher.mjs +513 -36
  133. package/src/hooks/gates.mjs +0 -43
  134. package/src/hooks/resolve.mjs +11 -10
  135. package/src/hooks/shim.mjs +17 -9
  136. package/src/identity.mjs +96 -15
  137. package/src/index/extract.mjs +192 -11
  138. package/src/index/persist.mjs +307 -47
  139. package/src/index/store.mjs +263 -60
  140. package/src/index/tests.mjs +6 -35
  141. package/src/journal.mjs +372 -84
  142. package/src/kernel/card.mjs +37 -12
  143. package/src/kernel/checks.mjs +44 -9
  144. package/src/kernel/variants.mjs +27 -16
  145. package/src/lease.mjs +97 -15
  146. package/src/ledger/index.mjs +120 -25
  147. package/src/local/client.mjs +4 -6
  148. package/src/locate.mjs +33 -9
  149. package/src/manifest/schema.mjs +213 -0
  150. package/src/mcp/resources.mjs +12 -13
  151. package/src/mcp/server.mjs +151 -38
  152. package/src/mcp/tools.mjs +90 -16
  153. package/src/measure/session.mjs +3 -2
  154. package/src/memory/decide.mjs +98 -43
  155. package/src/memory/lessons.mjs +168 -33
  156. package/src/name.mjs +9 -0
  157. package/src/node-version.mjs +37 -0
  158. package/src/output/index.mjs +111 -3
  159. package/src/packs/manage.mjs +17 -3
  160. package/src/packs.mjs +20 -2
  161. package/src/plan/index.mjs +31 -8
  162. package/src/plan/template.mjs +27 -5
  163. package/src/plugins.mjs +6 -2
  164. package/src/prbody.mjs +7 -5
  165. package/src/projection/baselines.mjs +1 -1
  166. package/src/projection/disclosure.mjs +2 -2
  167. package/src/projection/loaders.mjs +41 -1
  168. package/src/projection/lock.mjs +31 -14
  169. package/src/projection/manifest.mjs +89 -53
  170. package/src/projection/markers.mjs +43 -3
  171. package/src/projection/mdc.mjs +34 -7
  172. package/src/projection/project.mjs +162 -18
  173. package/src/projection/rules.mjs +58 -6
  174. package/src/projection/surfaces.mjs +9 -1
  175. package/src/repo-root.mjs +88 -0
  176. package/src/resolve/body.mjs +4 -0
  177. package/src/resolve/fingerprint.mjs +24 -15
  178. package/src/resolve/local.mjs +58 -0
  179. package/src/review/critic.mjs +6 -3
  180. package/src/secrets.mjs +165 -22
  181. package/src/state.mjs +76 -0
  182. package/src/status.mjs +9 -4
  183. package/src/toolwords.mjs +8 -0
  184. package/src/tsconfig.mjs +39 -99
  185. package/src/workspace.mjs +55 -10
  186. package/templates/plan/tracker.json +1 -1
  187. package/docs/decisions/README.md +0 -591
@@ -0,0 +1,58 @@
1
+ // Repo-local skills as resolver nodes.
2
+ //
3
+ // The registry is what simt-dev ships; the skill directories a repo holds are what
4
+ // that repo owns. After plan 0005 S2 the registry is small and generic, so a skill
5
+ // that lives only in a repo was invisible to `skills find` and `skills brief`, and
6
+ // the NOT SELECTED block points a reader at `skills show` by name - a remedy that
7
+ // 404s for a name the registry does not declare.
8
+ //
9
+ // Presence in the repo's skill directory is the eligibility signal: a skill the repo
10
+ // installed is about the repo, and the stack it belongs to is the stack the repo has.
11
+ // The node's `stack` is therefore the repo's own signals, which makes the stack gate
12
+ // a self-overlap that always passes, and the stack score reads "more this repo's"
13
+ // rather than "compatible with a stranger".
14
+
15
+ import { stat } from 'node:fs/promises';
16
+
17
+ import { BODY_BYTE_BUDGET } from '../graph/compile.mjs';
18
+ import { installedSkills } from '../projection/disclosure.mjs';
19
+
20
+ /**
21
+ * The repo's own skills, as nodes the resolver can rank.
22
+ *
23
+ * Names the registry declares are excluded: the registry entry is canonical and the
24
+ * local file is its installed copy, so keeping both would rank one skill twice.
25
+ *
26
+ * @param {string} root the repository
27
+ * @param {object} registry the loaded registry
28
+ * @param {string[]} repoStack the repo's signals (stack ∪ facts)
29
+ * @returns {Promise<object[]>} resolver-ready nodes, name-sorted
30
+ */
31
+ export async function localSkillNodes(root, registry, repoStack) {
32
+ const installed = await installedSkills(root);
33
+ const known = new Set(registry.skills.map((s) => s.name));
34
+ const nodes = [];
35
+ for (const { name, trigger, file } of installed) {
36
+ if (known.has(name)) continue;
37
+ let size;
38
+ try {
39
+ size = (await stat(file)).size;
40
+ } catch (error) {
41
+ if (error.code !== 'ENOENT') throw error;
42
+ continue; // gone between listing and measuring
43
+ }
44
+ nodes.push({
45
+ name,
46
+ summary: trigger,
47
+ keywords: [],
48
+ capabilities: [],
49
+ stack: [...repoStack],
50
+ paths: [],
51
+ bytes: size,
52
+ deferred: size > BODY_BYTE_BUDGET,
53
+ localFile: file,
54
+ });
55
+ }
56
+ nodes.sort((a, b) => a.name.localeCompare(b.name));
57
+ return nodes;
58
+ }
@@ -32,9 +32,12 @@ import { digestOf, promptHash, proposalFrontmatter } from '../distill/engine.mjs
32
32
 
33
33
  const exec = promisify(execFile);
34
34
 
35
- // Locked on-disk beneath `.simt`, like `.simt/distilled` (R38) — a cache of
35
+ import { LOCAL_DIR } from '../state.mjs';
36
+ import { commandRoot } from '../repo-root.mjs';
37
+
38
+ // Locked on-disk beneath machine-local state, like the distilled cache (R38) —
36
39
  // proposals keyed by content digest, never projected and never committed.
37
- const REVIEW_DIR = '.simt/reviews';
40
+ const REVIEW_DIR = join(LOCAL_DIR, 'reviews');
38
41
  const ENGINE_REVIEW_LOCAL = 'review-local';
39
42
 
40
43
  /** The diff brief's byte cap; `--budget` overrides it, `0` means unlimited. */
@@ -214,7 +217,7 @@ export async function reviewTarget(args, flags, { write = true, commands = [] }
214
217
  if (args.length > 0) {
215
218
  return fail(`unknown review argument ${args[0]}`, EXIT.USAGE, `usage: ${CLI} review [--local]`);
216
219
  }
217
- const root = resolve(flags.root ?? process.cwd());
220
+ const root = resolve(commandRoot(flags));
218
221
  const listed = await tracked(root);
219
222
  if (listed.error) return listed.error;
220
223
 
package/src/secrets.mjs CHANGED
@@ -33,39 +33,73 @@ const ENTROPY_MIN_LENGTH = 32;
33
33
  export const ENTROPY_MIN_BITS = 4.5;
34
34
 
35
35
  /**
36
- * Value markers that mean "this string is here to be replaced". A pattern match
37
- * or entropy hit whose value carries one of these is documentation, not a leak.
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).
38
39
  */
39
- const PLACEHOLDER =
40
- /(example|placeholder|changeme|dummy|redacted|notasecret|your[_-]|xxxx+|<[a-z-]*>|\{\{?[a-z_.-]+\}\}?)/i;
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
+ }
41
55
 
42
56
  /**
43
57
  * The pattern library. Each row names itself so the finding tells the reader
44
58
  * WHICH shape matched — "a secret was found" sends them hunting for it;
45
59
  * "aws-access-key-id on line 12" says what to revoke.
46
60
  *
47
- * `group`, when set, is the capture holding the VALUE — what the placeholder
48
- * check and the redaction run on. Without it the whole match is the value.
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.
49
64
  */
50
65
  const PATTERNS = [
51
66
  { id: 'aws-access-key-id', re: /\b(?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}\b/g },
52
- { id: 'private-key-block', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY(?: BLOCK)?-----/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 },
53
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 },
54
73
  { id: 'slack-token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
55
74
  { id: 'stripe-live-key', re: /\b(?:sk|rk)_live_[0-9a-zA-Z]{24,}\b/g },
56
75
  { id: 'google-api-key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
57
76
  { id: 'openai-style-key', re: /\bsk-[A-Za-z0-9_-]{20,}\b/g },
58
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).
59
80
  {
60
81
  id: 'assigned-secret',
61
- re: /\b(?:password|passwd|secret|token|api[_-]?key|access[_-]?key|client[_-]?secret)\b\s*[:=]\s*["']([^"']{8,})["']/gi,
62
- group: 1,
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],
63
90
  },
64
91
  ];
65
92
 
66
93
  /** Longest base64ish run worth measuring for entropy. */
67
94
  const ENTROPY_CANDIDATE = /[A-Za-z0-9+/=_-]{32,}/g;
68
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
+
69
103
  /**
70
104
  * Shannon entropy of a string, in bits per character.
71
105
  *
@@ -92,27 +126,37 @@ function redact(value) {
92
126
  }
93
127
 
94
128
  /**
95
- * Scan one line's worth of added text.
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).
96
133
  *
97
- * @param {string} text the added line, without the leading `+`
98
- * @returns {{kind: string, preview: string, entropy?: number}[]} findings in
99
- * pattern order, then entropy over whatever no pattern claimed
134
+ * @param {string} text
135
+ * @returns {{start: number, end: number, kind: string, preview: string, entropy?: number}[]}
100
136
  */
101
- export function scanText(text) {
137
+ function findSpans(text) {
102
138
  const found = [];
103
139
  const claimed = [];
104
140
 
105
141
  for (const pattern of PATTERNS) {
106
142
  pattern.re.lastIndex = 0;
107
143
  for (const match of text.matchAll(pattern.re)) {
108
- const value = pattern.group !== undefined ? match[pattern.group] : match[0];
109
- if (PLACEHOLDER.test(value)) continue;
110
- const at = match.index + (pattern.group !== undefined ? match[0].indexOf(value) : 0);
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);
111
155
  // Overlap, not equality: a token a pattern named is one finding, and the
112
156
  // entropy reading of the same bytes would double the count a gate reports.
113
157
  if (claimed.some(([a, b]) => at < b && a < at + value.length)) continue;
114
158
  claimed.push([at, at + value.length]);
115
- found.push({ kind: pattern.id, preview: redact(value) });
159
+ found.push({ start: at, end: at + value.length, kind: pattern.id, preview: redact(value) });
116
160
  }
117
161
  }
118
162
 
@@ -120,16 +164,106 @@ export function scanText(text) {
120
164
  for (const match of text.matchAll(ENTROPY_CANDIDATE)) {
121
165
  const value = match[0];
122
166
  if (value.length < ENTROPY_MIN_LENGTH) continue;
123
- if (PLACEHOLDER.test(value)) continue;
167
+ if (SRI_HASH.test(value)) continue;
168
+ if (isPlaceholder(value)) continue;
124
169
  if (claimed.some(([a, b]) => match.index < b && a < match.index + value.length)) continue;
125
170
  const bits = entropy(value);
126
171
  if (bits < ENTROPY_MIN_BITS) continue;
127
- found.push({ kind: 'high-entropy', preview: redact(value), entropy: Number(bits.toFixed(1)) });
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
+ });
128
179
  }
129
180
 
130
181
  return found;
131
182
  }
132
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
+
133
267
  /**
134
268
  * Scan the staged diff: the added lines of what the next commit would record.
135
269
  *
@@ -141,12 +275,18 @@ export function scanText(text) {
141
275
  * one a line is.
142
276
  *
143
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)
144
280
  * @returns {Promise<{findings: {file: string, line: number, kind: string,
145
281
  * preview: string, entropy?: number}[], lines: number, error: string|null}>}
146
282
  * `error` is set only when git itself could not answer; callers fail closed on
147
283
  * it rather than reading silence as a clean diff.
148
284
  */
149
- export async function scanStaged(root) {
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
+
150
290
  let stdout;
151
291
  try {
152
292
  // Every flag here defeats a way a repository can keep the scan from seeing its
@@ -183,6 +323,7 @@ export async function scanStaged(root) {
183
323
  let file = null;
184
324
  let lineNo = 0;
185
325
  let inHunk = false;
326
+ let fileSkipped = false;
186
327
 
187
328
  for (const raw of stdout.split('\n')) {
188
329
  if (raw.startsWith('diff --git ')) {
@@ -203,12 +344,14 @@ export async function scanStaged(root) {
203
344
  if (raw.startsWith('+++ ')) {
204
345
  const path = raw.slice(4).trim();
205
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)));
206
349
  }
207
350
  continue;
208
351
  }
209
352
  if (!raw.startsWith('+')) continue; // -U0 has no context; this is `\ No newline…`
210
353
  lines += 1;
211
- if (file !== null) {
354
+ if (file !== null && !fileSkipped && !raw.includes(PRAGMA)) {
212
355
  for (const hit of scanText(raw.slice(1))) {
213
356
  findings.push({ file, line: lineNo, ...hit });
214
357
  }
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 CHANGED
@@ -31,11 +31,12 @@ import { join } from 'node:path';
31
31
  import { promisify } from 'node:util';
32
32
 
33
33
  import { readHooksState, wantsShim } from './compare/hooksstate.mjs';
34
- import { STATE_DIR } from './name.mjs';
34
+ import { LOCAL_DIR } from './state.mjs';
35
35
 
36
36
  const exec = promisify(execFile);
37
37
 
38
- const CACHE_FILE = join(STATE_DIR, 'check.json');
38
+ const CACHE_FILE = join(LOCAL_DIR, 'check.json');
39
+ const CACHE_FILE_LEGACY = '.simt/check.json';
39
40
  const CACHE_VERSION = 1;
40
41
 
41
42
  /**
@@ -79,7 +80,7 @@ export async function buildCacheRecord(root, summary) {
79
80
  /** Atomically replace the cache. A killed `check` leaves the previous record, not half of one. */
80
81
  export async function writeCheckCache(root, record) {
81
82
  const path = cachePath(root);
82
- await mkdir(join(root, STATE_DIR), { recursive: true });
83
+ await mkdir(join(root, LOCAL_DIR), { recursive: true });
83
84
  const body = `${JSON.stringify(record)}\n`;
84
85
  const tmp = `${path}.tmp-${process.pid}`;
85
86
  await writeFile(tmp, body, 'utf8');
@@ -88,9 +89,13 @@ export async function writeCheckCache(root, record) {
88
89
 
89
90
  /** The last record `check` wrote, or null when it never ran (or a writer invalidated it). */
90
91
  export async function readCheckCache(root) {
92
+ // The pre-move path is a fallback for one release (review 0003 Q19).
91
93
  let raw;
92
94
  try {
93
- raw = await readFile(cachePath(root), 'utf8');
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
+ });
94
99
  } catch (error) {
95
100
  if (error.code === 'ENOENT') return null;
96
101
  throw error;
package/src/toolwords.mjs CHANGED
@@ -22,6 +22,8 @@ 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';
26
+ import { CORPUS_KIND } from './bench/corpus.mjs';
25
27
 
26
28
  /**
27
29
  * `simt-` names that belong to this tool and never to a repository.
@@ -45,6 +47,12 @@ export const TOOL_WORDS = new Set([
45
47
  'simt-dev',
46
48
  'simt-dev-check',
47
49
  'simt-hooks',
50
+ // The one agent this tool ships, named by the stem every harness's agents file shares.
51
+ AGENT_STEM,
52
+ // The companion skill the agent reads on demand; its stem is the directory name.
53
+ SKILL_STEM,
54
+ // The kind stamp this tool writes into every sealed bench corpus document it exports.
55
+ CORPUS_KIND,
48
56
  ]);
49
57
 
50
58
  /**