simt-dev 0.6.3 → 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 (137) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +2 -2
  3. package/agents/simt-agent.md +84 -0
  4. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  5. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  6. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  7. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  8. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  9. 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
  10. 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
  11. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  12. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  13. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  14. 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
  15. 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
  16. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  17. package/docs/decisions/README.md +9 -0
  18. package/package.json +4 -2
  19. package/rules/core.md +2 -2
  20. package/rules/mdc/test-gate.mdc +2 -0
  21. package/rules/stacks/android.md +3 -3
  22. package/rules/stacks/connectiq.md +2 -2
  23. package/rules/stacks/dart.md +1 -1
  24. package/rules/stacks/deno.md +1 -1
  25. package/rules/stacks/dotnet.md +1 -1
  26. package/rules/stacks/elixir.md +1 -1
  27. package/rules/stacks/go.md +1 -1
  28. package/rules/stacks/jvm.md +3 -2
  29. package/rules/stacks/node.md +2 -2
  30. package/rules/stacks/python.md +7 -6
  31. package/rules/stacks/ruby.md +1 -1
  32. package/rules/stacks/rust.md +1 -1
  33. package/rules/stacks/swift.md +1 -1
  34. package/rules/stacks/typescript.md +2 -2
  35. package/rules/stacks/uv.md +3 -2
  36. package/skills/simt-commands/SKILL.md +70 -0
  37. package/src/advice/advice.mjs +7 -3
  38. package/src/agenthooks/decide.mjs +141 -9
  39. package/src/agenthooks/opencode.mjs +72 -0
  40. package/src/agenthooks/prompt.mjs +6 -2
  41. package/src/agenthooks/render.mjs +186 -19
  42. package/src/agenthooks/shim.mjs +55 -0
  43. package/src/agents/render.mjs +277 -0
  44. package/src/argv.mjs +8 -0
  45. package/src/bench/ablate.mjs +31 -14
  46. package/src/bench/mine.mjs +48 -1
  47. package/src/bench/probes.mjs +5 -6
  48. package/src/bench/report.mjs +243 -80
  49. package/src/bench/run.mjs +12 -11
  50. package/src/bench/variants.mjs +112 -40
  51. package/src/brief/assemble.mjs +6 -0
  52. package/src/ci/workflow.mjs +16 -7
  53. package/src/commands/agent.mjs +206 -0
  54. package/src/commands/agenthook.mjs +109 -15
  55. package/src/commands/bench.mjs +2 -1
  56. package/src/commands/compare.mjs +8 -7
  57. package/src/commands/decide.mjs +6 -5
  58. package/src/commands/evidence.mjs +13 -3
  59. package/src/commands/gate.mjs +85 -19
  60. package/src/commands/graph.mjs +18 -5
  61. package/src/commands/help.mjs +35 -14
  62. package/src/commands/hooks.mjs +68 -25
  63. package/src/commands/impact.mjs +4 -2
  64. package/src/commands/index.mjs +17 -4
  65. package/src/commands/init.mjs +169 -37
  66. package/src/commands/lease.mjs +203 -35
  67. package/src/commands/lessons.mjs +65 -9
  68. package/src/commands/map.mjs +33 -18
  69. package/src/commands/plugin.mjs +1 -1
  70. package/src/commands/repoindex.mjs +1 -0
  71. package/src/commands/rules.mjs +92 -18
  72. package/src/commands/skills.mjs +60 -25
  73. package/src/commands/skillscompile.mjs +8 -7
  74. package/src/commands/skillsinstall.mjs +12 -0
  75. package/src/commands/slice.mjs +377 -18
  76. package/src/commands/sync.mjs +37 -22
  77. package/src/commands/tests.mjs +5 -2
  78. package/src/commands/undo.mjs +24 -16
  79. package/src/commands/upgrade.mjs +33 -1
  80. package/src/commands/why.mjs +10 -4
  81. package/src/compare/hooksstate.mjs +23 -1
  82. package/src/compare/learn.mjs +82 -57
  83. package/src/compare/parallel.mjs +50 -0
  84. package/src/compare/row.mjs +26 -1
  85. package/src/completions.mjs +5 -0
  86. package/src/decisions/dir.mjs +84 -0
  87. package/src/decisions/index.mjs +109 -11
  88. package/src/distill/distill.mjs +3 -1
  89. package/src/ecosystem/node-runner.mjs +144 -0
  90. package/src/explain.mjs +18 -6
  91. package/src/graph/load.mjs +38 -19
  92. package/src/hooks/capability.mjs +14 -1
  93. package/src/hooks/dispatcher.mjs +335 -13
  94. package/src/hooks/resolve.mjs +9 -3
  95. package/src/hooks/shim.mjs +12 -7
  96. package/src/identity.mjs +67 -14
  97. package/src/index/extract.mjs +192 -11
  98. package/src/index/persist.mjs +307 -47
  99. package/src/index/store.mjs +263 -60
  100. package/src/index/tests.mjs +6 -35
  101. package/src/journal.mjs +296 -77
  102. package/src/kernel/card.mjs +37 -12
  103. package/src/kernel/checks.mjs +44 -9
  104. package/src/kernel/variants.mjs +27 -16
  105. package/src/lease.mjs +97 -15
  106. package/src/ledger/index.mjs +91 -25
  107. package/src/mcp/resources.mjs +5 -3
  108. package/src/mcp/server.mjs +81 -22
  109. package/src/mcp/tools.mjs +45 -9
  110. package/src/memory/decide.mjs +98 -43
  111. package/src/memory/lessons.mjs +168 -33
  112. package/src/name.mjs +9 -0
  113. package/src/plan/index.mjs +31 -8
  114. package/src/plan/template.mjs +27 -5
  115. package/src/plugins.mjs +6 -2
  116. package/src/prbody.mjs +7 -5
  117. package/src/projection/baselines.mjs +1 -1
  118. package/src/projection/disclosure.mjs +2 -2
  119. package/src/projection/loaders.mjs +41 -1
  120. package/src/projection/lock.mjs +21 -14
  121. package/src/projection/manifest.mjs +69 -19
  122. package/src/projection/markers.mjs +43 -3
  123. package/src/projection/mdc.mjs +34 -7
  124. package/src/projection/project.mjs +162 -18
  125. package/src/projection/rules.mjs +54 -3
  126. package/src/projection/surfaces.mjs +9 -1
  127. package/src/repo-root.mjs +69 -0
  128. package/src/resolve/body.mjs +4 -0
  129. package/src/resolve/fingerprint.mjs +5 -0
  130. package/src/resolve/local.mjs +58 -0
  131. package/src/review/critic.mjs +4 -2
  132. package/src/secrets.mjs +165 -22
  133. package/src/state.mjs +76 -0
  134. package/src/status.mjs +9 -4
  135. package/src/toolwords.mjs +5 -0
  136. package/src/tsconfig.mjs +39 -99
  137. package/templates/plan/tracker.json +1 -1
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,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
  /**
package/src/tsconfig.mjs CHANGED
@@ -1,73 +1,36 @@
1
- // `tsconfig.base.json`: the strictness floor every TypeScript repo in the estate agrees on.
1
+ // `tsconfig.base.json`: the strictness floor a TypeScript repo writes for itself.
2
2
  //
3
- // This is ADR-026's mechanism — a config whose grammar has no comments is a whole file the
4
- // repo owns from the first commit — pointed at the file ADR-025 ruled out by name. Two
5
- // consequences shape everything here, and both are easy to get wrong:
3
+ // ADR-026's mechanism — a config whose grammar has no comments is a whole file the
4
+ // repo owns from the first commit — pointed at the file ADR-025 ruled out by name.
5
+ // `init` used to create it (review 0003 Q28 stopped that): a floor the tool writes
6
+ // into a fresh repo is a file nothing extends, because the repo's own `tsconfig.json`
7
+ // is the repo's to write — and a base that nothing points at is a floor with no
8
+ // effect, which `check` then reported as drift on every fresh adoption.
6
9
  //
7
- // 1. A base file is a FLOOR, not a policy. `extends` lets a child override any key, so a
8
- // base can only repair a repo that is *missing* a flag. It can never repair one that
9
- // spells a flag differently — the child wins and the base is inert for that key. Every
10
- // key below is therefore one the estate already agrees on, and the one place the estate
11
- // disagrees is a check instead (see `checkTsconfig`).
12
- // 2. The bytes cannot explain themselves. JSON has no comments, so this file ships with no
13
- // header saying who wrote it or what a reader must do next, which is normally the one
14
- // thing a shipped file owes its reader. `init` prints the `extends` line it requires
15
- // and `check` reports a repo that has not adopted it, so the obligation is met by the
16
- // tool's output rather than the file's.
10
+ // What is left here is the reporting side of ADR-026, and it reports exactly the two
11
+ // states a base file can be in that no file can fix:
17
12
  //
18
- // The whole file is owned by `init` alone. It is not in `src/ci/` and not in
19
- // `src/projection/baselines.mjs` because it is neither a `.github` file that `simt ci`
20
- // refreshes nor a marker region: `simt check` reports its absence and never its contents.
13
+ // 1. `inert` — the base exists and `tsconfig.json` does not extend it, so the floor
14
+ // is in the repository and not in effect. `extends` lets a child override any key,
15
+ // so a base can only repair a repo that is *missing* a flag; it can never repair
16
+ // one that does not point at it.
17
+ // 2. `inconsistent` — `moduleResolution` is spelled the way the parser accepts but
18
+ // the documentation does not. The parser is case-insensitive, which is the whole
19
+ // reason the estate drifted: both spellings work, so nothing ever said so.
20
+ //
21
+ // Both are non-blocking findings (review 0003 Q28): the base file is repo-owned, and
22
+ // a gate that fails a repository over a policy decision is a gate whose remedy is a
23
+ // choice, not a command. An existing base file stays exactly as the repo wrote it:
24
+ // this tool does not own its bytes, so a repo that edited them is not drift.
21
25
 
22
26
  import { readFile } from 'node:fs/promises';
23
27
  import { join } from 'node:path';
24
28
 
25
29
  import { fingerprint } from './resolve/fingerprint.mjs';
26
- import { CLI } from './name.mjs';
27
30
 
28
31
  export const TSCONFIG_BASE_FILE = 'tsconfig.base.json';
29
32
  export const TSCONFIG_FILE = 'tsconfig.json';
30
33
 
31
- /**
32
- * The strictness floor.
33
- *
34
- * Every key here is already set, and set the same way, by every `tsconfig.json` in the
35
- * estate that could afford it. One repository is the outlier and it is not one on purpose: it
36
- * is missing the seven flags below that the three social repos share, which is the gap
37
- * plan section 3.1 row 5 named. Nothing in this object is a value anybody has argued about;
38
- * it is the intersection of what four repositories independently wrote.
39
- *
40
- * Absent, and each for a reason that would be invisible to the next reader:
41
- *
42
- * - `noEmit`. node.md:36 wants it for "any project that bundles elsewhere", and it would
43
- * be harmless to inherit here because a child can set `false`. It is left out because a
44
- * base that hands `noEmit` to a repo nobody looked at is a build that stops emitting,
45
- * and the two repositories that bundle elsewhere did not ask for it. A floor nobody
46
- * opted into is not a floor.
47
- * - `moduleResolution`. All four repositories set it; three write `Bundler` and one writes
48
- * `bundler`. Shipping either would change nothing, because the child overrides the base
49
- * and the disagreement is precisely that the child sets it. `check` reports the spelling
50
- * instead.
51
- * - `target`, `lib`, `module`. Genuinely per-repo: `lib` is DOM+DOM.Iterable in two
52
- * repositories and DOM+WebWorker in the other two, and a base that chose would silence
53
- * one of them. These describe the platform, not the estate.
54
- * - `include`, `exclude`, `paths`, `types`, `jsx`. What a project is made of.
55
- */
56
- export const STRICTNESS_FLOOR = {
57
- strict: true,
58
- noUncheckedIndexedAccess: true,
59
- exactOptionalPropertyTypes: true,
60
- noImplicitOverride: true,
61
- useUnknownInCatchVariables: true,
62
- noFallthroughCasesInSwitch: true,
63
- noImplicitReturns: true,
64
- isolatedModules: true,
65
- verbatimModuleSyntax: true,
66
- allowJs: false,
67
- resolveJsonModule: true,
68
- skipLibCheck: true,
69
- };
70
-
71
34
  /**
72
35
  * `moduleResolution` as TypeScript documents it.
73
36
  *
@@ -87,19 +50,6 @@ export const DOCUMENTED_RESOLUTIONS = new Set([
87
50
  'bundler',
88
51
  ]);
89
52
 
90
- /**
91
- * The base file, as bytes.
92
- *
93
- * Key order is the order of `STRICTNESS_FLOOR` and not alphabetical, because the object
94
- * literal is the readable form and reordering it here would mean the file and the reason
95
- * disagree. `JSON.stringify` preserves insertion order, so the two cannot drift.
96
- *
97
- * @returns {string} JSON, LF-terminated
98
- */
99
- export function renderTsconfigBase() {
100
- return `${JSON.stringify({ compilerOptions: STRICTNESS_FLOOR }, null, 2)}\n`;
101
- }
102
-
103
53
  /**
104
54
  * Parse a file that TypeScript allows to carry comments.
105
55
  *
@@ -170,21 +120,19 @@ export function readJsonc(text) {
170
120
  }
171
121
 
172
122
  /**
173
- * What this repo's base file should be, and what should happen to it.
123
+ * What this repo's base file is, and what `init` will do about it.
174
124
  *
175
- * There is no `refresh`. ADR-026 gave simt-dev's own `.github` files a refresh path because
176
- * this repository is the template's only author; a base file has the same argument, and it
177
- * is deliberately not extended to it. `simt check` in this repository reports the
178
- * baseline files as unmanaged by design, so a refresh path here would be a way to make this
179
- * repository's own gate green by rewriting the file it is judging — which is the same
180
- * mistake as a gate that edits its own expectations.
125
+ * There is no `refresh`, and since review 0003 Q28 there is no `create` either: the
126
+ * file is the repo's to write, and a floor the tool created was one nothing extended.
127
+ * `init`'s whole contribution is now to say so in the plan — a file that appears after
128
+ * the run without being in the plan is a silent write, and "init will not write this"
129
+ * is a plan line, not an absence of one.
181
130
  *
182
- * The skip case is detection, not politeness: a repository with no TypeScript has nothing to
183
- * extend the base, and a `tsconfig.base.json` in a JavaScript repository is a file that
184
- * claims to be an authority and is read by nothing.
131
+ * `unchanged` means the file exists and is left exactly as it is: this tool does not
132
+ * own its bytes (ADR-026), so a repo that edited them is not drift.
185
133
  *
186
134
  * @param {string} root repo root
187
- * @returns {Promise<{file:string, path:string, action:'create'|'unchanged'|'skip',
135
+ * @returns {Promise<{file:string, path:string, action:'unchanged'|'skip',
188
136
  * exists:boolean, bytes:number, current:boolean, typescript:boolean,
189
137
  * reason:string|null}>}
190
138
  */
