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.
- package/CHANGELOG.md +48 -1
- package/README.md +44 -15
- package/bin/simt.mjs +19 -6
- package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
- package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
- package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
- package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
- package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
- package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
- package/docs/decisions/README.md +44 -0
- package/package.json +1 -1
- package/rules/mdc/preview-postflight.mdc +5 -6
- package/rules/stacks/android.md +7 -8
- package/rules/stacks/cloudflare-workers.md +1 -1
- package/rules/stacks/container.md +2 -0
- package/rules/stacks/jvm.md +1 -1
- package/rules/stacks/react-native.md +3 -5
- package/rules/stacks/react-web.md +9 -9
- package/src/agenthooks/decide.mjs +101 -10
- package/src/agenthooks/policy.mjs +81 -5
- package/src/agenthooks/shell.mjs +523 -0
- package/src/argv.mjs +54 -9
- package/src/commands/agenthook.mjs +25 -3
- package/src/commands/ci.mjs +28 -3
- package/src/commands/completions.mjs +27 -0
- package/src/commands/decide.mjs +14 -1
- package/src/commands/doctor.mjs +367 -0
- package/src/commands/explain.mjs +52 -0
- package/src/commands/help.mjs +66 -17
- package/src/commands/hooks.mjs +208 -60
- package/src/commands/ignore.mjs +152 -0
- package/src/commands/index.mjs +54 -100
- package/src/commands/init.mjs +82 -12
- package/src/commands/lease.mjs +136 -0
- package/src/commands/lessons.mjs +12 -0
- package/src/commands/mcp.mjs +2 -2
- package/src/commands/pack.mjs +9 -0
- package/src/commands/plan.mjs +13 -1
- package/src/commands/plugin.mjs +16 -1
- package/src/commands/prbody.mjs +45 -0
- package/src/commands/repoindex.mjs +3 -0
- package/src/commands/rules.mjs +145 -105
- package/src/commands/shared.mjs +36 -5
- package/src/commands/skills.mjs +293 -205
- package/src/commands/skillsinstall.mjs +100 -30
- package/src/commands/slice.mjs +353 -0
- package/src/commands/status.mjs +62 -0
- package/src/commands/sync.mjs +110 -15
- package/src/commands/undo.mjs +106 -0
- package/src/commands/upgrade.mjs +70 -8
- package/src/completions.mjs +191 -0
- package/src/diff.mjs +254 -0
- package/src/explain.mjs +258 -0
- package/src/federation/config.mjs +44 -0
- package/src/graph/load.mjs +14 -5
- package/src/hooks/dispatcher.mjs +110 -0
- package/src/hooks/gates.mjs +14 -3
- package/src/hooks/resolve.mjs +14 -0
- package/src/ignore.mjs +133 -0
- package/src/index/persist.mjs +66 -9
- package/src/journal.mjs +326 -0
- package/src/lease.mjs +113 -0
- package/src/mcp/server.mjs +15 -1
- package/src/mcp/tools.mjs +296 -256
- package/src/memory/decide.mjs +6 -1
- package/src/memory/lessons.mjs +12 -0
- package/src/output/index.mjs +82 -12
- package/src/package-version.mjs +49 -1
- package/src/packs/manage.mjs +13 -4
- package/src/plan/index.mjs +6 -0
- package/src/plugins.mjs +27 -6
- package/src/prbody.mjs +251 -0
- package/src/projection/manifest.mjs +13 -2
- package/src/projection/shared.mjs +4 -1
- package/src/secrets.mjs +231 -0
- package/src/status.mjs +152 -0
package/src/hooks/dispatcher.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import { CLI } from '../name.mjs';
|
|
|
29
29
|
import { MANIFEST_FILE } from '../name.mjs';
|
|
30
30
|
import { assertVersionPin } from '../package-version.mjs';
|
|
31
31
|
import { recordRun } from '../ledger/index.mjs';
|
|
32
|
+
import { renderFindings, scanStaged } from '../secrets.mjs';
|
|
32
33
|
|
|
33
34
|
const exec = promisify(execFile);
|
|
34
35
|
|
|
@@ -121,6 +122,37 @@ export async function runGates(event, { root, manifest }) {
|
|
|
121
122
|
});
|
|
122
123
|
continue;
|
|
123
124
|
}
|
|
125
|
+
|
|
126
|
+
// A built-in gate runs here, in this process: no script, no command, no
|
|
127
|
+
// tool on disk (ADR-100). It gets the same ledger line and the same
|
|
128
|
+
// fail-closed reporting as every other gate; only the source of the bytes
|
|
129
|
+
// it runs over differs. Keyed on the RESOLUTION rather than the gate, so a
|
|
130
|
+
// declared command for this name takes the shell path below instead.
|
|
131
|
+
if (resolution.builtin !== undefined) {
|
|
132
|
+
const startedBuiltin = Date.now();
|
|
133
|
+
const outcome = await runBuiltinGate(resolution.builtin, { root, staged });
|
|
134
|
+
try {
|
|
135
|
+
await recordRun(root, {
|
|
136
|
+
cmd: `builtin:${resolution.builtin}`,
|
|
137
|
+
exit: outcome.ok ? 0 : 1,
|
|
138
|
+
ms: Date.now() - startedBuiltin,
|
|
139
|
+
output: outcome.output,
|
|
140
|
+
});
|
|
141
|
+
} catch {
|
|
142
|
+
// Recorded by absence: the next evidence read for this tree has no line.
|
|
143
|
+
}
|
|
144
|
+
results.push({
|
|
145
|
+
name,
|
|
146
|
+
status: outcome.ok ? 'passed' : 'failed',
|
|
147
|
+
detail: outcome.detail,
|
|
148
|
+
output: outcome.ok ? '' : outcome.output,
|
|
149
|
+
...(outcome.remedy !== undefined ? { remedy: outcome.remedy } : {}),
|
|
150
|
+
adr: gate.adr,
|
|
151
|
+
exitCode: outcome.ok ? 0 : 1,
|
|
152
|
+
});
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
124
156
|
if (resolution.argv === null) {
|
|
125
157
|
results.push({ name, status: 'skipped', detail: resolution.detail, adr: gate.adr, exitCode: null });
|
|
126
158
|
continue;
|
|
@@ -241,6 +273,31 @@ export async function runGates(event, { root, manifest }) {
|
|
|
241
273
|
return { event, gates: results, failed: results.some((r) => r.status === 'failed') };
|
|
242
274
|
}
|
|
243
275
|
|
|
276
|
+
/**
|
|
277
|
+
* The lines that report one event's gate run: the event, what was declared, every
|
|
278
|
+
* gate with its disposition, its reason and its remedy, and the verdict.
|
|
279
|
+
*
|
|
280
|
+
* Shared by `hooks run` and `slice finish` rather than written twice (ADR-002: the
|
|
281
|
+
* second real use is what the extraction exists for). Both run this dispatcher over
|
|
282
|
+
* the same event, and two renderings of one run are two ways for one of them to
|
|
283
|
+
* quietly stop printing a gate that failed.
|
|
284
|
+
*
|
|
285
|
+
* @param {string} event the hook event as git spells it
|
|
286
|
+
* @param {string[]} declared the gate names the manifest declares for it
|
|
287
|
+
* @param {object} result the value `runGates` returned
|
|
288
|
+
* @returns {string[]} the report lines, newline-free, in print order
|
|
289
|
+
*/
|
|
290
|
+
export function renderGateReport(event, declared, result) {
|
|
291
|
+
const lines = [`hooks ${event}`, `gates ${(declared ?? []).join(', ') || '(none declared)'}`];
|
|
292
|
+
for (const g of result.gates) {
|
|
293
|
+
lines.push(`${g.status.toUpperCase().padEnd(8)} ${g.name.padEnd(20)} ${g.detail}${g.adr ? ` [${g.adr}]` : ''}`);
|
|
294
|
+
if (g.remedy) lines.push(` remedy: ${g.remedy}`);
|
|
295
|
+
if (g.output) lines.push(...g.output.split('\n').map((l) => ` ${l}`));
|
|
296
|
+
}
|
|
297
|
+
lines.push(result.failed ? 'BLOCKED at least one gate failed' : 'PASSED');
|
|
298
|
+
return lines;
|
|
299
|
+
}
|
|
300
|
+
|
|
244
301
|
async function run(argv, cwd) {
|
|
245
302
|
try {
|
|
246
303
|
const { stdout, stderr } = await exec(argv[0], argv.slice(1), { cwd, maxBuffer: 8 * 1024 * 1024 });
|
|
@@ -250,6 +307,59 @@ async function run(argv, cwd) {
|
|
|
250
307
|
}
|
|
251
308
|
}
|
|
252
309
|
|
|
310
|
+
/** The built-in gates this dispatcher can run, by name. */
|
|
311
|
+
const BUILTIN_GATES = new Map([['secret-scan', scanStaged]]);
|
|
312
|
+
|
|
313
|
+
/** How many findings the report prints; the detail carries the true count. */
|
|
314
|
+
const BUILTIN_REPORT_MAX = 12;
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Run a `builtin` gate in this process (ADR-100).
|
|
318
|
+
* Every outcome is a verdict with a reason: a built-in that does not exist, a
|
|
319
|
+
* diff git cannot read, or a secret found each fail with their own detail —
|
|
320
|
+
* none of them can degrade into a pass, which is the same fail-closed contract
|
|
321
|
+
* an unresolvable script gate has.
|
|
322
|
+
*/
|
|
323
|
+
async function runBuiltinGate(name, { root, staged }) {
|
|
324
|
+
const scan = BUILTIN_GATES.get(name);
|
|
325
|
+
if (scan === undefined) {
|
|
326
|
+
return {
|
|
327
|
+
ok: false,
|
|
328
|
+
detail: `no built-in implementation for gate "${name}"; this is a bug in ${CLI}`,
|
|
329
|
+
remedy: `remove "${name}" from ${MANIFEST_FILE} gates`,
|
|
330
|
+
output: '',
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
if (staged.length === 0) {
|
|
334
|
+
return { ok: true, detail: 'nothing staged; no added line to scan', output: '' };
|
|
335
|
+
}
|
|
336
|
+
const result = await scan(root);
|
|
337
|
+
if (result.error !== null) {
|
|
338
|
+
return {
|
|
339
|
+
ok: false,
|
|
340
|
+
detail: `the staged diff could not be read: ${result.error}`,
|
|
341
|
+
remedy: 'the gate fails closed; inspect the repository state and commit again',
|
|
342
|
+
output: '',
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
if (result.findings.length === 0) {
|
|
346
|
+
return { ok: true, detail: `${result.lines} added line(s) scanned, no secret found`, output: '' };
|
|
347
|
+
}
|
|
348
|
+
const files = new Set(result.findings.map((f) => f.file));
|
|
349
|
+
const shown = result.findings.slice(0, BUILTIN_REPORT_MAX);
|
|
350
|
+
const more = result.findings.length - shown.length;
|
|
351
|
+
return {
|
|
352
|
+
ok: false,
|
|
353
|
+
detail: `${result.findings.length} probable secret(s) in ${files.size} staged file(s)`,
|
|
354
|
+
remedy:
|
|
355
|
+
'remove the value from the staged file (or unstage it with git restore --staged <path>) ' +
|
|
356
|
+
'and commit again; the report never prints the matched bytes',
|
|
357
|
+
output:
|
|
358
|
+
renderFindings(shown) +
|
|
359
|
+
(more > 0 ? `\n … ${more} more finding(s) not shown` : ''),
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
253
363
|
/**
|
|
254
364
|
* Run a gate's resolution, as a shell line or as an argv.
|
|
255
365
|
*
|
package/src/hooks/gates.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { MANIFEST_FILE } from '../name.mjs';
|
|
2
2
|
// Gate definitions for the shared hook dispatcher.
|
|
3
3
|
//
|
|
4
|
-
// A gate has one of
|
|
4
|
+
// A gate has one of four kinds, and the choice is not cosmetic:
|
|
5
5
|
//
|
|
6
6
|
// staged run a formatter over the STAGED FILES ONLY, then re-stage them.
|
|
7
7
|
// Whole-repo formatting from a pre-commit hook would rewrite
|
|
@@ -14,13 +14,18 @@ import { MANIFEST_FILE } from '../name.mjs';
|
|
|
14
14
|
// know what monkeyc or a Kotlin script path looks like in one repo
|
|
15
15
|
// and something else in another. Plan 0001 section 3.2 keeps repo
|
|
16
16
|
// commands and paths local; this is that ruling in code.
|
|
17
|
+
// builtin run inside this binary, with nothing to resolve on disk. For a
|
|
18
|
+
// check whose whole value is that a fresh clone has it: the staged-
|
|
19
|
+
// diff secret scan needs no script, no command and no tool in
|
|
20
|
+
// node_modules, and a gate that needed an install step would be a
|
|
21
|
+
// gate the repository that most needs it does not have (ADR-100).
|
|
17
22
|
//
|
|
18
23
|
// ADR-010 note: no gate invokes `npx` or `npm exec`. Script gates go through
|
|
19
24
|
// `npm run`, and staged gates resolve the formatter from the project's own
|
|
20
25
|
// `node_modules/.bin`, which is the declared dependency's binary rather than
|
|
21
26
|
// one fetched at hook time.
|
|
22
27
|
|
|
23
|
-
export const GATE_KINDS = new Set(['staged', 'script', 'command']);
|
|
28
|
+
export const GATE_KINDS = new Set(['staged', 'script', 'command', 'builtin']);
|
|
24
29
|
|
|
25
30
|
/** Extensions a `staged` formatter gate will hand to the tool. */
|
|
26
31
|
export const FORMATTABLE = new Set([
|
|
@@ -29,7 +34,7 @@ export const FORMATTABLE = new Set([
|
|
|
29
34
|
]);
|
|
30
35
|
|
|
31
36
|
/**
|
|
32
|
-
* @typedef {{name: string, kind: 'staged'|'script'|'command', adr: string,
|
|
37
|
+
* @typedef {{name: string, kind: 'staged'|'script'|'command'|'builtin', adr: string,
|
|
33
38
|
* script?: string, tools?: string[], why: string}} Gate
|
|
34
39
|
*/
|
|
35
40
|
|
|
@@ -88,6 +93,12 @@ export const GATES = {
|
|
|
88
93
|
adr: 'ADR-002',
|
|
89
94
|
why: 'a protocol change is a two-repo change; the drift check is what makes half of it fail loudly',
|
|
90
95
|
},
|
|
96
|
+
'secret-scan': {
|
|
97
|
+
name: 'secret-scan',
|
|
98
|
+
kind: 'builtin',
|
|
99
|
+
adr: 'ADR-100',
|
|
100
|
+
why: 'a staged diff is the last moment a secret can still be kept out of history, and the scan must be runnable by a clone with nothing installed',
|
|
101
|
+
},
|
|
91
102
|
};
|
|
92
103
|
|
|
93
104
|
export function getGate(name) {
|
package/src/hooks/resolve.mjs
CHANGED
|
@@ -119,6 +119,20 @@ export async function resolveGate(gate, { root, pkg, staged, commands }) {
|
|
|
119
119
|
};
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
if (gate.kind === 'builtin') {
|
|
123
|
+
// Nothing on disk to resolve: the implementation IS this binary, which is
|
|
124
|
+
// the whole point of the kind (ADR-100). A declared command was handled
|
|
125
|
+
// above and still wins, so a repository can swap the built-in for its own
|
|
126
|
+
// program exactly the way it can for any other gate.
|
|
127
|
+
return {
|
|
128
|
+
ok: true,
|
|
129
|
+
kind: gate.kind,
|
|
130
|
+
argv: null,
|
|
131
|
+
builtin: gate.name,
|
|
132
|
+
detail: `built-in: ${gate.name}`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
122
136
|
if (gate.kind === 'staged') {
|
|
123
137
|
const tool = await resolveStagedTool(root, gate);
|
|
124
138
|
if (tool.path === null) {
|
package/src/ignore.mjs
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// One ignore list, four readers' dialects (plan 0003 R52).
|
|
2
|
+
//
|
|
3
|
+
// Cursor, Aider, Gemini and Claude each want the same fact — "the agent must
|
|
4
|
+
// not load these paths" — in a different place: three gitignore-style files and
|
|
5
|
+
// one array inside a JSON settings object. Hand-maintaining four copies is four
|
|
6
|
+
// chances to forget one, and a forget is silent: the agent reads the file that
|
|
7
|
+
// did get updated and confidently loads the path the other three excluded. So
|
|
8
|
+
// the list lives in exactly one file (`.simtignore`, at the repository root,
|
|
9
|
+
// where a gitignore-syntax list already belongs) and everything else is
|
|
10
|
+
// rendered from it.
|
|
11
|
+
//
|
|
12
|
+
// Two ownership rules, because the four targets are not equally ours:
|
|
13
|
+
//
|
|
14
|
+
// - The three ignore files are GENERATED: whatever they held is what the list
|
|
15
|
+
// says after `simt ignore --yes`. A line that is in one and not in the list
|
|
16
|
+
// is drift between four files that claim to agree, and the plan prints it
|
|
17
|
+
// as a removal before anything is written.
|
|
18
|
+
// - `.claude/settings.json` is the READER'S file that merely contains our
|
|
19
|
+
// entries. We merge: every key, every permission and every deny entry that
|
|
20
|
+
// was already there survives byte-for-byte, and we only add `Read(...)`
|
|
21
|
+
// lines. In particular we never REMOVE a deny entry, because deleting a
|
|
22
|
+
// permission is a security decision that belongs to whoever wrote it — an
|
|
23
|
+
// entry dropped from `.simtignore` leaves a stale deny in Claude (safe
|
|
24
|
+
// direction) while the three generated files converge immediately.
|
|
25
|
+
|
|
26
|
+
/** The one list. gitignore syntax: one path or glob per line, `#` comments. */
|
|
27
|
+
export const IGNORE_FILE = '.simtignore';
|
|
28
|
+
|
|
29
|
+
/** The three readers that take the list verbatim. */
|
|
30
|
+
const TEXT_TARGETS = ['.cursorignore', '.aiderignore', '.geminiignore'];
|
|
31
|
+
|
|
32
|
+
/** The reader that takes it as `permissions.deny` `Read(...)` entries. */
|
|
33
|
+
export const CLAUDE_SETTINGS = '.claude/settings.json';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse the list: trim, drop blanks and `#` comments, keep entries verbatim.
|
|
37
|
+
*
|
|
38
|
+
* Verbatim matters — `.env.local` and `secrets/` and `*.pem` are the reader's
|
|
39
|
+
* globs, and normalising them would be this tool guessing at another tool's
|
|
40
|
+
* pattern language.
|
|
41
|
+
*/
|
|
42
|
+
export function parseIgnoreList(text) {
|
|
43
|
+
return String(text)
|
|
44
|
+
.split('\n')
|
|
45
|
+
.map((line) => line.replace(/\r$/, '').trim())
|
|
46
|
+
.filter((line) => line !== '' && !line.startsWith('#'));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The bytes of one of the three generated ignore files. */
|
|
50
|
+
export function renderTextIgnore(entries) {
|
|
51
|
+
return entries.length === 0 ? '' : `${entries.join('\n')}\n`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The list as Claude deny entries: `Read(.env)`, `Read(secrets/**)`, … */
|
|
55
|
+
export function denyEntries(entries) {
|
|
56
|
+
return entries.map((entry) => `Read(${entry})`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Merge the list into `.claude/settings.json`, preserving everything else.
|
|
61
|
+
*
|
|
62
|
+
* @param {string|null} existingText current bytes, or null when absent
|
|
63
|
+
* @param {string[]} entries the parsed list
|
|
64
|
+
* @returns {{ok: true, text: string} | {ok: false, error: string}} — a settings
|
|
65
|
+
* file that is not valid JSON OBJECT is refused rather than replaced: the one
|
|
66
|
+
* failure that loses a reader's whole Claude configuration is a renderer that
|
|
67
|
+
* "repairs" what it could not parse.
|
|
68
|
+
*/
|
|
69
|
+
export function renderClaudeSettings(existingText, entries) {
|
|
70
|
+
let settings = {};
|
|
71
|
+
if (existingText !== null) {
|
|
72
|
+
try {
|
|
73
|
+
settings = JSON.parse(existingText);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return { ok: false, error: `${CLAUDE_SETTINGS} is not valid JSON: ${error.message}` };
|
|
76
|
+
}
|
|
77
|
+
if (settings === null || typeof settings !== 'object' || Array.isArray(settings)) {
|
|
78
|
+
return { ok: false, error: `${CLAUDE_SETTINGS} is not a JSON object; it cannot hold permissions` };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const current =
|
|
83
|
+
settings.permissions !== null &&
|
|
84
|
+
typeof settings.permissions === 'object' &&
|
|
85
|
+
!Array.isArray(settings.permissions)
|
|
86
|
+
? { ...settings.permissions }
|
|
87
|
+
: {};
|
|
88
|
+
const existing = Array.isArray(current.deny)
|
|
89
|
+
? current.deny.filter((entry) => typeof entry === 'string')
|
|
90
|
+
: [];
|
|
91
|
+
const wanted = denyEntries(entries);
|
|
92
|
+
// Existing entries keep their order and their bytes; ours append in list
|
|
93
|
+
// order, deduplicated so a re-run is byte-idempotent.
|
|
94
|
+
current.deny = [...existing, ...wanted.filter((entry) => !existing.includes(entry))];
|
|
95
|
+
settings.permissions = current;
|
|
96
|
+
|
|
97
|
+
return { ok: true, text: `${JSON.stringify(settings, null, 2)}\n` };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The four targets with their desired bytes, from the current bytes of each.
|
|
102
|
+
*
|
|
103
|
+
* @param {string[]} entries the parsed list
|
|
104
|
+
* @param {(path: string) => Promise<string|null>} read current bytes or null
|
|
105
|
+
* @returns {Promise<{ok: true, targets: {path: string, action: 'create'|'update'|'unchanged',
|
|
106
|
+
* before: string|null, after: string}[]} | {ok: false, error: string}>}
|
|
107
|
+
*/
|
|
108
|
+
export async function planIgnore(entries, read) {
|
|
109
|
+
const text = renderTextIgnore(entries);
|
|
110
|
+
const targets = [];
|
|
111
|
+
|
|
112
|
+
for (const path of TEXT_TARGETS) {
|
|
113
|
+
const before = await read(path);
|
|
114
|
+
targets.push({
|
|
115
|
+
path,
|
|
116
|
+
action: before === null ? 'create' : before === text ? 'unchanged' : 'update',
|
|
117
|
+
before,
|
|
118
|
+
after: text,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const claudeBefore = await read(CLAUDE_SETTINGS);
|
|
123
|
+
const merged = renderClaudeSettings(claudeBefore, entries);
|
|
124
|
+
if (!merged.ok) return { ok: false, error: merged.error };
|
|
125
|
+
targets.push({
|
|
126
|
+
path: CLAUDE_SETTINGS,
|
|
127
|
+
action: claudeBefore === null ? 'create' : claudeBefore === merged.text ? 'unchanged' : 'update',
|
|
128
|
+
before: claudeBefore,
|
|
129
|
+
after: merged.text,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return { ok: true, targets };
|
|
133
|
+
}
|
package/src/index/persist.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// rows. This file is the format; store.mjs is the walk that fills it. They are
|
|
5
5
|
// split because the walk and the format together crossed the review trigger.
|
|
6
6
|
|
|
7
|
-
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
|
|
8
8
|
import { dirname, join } from 'node:path';
|
|
9
9
|
|
|
10
10
|
export const SQLITE_FILE = '.simt/index.sqlite';
|
|
@@ -44,18 +44,53 @@ export function decodeVectorBlob(blob) {
|
|
|
44
44
|
return new Float32Array(copy.buffer);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
let tmpSeq = 0;
|
|
48
|
+
|
|
49
|
+
/** A per-writer temp file: pid plus a sequence, so in-process concurrent writers meet on no shared path. */
|
|
50
|
+
function tmpFor(file) {
|
|
51
|
+
tmpSeq += 1;
|
|
52
|
+
return `${file}.${process.pid}.${tmpSeq}.tmp`;
|
|
53
|
+
}
|
|
54
|
+
|
|
47
55
|
export async function writeSqlite(root, next, removed, symbols, edges, fts, embeddings = []) {
|
|
48
56
|
const { DatabaseSync } = await import('node:sqlite');
|
|
49
57
|
const file = join(root, SQLITE_FILE);
|
|
50
58
|
await mkdir(dirname(file), { recursive: true });
|
|
51
|
-
|
|
59
|
+
// A cold build goes to a temp file that is renamed over the target: three
|
|
60
|
+
// concurrent cold runs used to interleave schema creation and leave a locked,
|
|
61
|
+
// table-less database behind (Q08). Each writer builds its own complete file
|
|
62
|
+
// and the rename is atomic, so the last writer wins with a whole index.
|
|
63
|
+
let cold = false;
|
|
52
64
|
try {
|
|
65
|
+
await stat(file);
|
|
66
|
+
} catch {
|
|
67
|
+
cold = true;
|
|
68
|
+
}
|
|
69
|
+
const target = cold ? tmpFor(file) : file;
|
|
70
|
+
if (cold) {
|
|
71
|
+
try {
|
|
72
|
+
await stat(target);
|
|
73
|
+
const { unlink } = await import('node:fs/promises');
|
|
74
|
+
await unlink(target);
|
|
75
|
+
} catch {
|
|
76
|
+
// No stale temp file: build fresh.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const db = new DatabaseSync(target);
|
|
80
|
+
try {
|
|
81
|
+
db.exec('pragma journal_mode = WAL');
|
|
82
|
+
db.exec('pragma busy_timeout = 5000');
|
|
83
|
+
db.exec('pragma synchronous = normal');
|
|
53
84
|
db.exec(SCHEMA);
|
|
54
|
-
db.exec('begin');
|
|
85
|
+
db.exec('begin immediate');
|
|
55
86
|
const upsert = db.prepare(`insert into files(path, blob, lang, bytes, pkg) values (?, ?, ?, ?, ?)
|
|
56
87
|
on conflict(path) do update set blob = excluded.blob, lang = excluded.lang, bytes = excluded.bytes, pkg = excluded.pkg`);
|
|
57
88
|
const dropSymbols = db.prepare('delete from symbols where path = ?');
|
|
58
|
-
|
|
89
|
+
// An importer owns its outgoing edges: re-extracting a changed file drops
|
|
90
|
+
// only `src = ?`, so the unchanged importers' edges pointing into it survive
|
|
91
|
+
// (Q07). A removed file's incoming edges dangle, so they drop with it.
|
|
92
|
+
const dropEdges = db.prepare('delete from edges where src = ?');
|
|
93
|
+
const dropIncoming = db.prepare('delete from edges where dst = ?');
|
|
59
94
|
const dropFts = db.prepare('delete from fts where ref = ?');
|
|
60
95
|
const dropEmbed = db.prepare('delete from embeddings where path = ?');
|
|
61
96
|
const dropFile = db.prepare('delete from files where path = ?');
|
|
@@ -67,14 +102,15 @@ export async function writeSqlite(root, next, removed, symbols, edges, fts, embe
|
|
|
67
102
|
for (const row of next) {
|
|
68
103
|
upsert.run(row.path, row.blob, row.lang, row.bytes, row.pkg);
|
|
69
104
|
dropSymbols.run(row.path);
|
|
70
|
-
dropEdges.run(row.path
|
|
105
|
+
dropEdges.run(row.path);
|
|
71
106
|
dropFts.run(row.path);
|
|
72
107
|
dropEmbed.run(row.path);
|
|
73
108
|
}
|
|
74
109
|
for (const path of removed) {
|
|
75
110
|
dropFile.run(path);
|
|
76
111
|
dropSymbols.run(path);
|
|
77
|
-
dropEdges.run(path
|
|
112
|
+
dropEdges.run(path);
|
|
113
|
+
dropIncoming.run(path);
|
|
78
114
|
dropFts.run(path);
|
|
79
115
|
dropEmbed.run(path);
|
|
80
116
|
}
|
|
@@ -90,10 +126,26 @@ export async function writeSqlite(root, next, removed, symbols, edges, fts, embe
|
|
|
90
126
|
db.exec('commit');
|
|
91
127
|
} catch (error) {
|
|
92
128
|
try { db.exec('rollback'); } catch { /* a failed begin has nothing to undo */ }
|
|
93
|
-
throw error;
|
|
129
|
+
throw asIndexError(error);
|
|
94
130
|
} finally {
|
|
95
131
|
db.close();
|
|
96
132
|
}
|
|
133
|
+
if (cold) await rename(target, file);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A contended index write is a retryable race, not a broken repository: name it
|
|
138
|
+
* so the command maps its own remedy instead of blaming the git checkout (Q08).
|
|
139
|
+
*/
|
|
140
|
+
function asIndexError(error) {
|
|
141
|
+
const code = error?.code ?? error?.errno;
|
|
142
|
+
if (code === 'SQLITE_BUSY' || code === 5 || /database is locked/i.test(error?.message ?? '')) {
|
|
143
|
+
const busy = new Error('another index run is writing; wait a moment and run again');
|
|
144
|
+
busy.code = 'INDEX_BUSY';
|
|
145
|
+
busy.cause = error;
|
|
146
|
+
return busy;
|
|
147
|
+
}
|
|
148
|
+
return error;
|
|
97
149
|
}
|
|
98
150
|
|
|
99
151
|
export async function writeJsonl(root, previous, next, removed, symbols, edges, fts, force, embeddings = []) {
|
|
@@ -109,7 +161,9 @@ export async function writeJsonl(root, previous, next, removed, symbols, edges,
|
|
|
109
161
|
for (const row of replaced.values()) files.push(row);
|
|
110
162
|
files.sort(byPath);
|
|
111
163
|
const keptSymbols = force ? [] : (await readJsonl(root, 'symbols')).filter((row) => !drop.has(row.path));
|
|
112
|
-
|
|
164
|
+
// Edge ownership mirrors the sqlite backend: a changed file loses only the edges
|
|
165
|
+
// it owns (src), while a removed file also drops the edges that pointed at it.
|
|
166
|
+
const keptEdges = force ? [] : (await readJsonl(root, 'edges')).filter((row) => !drop.has(row.src) && !gone.has(row.dst));
|
|
113
167
|
const keptFts = force ? [] : (await readJsonl(root, 'fts')).filter((row) => !drop.has(row.ref));
|
|
114
168
|
const newEmbedPaths = new Set(embeddings.map((row) => row.path));
|
|
115
169
|
const keptEmbeddings = force
|
|
@@ -121,6 +175,8 @@ export async function writeJsonl(root, previous, next, removed, symbols, edges,
|
|
|
121
175
|
const allEmbeddings = [...keptEmbeddings, ...embeddings].sort(byPath);
|
|
122
176
|
const file = join(root, JSONL_FILE);
|
|
123
177
|
await mkdir(dirname(file), { recursive: true });
|
|
178
|
+
// Written atomically: a concurrent reader never sees a half-written file.
|
|
179
|
+
const tmp = tmpFor(file);
|
|
124
180
|
const lines = [
|
|
125
181
|
JSON.stringify({ table: 'meta', key: 'schema', value: SCHEMA_V }),
|
|
126
182
|
...files.map((row) => JSON.stringify({ table: 'files', path: row.path, blob: row.blob, lang: row.lang, bytes: row.bytes, pkg: row.pkg })),
|
|
@@ -140,7 +196,8 @@ export async function writeJsonl(root, previous, next, removed, symbols, edges,
|
|
|
140
196
|
});
|
|
141
197
|
}),
|
|
142
198
|
];
|
|
143
|
-
await writeFile(
|
|
199
|
+
await writeFile(tmp, `${lines.join('\n')}\n`);
|
|
200
|
+
await rename(tmp, file);
|
|
144
201
|
return { symbols: allSymbols.length, edges: allEdges.length, embeddings: allEmbeddings.length };
|
|
145
202
|
}
|
|
146
203
|
|