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.
- package/CHANGELOG.md +35 -0
- package/README.md +2 -2
- package/agents/simt-agent.md +84 -0
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
- package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
- package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
- package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
- 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
- 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
- package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
- package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
- package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
- 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
- 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
- package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
- package/docs/decisions/README.md +9 -0
- package/package.json +4 -2
- package/rules/core.md +2 -2
- package/rules/mdc/test-gate.mdc +2 -0
- package/rules/stacks/android.md +3 -3
- package/rules/stacks/connectiq.md +2 -2
- package/rules/stacks/dart.md +1 -1
- package/rules/stacks/deno.md +1 -1
- package/rules/stacks/dotnet.md +1 -1
- package/rules/stacks/elixir.md +1 -1
- package/rules/stacks/go.md +1 -1
- package/rules/stacks/jvm.md +3 -2
- package/rules/stacks/node.md +2 -2
- package/rules/stacks/python.md +7 -6
- package/rules/stacks/ruby.md +1 -1
- package/rules/stacks/rust.md +1 -1
- package/rules/stacks/swift.md +1 -1
- package/rules/stacks/typescript.md +2 -2
- package/rules/stacks/uv.md +3 -2
- package/skills/simt-commands/SKILL.md +70 -0
- package/src/advice/advice.mjs +7 -3
- package/src/agenthooks/decide.mjs +141 -9
- package/src/agenthooks/opencode.mjs +72 -0
- package/src/agenthooks/prompt.mjs +6 -2
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/argv.mjs +8 -0
- package/src/bench/ablate.mjs +31 -14
- package/src/bench/mine.mjs +48 -1
- package/src/bench/probes.mjs +5 -6
- package/src/bench/report.mjs +243 -80
- package/src/bench/run.mjs +12 -11
- package/src/bench/variants.mjs +112 -40
- package/src/brief/assemble.mjs +6 -0
- package/src/ci/workflow.mjs +16 -7
- package/src/commands/agent.mjs +206 -0
- package/src/commands/agenthook.mjs +109 -15
- package/src/commands/bench.mjs +2 -1
- package/src/commands/compare.mjs +8 -7
- package/src/commands/decide.mjs +6 -5
- package/src/commands/evidence.mjs +13 -3
- package/src/commands/gate.mjs +85 -19
- package/src/commands/graph.mjs +18 -5
- package/src/commands/help.mjs +35 -14
- package/src/commands/hooks.mjs +68 -25
- package/src/commands/impact.mjs +4 -2
- package/src/commands/index.mjs +17 -4
- package/src/commands/init.mjs +169 -37
- package/src/commands/lease.mjs +203 -35
- package/src/commands/lessons.mjs +65 -9
- package/src/commands/map.mjs +33 -18
- package/src/commands/plugin.mjs +1 -1
- package/src/commands/repoindex.mjs +1 -0
- package/src/commands/rules.mjs +92 -18
- package/src/commands/skills.mjs +60 -25
- package/src/commands/skillscompile.mjs +8 -7
- package/src/commands/skillsinstall.mjs +12 -0
- package/src/commands/slice.mjs +377 -18
- package/src/commands/sync.mjs +37 -22
- package/src/commands/tests.mjs +5 -2
- package/src/commands/undo.mjs +24 -16
- package/src/commands/upgrade.mjs +33 -1
- package/src/commands/why.mjs +10 -4
- package/src/compare/hooksstate.mjs +23 -1
- package/src/compare/learn.mjs +82 -57
- package/src/compare/parallel.mjs +50 -0
- package/src/compare/row.mjs +26 -1
- package/src/completions.mjs +5 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index.mjs +109 -11
- package/src/distill/distill.mjs +3 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +18 -6
- package/src/graph/load.mjs +38 -19
- package/src/hooks/capability.mjs +14 -1
- package/src/hooks/dispatcher.mjs +335 -13
- package/src/hooks/resolve.mjs +9 -3
- package/src/hooks/shim.mjs +12 -7
- package/src/identity.mjs +67 -14
- package/src/index/extract.mjs +192 -11
- package/src/index/persist.mjs +307 -47
- package/src/index/store.mjs +263 -60
- package/src/index/tests.mjs +6 -35
- package/src/journal.mjs +296 -77
- package/src/kernel/card.mjs +37 -12
- package/src/kernel/checks.mjs +44 -9
- package/src/kernel/variants.mjs +27 -16
- package/src/lease.mjs +97 -15
- package/src/ledger/index.mjs +91 -25
- package/src/mcp/resources.mjs +5 -3
- package/src/mcp/server.mjs +81 -22
- package/src/mcp/tools.mjs +45 -9
- package/src/memory/decide.mjs +98 -43
- package/src/memory/lessons.mjs +168 -33
- package/src/name.mjs +9 -0
- package/src/plan/index.mjs +31 -8
- package/src/plan/template.mjs +27 -5
- package/src/plugins.mjs +6 -2
- package/src/prbody.mjs +7 -5
- package/src/projection/baselines.mjs +1 -1
- package/src/projection/disclosure.mjs +2 -2
- package/src/projection/loaders.mjs +41 -1
- package/src/projection/lock.mjs +21 -14
- package/src/projection/manifest.mjs +69 -19
- package/src/projection/markers.mjs +43 -3
- package/src/projection/mdc.mjs +34 -7
- package/src/projection/project.mjs +162 -18
- package/src/projection/rules.mjs +54 -3
- package/src/projection/surfaces.mjs +9 -1
- package/src/repo-root.mjs +69 -0
- package/src/resolve/body.mjs +4 -0
- package/src/resolve/fingerprint.mjs +5 -0
- package/src/resolve/local.mjs +58 -0
- package/src/review/critic.mjs +4 -2
- package/src/secrets.mjs +165 -22
- package/src/state.mjs +76 -0
- package/src/status.mjs +9 -4
- package/src/toolwords.mjs +5 -0
- package/src/tsconfig.mjs +39 -99
- 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
|
-
*
|
|
37
|
-
*
|
|
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
|
|
40
|
-
|
|
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
|
-
* `
|
|
48
|
-
* check and the redaction run on
|
|
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
|
-
|
|
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*["'
|
|
62
|
-
|
|
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
|
-
*
|
|
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
|
|
98
|
-
* @returns {{kind: string, preview: string, entropy?: number}[]}
|
|
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
|
-
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
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 (
|
|
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({
|
|
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 {
|
|
34
|
+
import { LOCAL_DIR } from './state.mjs';
|
|
35
35
|
|
|
36
36
|
const exec = promisify(execFile);
|
|
37
37
|
|
|
38
|
-
const CACHE_FILE = join(
|
|
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,
|
|
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
|
|
1
|
+
// `tsconfig.base.json`: the strictness floor a TypeScript repo writes for itself.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// repo owns from the first commit — pointed at the file ADR-025 ruled out by name.
|
|
5
|
-
//
|
|
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
|
-
//
|
|
8
|
-
//
|
|
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
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
|
123
|
+
* What this repo's base file is, and what `init` will do about it.
|
|
174
124
|
*
|
|
175
|
-
* There is no `refresh
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
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
|
-
*
|
|
183
|
-
*
|
|
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:'
|
|
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:
|
|
169
|
+
action: 'skip',
|
|
219
170
|
exists,
|
|
220
171
|
bytes: 0,
|
|
221
|
-
current:
|
|
172
|
+
current: true,
|
|
222
173
|
typescript,
|
|
223
174
|
reason: typescript
|
|
224
|
-
?
|
|
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
|
],
|