@@ -212,16 +160,19 @@ export async function planTsconfigBase(root) {
212
160
  reason: null,
213
161
  };
214
162
  }
163
+ // Absent is a skip in every case: init no longer creates the file (review 0003 Q28),
164
+ // so the plan states what it will not write and why, rather than leaving a floor
165
+ // behind that nothing extends.
215
166
  return {
216
167
  file: TSCONFIG_BASE_FILE,
217
168
  path,
218
- action: typescript ? 'create' : 'skip',
169
+ action: 'skip',
219
170
  exists,
220
171
  bytes: 0,
221
- current: false,
172
+ current: true,
222
173
  typescript,
223
174
  reason: typescript
224
- ? null
175
+ ? 'init no longer creates the base file; a floor is a policy the repo writes for itself'
225
176
  : 'no TypeScript in this repository, so nothing would extend the base',
226
177
  };
227
178
  }
@@ -249,23 +200,12 @@ export async function checkTsconfig(root) {
249
200
  baseExists = false;
250
201
  }
251
202
 
252
- const { stack } = await fingerprint(root);
253
- const typescript = stack.includes('typescript');
254
- if (typescript && !baseExists) {
255
- findings.push({
256
- file: TSCONFIG_BASE_FILE,
257
- status: 'absent',
258
- detail:
259
- 'this repo is TypeScript and has no base file, so every strictness flag is whatever ' +
260
- 'one person happened to write',
261
- remedy: `${CLI} init --yes`,
262
- });
263
- }
264
-
265
203
  // Nothing below reads the base file's contents, and that is deliberate. This tool does not
266
204
  // own those bytes (ADR-026), so a repo that edited them is not drift — and a finding that
267
205
  // compared them would report somebody's work as an error. Only the question of whether
268
- // `tsconfig.json` points at the base is this tool's to ask.
206
+ // `tsconfig.json` points at the base is this tool's to ask. A missing base file raises
207
+ // no finding at all (review 0003 Q28): writing one is the repo's decision, and a gate
208
+ // whose remedy is a policy choice is a gate nothing can satisfy.
269
209
  let own = null;
270
210
  try {
271
211
  own = readJsonc(await readFile(join(root, TSCONFIG_FILE), 'utf8'));
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "states": [
19
19
  { "id": "queued", "write": "Queued", "read": ["queued"] },
20
- { "id": "in-progress", "write": "In progress", "read": ["in progress", "in-progress"] },
20
+ { "id": "in-progress", "write": "In progress", "read": ["in progress", "in-progress"], "role": "started" },
21
21
  { "id": "complete", "write": "Complete", "read": ["complete", "completed", "done"], "satisfies": true },
22
22
  { "id": "accepted", "write": "Accepted", "read": ["accepted"] }
23
23
  ],