simt-dev 0.6.2 → 0.6.3

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 (76) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/README.md +44 -15
  3. package/bin/simt.mjs +19 -6
  4. package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
  5. package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
  6. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
  7. package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
  8. package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
  9. package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
  10. package/docs/decisions/README.md +44 -0
  11. package/package.json +1 -1
  12. package/rules/mdc/preview-postflight.mdc +5 -6
  13. package/rules/stacks/android.md +7 -8
  14. package/rules/stacks/cloudflare-workers.md +1 -1
  15. package/rules/stacks/container.md +2 -0
  16. package/rules/stacks/jvm.md +1 -1
  17. package/rules/stacks/react-native.md +3 -5
  18. package/rules/stacks/react-web.md +9 -9
  19. package/src/agenthooks/decide.mjs +101 -10
  20. package/src/agenthooks/policy.mjs +81 -5
  21. package/src/agenthooks/shell.mjs +523 -0
  22. package/src/argv.mjs +54 -9
  23. package/src/commands/agenthook.mjs +25 -3
  24. package/src/commands/ci.mjs +28 -3
  25. package/src/commands/completions.mjs +27 -0
  26. package/src/commands/decide.mjs +14 -1
  27. package/src/commands/doctor.mjs +367 -0
  28. package/src/commands/explain.mjs +52 -0
  29. package/src/commands/help.mjs +66 -17
  30. package/src/commands/hooks.mjs +208 -60
  31. package/src/commands/ignore.mjs +152 -0
  32. package/src/commands/index.mjs +54 -100
  33. package/src/commands/init.mjs +82 -12
  34. package/src/commands/lease.mjs +136 -0
  35. package/src/commands/lessons.mjs +12 -0
  36. package/src/commands/mcp.mjs +2 -2
  37. package/src/commands/pack.mjs +9 -0
  38. package/src/commands/plan.mjs +13 -1
  39. package/src/commands/plugin.mjs +16 -1
  40. package/src/commands/prbody.mjs +45 -0
  41. package/src/commands/repoindex.mjs +3 -0
  42. package/src/commands/rules.mjs +145 -105
  43. package/src/commands/shared.mjs +36 -5
  44. package/src/commands/skills.mjs +293 -205
  45. package/src/commands/skillsinstall.mjs +100 -30
  46. package/src/commands/slice.mjs +353 -0
  47. package/src/commands/status.mjs +62 -0
  48. package/src/commands/sync.mjs +110 -15
  49. package/src/commands/undo.mjs +106 -0
  50. package/src/commands/upgrade.mjs +70 -8
  51. package/src/completions.mjs +191 -0
  52. package/src/diff.mjs +254 -0
  53. package/src/explain.mjs +258 -0
  54. package/src/federation/config.mjs +44 -0
  55. package/src/graph/load.mjs +14 -5
  56. package/src/hooks/dispatcher.mjs +110 -0
  57. package/src/hooks/gates.mjs +14 -3
  58. package/src/hooks/resolve.mjs +14 -0
  59. package/src/ignore.mjs +133 -0
  60. package/src/index/persist.mjs +66 -9
  61. package/src/journal.mjs +326 -0
  62. package/src/lease.mjs +113 -0
  63. package/src/mcp/server.mjs +15 -1
  64. package/src/mcp/tools.mjs +296 -256
  65. package/src/memory/decide.mjs +6 -1
  66. package/src/memory/lessons.mjs +12 -0
  67. package/src/output/index.mjs +82 -12
  68. package/src/package-version.mjs +49 -1
  69. package/src/packs/manage.mjs +13 -4
  70. package/src/plan/index.mjs +6 -0
  71. package/src/plugins.mjs +27 -6
  72. package/src/prbody.mjs +251 -0
  73. package/src/projection/manifest.mjs +13 -2
  74. package/src/projection/shared.mjs +4 -1
  75. package/src/secrets.mjs +231 -0
  76. package/src/status.mjs +152 -0
@@ -0,0 +1,231 @@
1
+ // A zero-dependency scan of the staged diff for secrets (plan 0003 R52).
2
+ //
3
+ // Two finding sources, deliberately both. A pattern library catches the shapes
4
+ // vendors publish — an AWS key id, a private key header, a token prefix — and
5
+ // Shannon entropy catches the high-entropy blobs that carry no prefix at all:
6
+ // a 32-character random base64 string is a secret whether or not a vendor named
7
+ // it. Neither needs a network call or an install step, which is the point. A
8
+ // gate that needs an install step is a gate a fresh clone does not have
9
+ // (ADR-012, zero runtime dependencies).
10
+ //
11
+ // Three boundaries, each load-bearing:
12
+ //
13
+ // - Added lines only. The scan reads `git diff --cached`, so a secret already
14
+ // sitting in history is not this gate's finding: the commit about to be made
15
+ // is. Flagging context lines would block commits for work the author did not
16
+ // stage, which is the failure `format`'s staged-only rule exists to prevent.
17
+ // - Findings never print the matched bytes. The hook output lands in a
18
+ // terminal, a CI log and often a transcript an agent reads back: echoing the
19
+ // secret into a report about the secret is the leak with extra steps. A
20
+ // finding carries a four-character prefix (the part a vendor publishes as a
21
+ // type — `AKIA`, `sk-`, `ghp_`) and nothing else.
22
+ // - Placeholders are not secrets. `password = "changeme-example"` in a README
23
+ // is documentation, and a gate that blocks it teaches the exact `--no-verify`
24
+ // habit the dispatcher's fail-closed rule exists to prevent.
25
+
26
+ import { execFile } from 'node:child_process';
27
+ import { promisify } from 'node:util';
28
+
29
+ const exec = promisify(execFile);
30
+
31
+ /** The length at which a token is worth measuring, and the entropy it must reach. */
32
+ const ENTROPY_MIN_LENGTH = 32;
33
+ export const ENTROPY_MIN_BITS = 4.5;
34
+
35
+ /**
36
+ * 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.
38
+ */
39
+ const PLACEHOLDER =
40
+ /(example|placeholder|changeme|dummy|redacted|notasecret|your[_-]|xxxx+|<[a-z-]*>|\{\{?[a-z_.-]+\}\}?)/i;
41
+
42
+ /**
43
+ * The pattern library. Each row names itself so the finding tells the reader
44
+ * WHICH shape matched — "a secret was found" sends them hunting for it;
45
+ * "aws-access-key-id on line 12" says what to revoke.
46
+ *
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.
49
+ */
50
+ const PATTERNS = [
51
+ { 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 },
53
+ { 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 },
54
+ { id: 'slack-token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
55
+ { id: 'stripe-live-key', re: /\b(?:sk|rk)_live_[0-9a-zA-Z]{24,}\b/g },
56
+ { id: 'google-api-key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
57
+ { id: 'openai-style-key', re: /\bsk-[A-Za-z0-9_-]{20,}\b/g },
58
+ { id: 'jwt', re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g },
59
+ {
60
+ id: 'assigned-secret',
61
+ re: /\b(?:password|passwd|secret|token|api[_-]?key|access[_-]?key|client[_-]?secret)\b\s*[:=]\s*["']([^"']{8,})["']/gi,
62
+ group: 1,
63
+ },
64
+ ];
65
+
66
+ /** Longest base64ish run worth measuring for entropy. */
67
+ const ENTROPY_CANDIDATE = /[A-Za-z0-9+/=_-]{32,}/g;
68
+
69
+ /**
70
+ * Shannon entropy of a string, in bits per character.
71
+ *
72
+ * The detector's whole discriminator in one function: a uniformly random token
73
+ * approaches log2(alphabet size) — near 6 bits for base64 — while English,
74
+ * hex digests and repeated identifiers fall below 4.5. A sha256 hex digest tops
75
+ * out at 4.0 over sixteen symbols, which is exactly why the threshold sits above
76
+ * it: a commit hash is not a secret, and a gate that calls it one gets disabled.
77
+ */
78
+ export function entropy(text) {
79
+ const freq = new Map();
80
+ for (const ch of text) freq.set(ch, (freq.get(ch) ?? 0) + 1);
81
+ let bits = 0;
82
+ for (const n of freq.values()) {
83
+ const p = n / text.length;
84
+ bits -= p * Math.log2(p);
85
+ }
86
+ return bits;
87
+ }
88
+
89
+ /** The four characters a finding may show: the prefix a vendor publishes as a type. */
90
+ function redact(value) {
91
+ return `${value.slice(0, 4)}…`;
92
+ }
93
+
94
+ /**
95
+ * Scan one line's worth of added text.
96
+ *
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
100
+ */
101
+ export function scanText(text) {
102
+ const found = [];
103
+ const claimed = [];
104
+
105
+ for (const pattern of PATTERNS) {
106
+ pattern.re.lastIndex = 0;
107
+ 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);
111
+ // Overlap, not equality: a token a pattern named is one finding, and the
112
+ // entropy reading of the same bytes would double the count a gate reports.
113
+ if (claimed.some(([a, b]) => at < b && a < at + value.length)) continue;
114
+ claimed.push([at, at + value.length]);
115
+ found.push({ kind: pattern.id, preview: redact(value) });
116
+ }
117
+ }
118
+
119
+ ENTROPY_CANDIDATE.lastIndex = 0;
120
+ for (const match of text.matchAll(ENTROPY_CANDIDATE)) {
121
+ const value = match[0];
122
+ if (value.length < ENTROPY_MIN_LENGTH) continue;
123
+ if (PLACEHOLDER.test(value)) continue;
124
+ if (claimed.some(([a, b]) => match.index < b && a < match.index + value.length)) continue;
125
+ const bits = entropy(value);
126
+ if (bits < ENTROPY_MIN_BITS) continue;
127
+ found.push({ kind: 'high-entropy', preview: redact(value), entropy: Number(bits.toFixed(1)) });
128
+ }
129
+
130
+ return found;
131
+ }
132
+
133
+ /**
134
+ * Scan the staged diff: the added lines of what the next commit would record.
135
+ *
136
+ * File attribution is a hunk state machine rather than a prefix test, because
137
+ * the two are indistinguishable by prefix alone: an added line whose content
138
+ * starts with `++ ` renders as `+++ something`, which looks exactly like a
139
+ * `+++ b/…` file header. Headers appear only outside hunks and content only
140
+ * inside them, so the `diff --git` boundary and the `@@` header decide which
141
+ * one a line is.
142
+ *
143
+ * @param {string} root repository root
144
+ * @returns {Promise<{findings: {file: string, line: number, kind: string,
145
+ * preview: string, entropy?: number}[], lines: number, error: string|null}>}
146
+ * `error` is set only when git itself could not answer; callers fail closed on
147
+ * it rather than reading silence as a clean diff.
148
+ */
149
+ export async function scanStaged(root) {
150
+ let stdout;
151
+ try {
152
+ // Every flag here defeats a way a repository can keep the scan from seeing its
153
+ // own staged bytes (review 0003 Q02): `diff.external` routes the diff to a
154
+ // program that prints nothing, `-diff` in `.gitattributes` and genuinely binary
155
+ // files make git print no text at all, a textconv rewrites the bytes on the way
156
+ // out, `diff.mnemonicPrefix` renames the header prefixes the attribution below
157
+ // reads, and C-quoting mangles a non-ASCII name. The scan asks git for the staged
158
+ // bytes with its own flags and ignores what the repository asked for.
159
+ ({ stdout } = await exec(
160
+ 'git',
161
+ [
162
+ '-c', 'core.quotePath=false',
163
+ 'diff',
164
+ '--cached',
165
+ '--no-ext-diff',
166
+ '--no-textconv',
167
+ '--text',
168
+ '--unified=0',
169
+ '--no-color',
170
+ '--src-prefix=a/',
171
+ '--dst-prefix=b/',
172
+ ],
173
+ { cwd: root, maxBuffer: 32 * 1024 * 1024 },
174
+ ));
175
+ } catch (error) {
176
+ const stderr = String(error.stderr ?? '').trim();
177
+ const reason = (stderr || String(error.message ?? error)).split('\n')[0];
178
+ return { findings: [], lines: 0, error: reason };
179
+ }
180
+
181
+ const findings = [];
182
+ let lines = 0;
183
+ let file = null;
184
+ let lineNo = 0;
185
+ let inHunk = false;
186
+
187
+ for (const raw of stdout.split('\n')) {
188
+ if (raw.startsWith('diff --git ')) {
189
+ inHunk = false;
190
+ continue;
191
+ }
192
+ if (raw.startsWith('@@ ')) {
193
+ const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
194
+ if (hunk) {
195
+ lineNo = Number(hunk[1]);
196
+ inHunk = true;
197
+ }
198
+ continue;
199
+ }
200
+ if (!inHunk) {
201
+ // Outside a hunk: headers only. `+++ /dev/null` is the removal side of a
202
+ // deletion; nothing is added under it.
203
+ if (raw.startsWith('+++ ')) {
204
+ const path = raw.slice(4).trim();
205
+ file = path === '/dev/null' ? null : path.replace(/^b\//, '');
206
+ }
207
+ continue;
208
+ }
209
+ if (!raw.startsWith('+')) continue; // -U0 has no context; this is `\ No newline…`
210
+ lines += 1;
211
+ if (file !== null) {
212
+ for (const hit of scanText(raw.slice(1))) {
213
+ findings.push({ file, line: lineNo, ...hit });
214
+ }
215
+ }
216
+ lineNo += 1;
217
+ }
218
+
219
+ return { findings, lines, error: null };
220
+ }
221
+
222
+ /** The report a failing gate prints: one finding per line, redacted. */
223
+ export function renderFindings(findings) {
224
+ return findings
225
+ .map(
226
+ (f) =>
227
+ ` ${f.file}:${f.line} ${f.kind} ${f.preview}` +
228
+ (f.entropy !== undefined ? ` (entropy ${f.entropy} bits)` : ''),
229
+ )
230
+ .join('\n');
231
+ }
package/src/status.mjs ADDED
@@ -0,0 +1,152 @@
1
+ // The `--line` status cache (plan 0003 R48).
2
+ //
3
+ // `status --line` is for shell prompts, tmux and agent status lines: it must
4
+ // answer in well under 50 ms, which rules out everything `check` does (rule
5
+ // loading, projection, gate probing). So `check` — the one command that
6
+ // already measures all of it — writes `.simt/check.json` on every run, and
7
+ // `--line` reads only that file plus one `git rev-parse HEAD` to notice new
8
+ // commits. No fingerprint, no rule reads, no capability probe.
9
+ //
10
+ // Three honesties, each a failure this module refuses to be:
11
+ //
12
+ // - The cache is local state, never content (the same standing as the
13
+ // journal, the ledger and the index). Writers that change files `check`
14
+ // reads (`init`, `sync`, `upgrade`, `hooks install`, `undo`) delete it
15
+ // when they apply, so the line goes back to "no cache" instead of
16
+ // reporting the tree as it was. Hand edits between checks are the
17
+ // documented blind spot: the line is the gate state *as of the last
18
+ // check*, and says `stale` only when HEAD moved.
19
+ // - A blocking finding that fits no slot still fails the line. The version
20
+ // pin, an invalid rule, a bad gate cwd — none of these is rules, hooks
21
+ // or gates-count, and a line that showed all slots green while the gate
22
+ // fails would be the lie prompts are for. So PASS/FAIL is carried
23
+ // explicitly rather than inferred from the symbols.
24
+ // - The write is atomic (tmp + rename) and fixed-size: only counts and
25
+ // booleans are stored, never findings, so a huge gate still leaves a
26
+ // ~200-byte file and a killed `check` never leaves half a document.
27
+
28
+ import { execFile } from 'node:child_process';
29
+ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
30
+ import { join } from 'node:path';
31
+ import { promisify } from 'node:util';
32
+
33
+ import { readHooksState, wantsShim } from './compare/hooksstate.mjs';
34
+ import { STATE_DIR } from './name.mjs';
35
+
36
+ const exec = promisify(execFile);
37
+
38
+ const CACHE_FILE = join(STATE_DIR, 'check.json');
39
+ const CACHE_VERSION = 1;
40
+
41
+ /**
42
+ * The commit the cached state was measured at, or null when git cannot say
43
+ * (not a repository, no commits yet, git missing). Null is trust, not failure:
44
+ * without a HEAD there is nothing to compare, and refusing the line would
45
+ * make exported trees — the ones with no `.git` at all — unreportable.
46
+ */
47
+ export async function currentHead(root) {
48
+ try {
49
+ const { stdout } = await exec('git', ['rev-parse', 'HEAD'], { cwd: root });
50
+ return stdout.trim() || null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function cachePath(root) {
57
+ return join(root, CACHE_FILE);
58
+ }
59
+
60
+ /**
61
+ * Add the when and the where to a check-measured summary. The summary itself
62
+ * is built by `check`, which is the only command that measures all of it;
63
+ * this only stamps it.
64
+ */
65
+ export async function buildCacheRecord(root, summary) {
66
+ return {
67
+ version: CACHE_VERSION,
68
+ at: Date.now(),
69
+ head: await currentHead(root),
70
+ rulesOk: summary.rulesOk,
71
+ hooksOk: summary.hooksOk,
72
+ gatesRun: summary.gatesRun,
73
+ gatesTotal: summary.gatesTotal,
74
+ blocking: summary.blocking,
75
+ warnings: summary.warnings,
76
+ };
77
+ }
78
+
79
+ /** Atomically replace the cache. A killed `check` leaves the previous record, not half of one. */
80
+ export async function writeCheckCache(root, record) {
81
+ const path = cachePath(root);
82
+ await mkdir(join(root, STATE_DIR), { recursive: true });
83
+ const body = `${JSON.stringify(record)}\n`;
84
+ const tmp = `${path}.tmp-${process.pid}`;
85
+ await writeFile(tmp, body, 'utf8');
86
+ await rename(tmp, path);
87
+ }
88
+
89
+ /** The last record `check` wrote, or null when it never ran (or a writer invalidated it). */
90
+ export async function readCheckCache(root) {
91
+ let raw;
92
+ try {
93
+ raw = await readFile(cachePath(root), 'utf8');
94
+ } catch (error) {
95
+ if (error.code === 'ENOENT') return null;
96
+ throw error;
97
+ }
98
+ try {
99
+ const record = JSON.parse(raw);
100
+ if (record.version !== CACHE_VERSION) return null;
101
+ return record;
102
+ } catch {
103
+ // A damaged cache is no cache: the next `check` rewrites it, and a prompt
104
+ // that refused to render over a corrupt byte would stay broken until then.
105
+ return null;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Forget the cached state. Called by writers that change files `check` reads,
111
+ * after they apply — never on preview or dry-run, where nothing changed and
112
+ * the record is still true.
113
+ */
114
+ export async function invalidateCheckCache(root) {
115
+ try {
116
+ await unlink(cachePath(root));
117
+ } catch (error) {
118
+ if (error.code === 'ENOENT') return;
119
+ throw error;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * The hooks slot of the line: every declared gate routed through a current
125
+ * shim. A repository that declares no gate has nothing to enforce, so the
126
+ * slot is vacuously true rather than forever red (the same answer `init`
127
+ * prints in prose). Gates run/total is counted by `check` from the runnable
128
+ * verdicts, not here: routing and runnability are different slots.
129
+ */
130
+ export async function hooksSummary(root, manifest, exists) {
131
+ const declared = [...(manifest?.gates?.preCommit ?? []), ...(manifest?.gates?.prePush ?? [])];
132
+ const evidence = manifest?.gates?.evidence === 'warn' || manifest?.gates?.evidence === 'block';
133
+ if (declared.length === 0 && !evidence) return { ok: true };
134
+ const state = await readHooksState(root, { declaresGate: (event) => wantsShim(manifest, exists, event) });
135
+ return { ok: state.complete };
136
+ }
137
+
138
+ /**
139
+ * One line, always: `simt PASS ✓rules ✓hooks 3/3gates`, longest honest case
140
+ * `simt FAIL ✗rules ✗hooks 0/12gates ⚠9 stale`. Counts stay small because they
141
+ * count gates and warnings, never findings; the ≤ 60-char budget is asserted
142
+ * on a synthetic worst case, not on the hope that gates stay few.
143
+ */
144
+ export function renderStatusLine(record, stale) {
145
+ const outcome = record.blocking === 0 ? 'PASS' : 'FAIL';
146
+ const rules = record.rulesOk ? '✓rules' : '✗rules';
147
+ const hooks = record.hooksOk ? '✓hooks' : '✗hooks';
148
+ let line = `simt ${outcome} ${rules} ${hooks} ${record.gatesRun}/${record.gatesTotal}gates`;
149
+ if (record.warnings > 0) line += ` ⚠${record.warnings}`;
150
+ if (stale) line += ' stale';
151
+ return line;
152
+ }