simt-dev 0.6.0 → 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 +71 -0
- package/README.md +44 -15
- package/bin/simt.mjs +19 -6
- package/docs/decisions/ADR-098-the-fourth-channel-is-one-file-and-it-needs-no-toolchain.md +1 -1
- package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +79 -0
- 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/ci/workflow.mjs +7 -5
- 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/commands/hooks.mjs
CHANGED
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
// foreign hook is found by asking git rather than by reading config.
|
|
14
14
|
|
|
15
15
|
import { execFile } from 'node:child_process';
|
|
16
|
-
import { chmod, mkdir, readFile, realpath
|
|
16
|
+
import { chmod, mkdir, readFile, realpath } from 'node:fs/promises';
|
|
17
|
+
import { commitJournal, journaledRemoveFile, journaledWriteFile } from '../journal.mjs';
|
|
18
|
+
import { diffField, emitDiffs, readBefore } from '../diff.mjs';
|
|
19
|
+
import { invalidateCheckCache } from '../status.mjs';
|
|
17
20
|
import { isAbsolute, join, relative } from 'node:path';
|
|
18
21
|
import { promisify } from 'node:util';
|
|
19
22
|
|
|
@@ -23,7 +26,7 @@ import { EVENTS, HOOKS_DIR, HOOKS_PATH_KEY, SHIM_MARKER, fileFor, renderChainedS
|
|
|
23
26
|
import { GATE_KEY, gateCount, readHooksPath, readHooksState, wantsShim } from '../compare/hooksstate.mjs';
|
|
24
27
|
import { GATES } from '../hooks/gates.mjs';
|
|
25
28
|
import { knownGates } from '../hooks/resolve.mjs';
|
|
26
|
-
import { runGates } from '../hooks/dispatcher.mjs';
|
|
29
|
+
import { renderGateReport, runGates } from '../hooks/dispatcher.mjs';
|
|
27
30
|
import { CLI } from '../name.mjs';
|
|
28
31
|
import { MANIFEST_FILE } from '../name.mjs';
|
|
29
32
|
import { packageVersion, hasNpm } from '../package-version.mjs';
|
|
@@ -60,14 +63,7 @@ async function runHook(args, flags) {
|
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
for (const n of result.notes ?? []) note(`note ${n}`);
|
|
63
|
-
|
|
64
|
-
for (const g of result.gates) {
|
|
65
|
-
lines.push(`${g.status.toUpperCase().padEnd(8)} ${g.name.padEnd(20)} ${g.detail}${g.adr ? ` [${g.adr}]` : ''}`);
|
|
66
|
-
if (g.remedy) lines.push(` remedy: ${g.remedy}`);
|
|
67
|
-
if (g.output) lines.push(...g.output.split('\n').map((l) => ` ${l}`));
|
|
68
|
-
}
|
|
69
|
-
lines.push(result.failed ? 'BLOCKED at least one gate failed' : 'PASSED');
|
|
70
|
-
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
66
|
+
emit(renderGateReport(event, manifest.gates[GATE_KEY[event]] ?? [], result).join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
71
67
|
return result.failed ? EXIT.FAIL : EXIT.OK;
|
|
72
68
|
}
|
|
73
69
|
|
|
@@ -101,27 +97,26 @@ async function relativeTarget(root, target) {
|
|
|
101
97
|
return rel === '' || rel.startsWith('..') ? resolved : rel;
|
|
102
98
|
}
|
|
103
99
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
100
|
+
/**
|
|
101
|
+
* What `hooks install` would write, computed without writing or reporting (plan 0003 R47).
|
|
102
|
+
*
|
|
103
|
+
* `installHooks` and `doctor --fix` share this rather than each deriving the write
|
|
104
|
+
* set: the set nothing else here touches (obsolete shims, chained foreign hooks)
|
|
105
|
+
* is exactly the set a second derivation gets wrong, and a fix that writes bytes
|
|
106
|
+
* the command would not is a fix that lies about what it did. Reporting stays
|
|
107
|
+
* with the callers — the plan table belongs to the command, the finding line to
|
|
108
|
+
* the doctor — so this returns facts, never text.
|
|
109
|
+
*
|
|
110
|
+
* @returns the install inputs `applyHooksInstall` needs, plus the guards each
|
|
111
|
+
* caller enforces in its own voice (`takeover`, and the `wanted`/`obsolete`
|
|
112
|
+
* emptiness the command refuses on)
|
|
113
|
+
*/
|
|
114
|
+
export async function planHooksInstall(root, { manifest, exists }) {
|
|
111
115
|
const declaresGate = (event) => wantsShim(manifest, exists, event);
|
|
112
116
|
const state = await readHooksState(root, { declaresGate });
|
|
113
117
|
const current = state.current;
|
|
114
118
|
const wants = HOOKS_DIR;
|
|
115
119
|
|
|
116
|
-
// Read through `writeMode` like every other writer, and only now: this command
|
|
117
|
-
// used to consult `flags.yes` and `flags.dryRun` directly and write when neither
|
|
118
|
-
// said otherwise, which made it the last writer still writing by default after
|
|
119
|
-
// ADR-035 and ADR-036 had settled that none of them may. Review 0002's S24
|
|
120
|
-
// listed it among the writers that "all preview first"; that was wrong, and the
|
|
121
|
-
// migration script in the same review tells an operator to run `hooks install`
|
|
122
|
-
// for a plan and then `--yes` - advice that printed a plan and wrote anyway.
|
|
123
|
-
const mode = writeMode(flags);
|
|
124
|
-
|
|
125
120
|
// The release this install renders, and the kind of repository it installs into: the
|
|
126
121
|
// remedy a shim prints when it cannot find the binary must name the install that
|
|
127
122
|
// actually works here — a devDependency where package.json exists, a pinned global
|
|
@@ -240,6 +235,148 @@ async function installHooks(root, flags) {
|
|
|
240
235
|
if (at !== -1) chained.splice(at, 1);
|
|
241
236
|
}
|
|
242
237
|
|
|
238
|
+
const routed = wanted.length > 0 || chained.length > 0 || evidenceDeclared;
|
|
239
|
+
const takeover = (current !== null && current !== wants) || state.foreign.length > 0;
|
|
240
|
+
return {
|
|
241
|
+
manifest,
|
|
242
|
+
state,
|
|
243
|
+
current,
|
|
244
|
+
wants,
|
|
245
|
+
npm,
|
|
246
|
+
version,
|
|
247
|
+
wanted,
|
|
248
|
+
chained,
|
|
249
|
+
leftRunning,
|
|
250
|
+
obsolete,
|
|
251
|
+
evidenceDeclared,
|
|
252
|
+
evidenceSource,
|
|
253
|
+
evidenceFile,
|
|
254
|
+
// Returned so a caller can tell a current claim check from a stale one
|
|
255
|
+
// without re-reading the file: the evidence shim is not one of EVENTS, so
|
|
256
|
+
// neither the problems list nor the obsolete set names its staleness.
|
|
257
|
+
evidenceExisting,
|
|
258
|
+
routed,
|
|
259
|
+
takeover,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Write a planned install: shims, chains, removals, and the git routing (plan 0003 R47).
|
|
265
|
+
*
|
|
266
|
+
* The caller's journal records the file writes, and the caller commits it under
|
|
267
|
+
* its own command name — `hooks install` here, `doctor --fix` there — so `undo`
|
|
268
|
+
* names the run that actually changed something. Reporting stays with the
|
|
269
|
+
* caller for the same reason it does in `planHooksInstall`.
|
|
270
|
+
*
|
|
271
|
+
* @returns {{wrote: string[]}} the shim paths written, removed, or refreshed
|
|
272
|
+
*/
|
|
273
|
+
export async function applyHooksInstall(root, planned, journal) {
|
|
274
|
+
const { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, version, routed, wants, current } =
|
|
275
|
+
planned;
|
|
276
|
+
// Shim and evidence bytes are journaled so `undo` restores them (R49). The
|
|
277
|
+
// `core.hooksPath` config below is deliberately not journaled: it is git
|
|
278
|
+
// config, not a repo file, and `undo` restores files.
|
|
279
|
+
const wrote = [];
|
|
280
|
+
await mkdir(join(root, HOOKS_DIR), { recursive: true });
|
|
281
|
+
for (const event of wanted) {
|
|
282
|
+
const path = join(root, fileFor(event));
|
|
283
|
+
await journaledWriteFile(root, journal, path, renderShim(event, { npm, version }));
|
|
284
|
+
await chmod(path, 0o755);
|
|
285
|
+
wrote.push(fileFor(event));
|
|
286
|
+
}
|
|
287
|
+
for (const c of chained) {
|
|
288
|
+
const path = join(root, fileFor(c.event));
|
|
289
|
+
await journaledWriteFile(root, journal, path, renderChainedShim(c.event, c.target));
|
|
290
|
+
await chmod(path, 0o755);
|
|
291
|
+
wrote.push(fileFor(c.event));
|
|
292
|
+
}
|
|
293
|
+
for (const h of obsolete) {
|
|
294
|
+
await journaledRemoveFile(root, journal, join(root, fileFor(h.event)));
|
|
295
|
+
wrote.push(fileFor(h.event));
|
|
296
|
+
}
|
|
297
|
+
// After the chain loop, so a chained commit-msg cannot replace the claim check.
|
|
298
|
+
if (evidenceSource) {
|
|
299
|
+
const path = join(root, evidenceFile);
|
|
300
|
+
await journaledWriteFile(root, journal, path, evidenceSource);
|
|
301
|
+
await chmod(path, 0o755);
|
|
302
|
+
wrote.push(evidenceFile);
|
|
303
|
+
}
|
|
304
|
+
// `core.hooksPath` tells git to read ONE directory for EVERY event, so leaving it
|
|
305
|
+
// pointing at a directory of no shims stops the repository's own hooks - the ones in
|
|
306
|
+
// `.git/hooks` that predate this tool - from running, silently. Unsetting it is the
|
|
307
|
+
// only way back, and this is the only place that knows the last shim just left.
|
|
308
|
+
if (routed) {
|
|
309
|
+
await exec('git', ['config', HOOKS_PATH_KEY, wants], { cwd: root });
|
|
310
|
+
} else if (current === wants) {
|
|
311
|
+
await exec('git', ['config', '--unset', HOOKS_PATH_KEY], { cwd: root });
|
|
312
|
+
}
|
|
313
|
+
return { wrote };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The `--diff` pairs for a planned install: current disk against what the
|
|
318
|
+
* install would write, re-rendered from the same inputs `applyHooksInstall`
|
|
319
|
+
* writes (plan 0003 R50). Read-only, so preview and apply share it, and
|
|
320
|
+
* `doctor --fix` reuses it rather than deriving a second answer.
|
|
321
|
+
*/
|
|
322
|
+
export async function plannedDiffs(root, planned) {
|
|
323
|
+
const { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, version } = planned;
|
|
324
|
+
const pairs = [];
|
|
325
|
+
for (const event of wanted) {
|
|
326
|
+
const path = fileFor(event);
|
|
327
|
+
pairs.push({ path, before: await readBefore(join(root, path)), after: renderShim(event, { npm, version }) });
|
|
328
|
+
}
|
|
329
|
+
for (const c of chained) {
|
|
330
|
+
const path = fileFor(c.event);
|
|
331
|
+
pairs.push({ path, before: await readBefore(join(root, path)), after: renderChainedShim(c.event, c.target) });
|
|
332
|
+
}
|
|
333
|
+
for (const h of obsolete) {
|
|
334
|
+
const path = fileFor(h.event);
|
|
335
|
+
pairs.push({ path, before: await readBefore(join(root, path)), after: null });
|
|
336
|
+
}
|
|
337
|
+
if (evidenceSource) {
|
|
338
|
+
pairs.push({ path: evidenceFile, before: await readBefore(join(root, evidenceFile)), after: evidenceSource });
|
|
339
|
+
}
|
|
340
|
+
return pairs;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function installHooks(root, flags) {
|
|
344
|
+
const { manifest, exists, error } = await readManifest(root);
|
|
345
|
+
if (error) return fail(`${MANIFEST_FILE}: ${error}`, EXIT.CONFIG);
|
|
346
|
+
if (!exists) {
|
|
347
|
+
return fail(`${root} has no ${MANIFEST_FILE}; gates must be declared before hooks can run them`, EXIT.CONFIG, `run: ${CLI} init --root ${root}`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// One computation, two callers (R47): the write set is derived once in
|
|
351
|
+
// `planHooksInstall` and shared with `doctor --fix`, so a fix applies exactly
|
|
352
|
+
// the bytes this command would write. Reporting stays here.
|
|
353
|
+
const {
|
|
354
|
+
state,
|
|
355
|
+
current,
|
|
356
|
+
wants,
|
|
357
|
+
npm,
|
|
358
|
+
version,
|
|
359
|
+
wanted,
|
|
360
|
+
chained,
|
|
361
|
+
leftRunning,
|
|
362
|
+
obsolete,
|
|
363
|
+
evidenceDeclared,
|
|
364
|
+
evidenceSource,
|
|
365
|
+
evidenceFile,
|
|
366
|
+
evidenceExisting,
|
|
367
|
+
routed,
|
|
368
|
+
takeover,
|
|
369
|
+
} = await planHooksInstall(root, { manifest, exists });
|
|
370
|
+
|
|
371
|
+
// Read through `writeMode` like every other writer, and only now: this command
|
|
372
|
+
// used to consult `flags.yes` and `flags.dryRun` directly and write when neither
|
|
373
|
+
// said otherwise, which made it the last writer still writing by default after
|
|
374
|
+
// ADR-035 and ADR-036 had settled that none of them may. Review 0002's S24
|
|
375
|
+
// listed it among the writers that "all preview first"; that was wrong, and the
|
|
376
|
+
// migration script in the same review tells an operator to run `hooks install`
|
|
377
|
+
// for a plan and then `--yes` - advice that printed a plan and wrote anyway.
|
|
378
|
+
const mode = writeMode(flags);
|
|
379
|
+
|
|
243
380
|
// Nothing to write and nothing to take away is the only refusal. It sits after
|
|
244
381
|
// `obsolete` because a repository whose last gate just left the manifest has
|
|
245
382
|
// something to take away, and blocking that one left no way out except undoing
|
|
@@ -307,7 +444,6 @@ async function installHooks(root, flags) {
|
|
|
307
444
|
note: 'commit-msg checks a verification claim against the ledger',
|
|
308
445
|
});
|
|
309
446
|
}
|
|
310
|
-
const routed = wanted.length > 0 || chained.length > 0 || evidenceDeclared;
|
|
311
447
|
|
|
312
448
|
// The routing this install leaves behind, which is not always `wants`. A repository
|
|
313
449
|
// that removed its last gate has no shims to route to, and leaving `core.hooksPath`
|
|
@@ -324,7 +460,6 @@ async function installHooks(root, flags) {
|
|
|
324
460
|
// being unset while a hook sits in the default location (4 KB of real logic in
|
|
325
461
|
// .git/hooks/pre-commit). Reading unset as
|
|
326
462
|
// "no hooks" is what let install replace a working gate with no warning.
|
|
327
|
-
const takeover = (current !== null && current !== wants) || state.foreign.length > 0;
|
|
328
463
|
if (takeover && !flags.yes) {
|
|
329
464
|
const detail = state.foreign.length > 0
|
|
330
465
|
? state.foreign.map((f) => ` git runs ${f.event} at ${f.path} (${f.bytes} bytes)`)
|
|
@@ -348,12 +483,29 @@ async function installHooks(root, flags) {
|
|
|
348
483
|
return EXIT.FAIL;
|
|
349
484
|
}
|
|
350
485
|
|
|
486
|
+
// The `--diff` pairs, read before anything is written so the before side is
|
|
487
|
+
// true in preview and in apply alike (R50).
|
|
488
|
+
const diffs =
|
|
489
|
+
flags.diff === true
|
|
490
|
+
? await plannedDiffs(root, { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, version })
|
|
491
|
+
: [];
|
|
492
|
+
|
|
351
493
|
if (!mode.apply) {
|
|
352
494
|
if (isJson(flags)) {
|
|
353
495
|
// `leaves`, not `wants`. A plan that would remove the last shim also un-routes
|
|
354
496
|
// git, and a payload promising to set a directory the command is about to unset
|
|
355
497
|
// is the same lie in a shape a machine reads.
|
|
356
|
-
emitJson({
|
|
498
|
+
emitJson({
|
|
499
|
+
ok: true,
|
|
500
|
+
command: 'hooks install',
|
|
501
|
+
root,
|
|
502
|
+
dryRun: true,
|
|
503
|
+
currentHooksPath: current,
|
|
504
|
+
wouldSet: leaves,
|
|
505
|
+
plan,
|
|
506
|
+
filesWritten: false,
|
|
507
|
+
...diffField(diffs, flags),
|
|
508
|
+
});
|
|
357
509
|
return EXIT.OK;
|
|
358
510
|
}
|
|
359
511
|
emit(
|
|
@@ -373,39 +525,23 @@ async function installHooks(root, flags) {
|
|
|
373
525
|
),
|
|
374
526
|
{ maxBytes: flags.maxBytes ?? 0 },
|
|
375
527
|
);
|
|
528
|
+
// After the report: the report says what, the diff shows it (R50).
|
|
529
|
+
emitDiffs(diffs, flags);
|
|
376
530
|
return EXIT.OK;
|
|
377
531
|
}
|
|
378
532
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
await rm(join(root, fileFor(h.event)));
|
|
392
|
-
}
|
|
393
|
-
// After the chain loop, so a chained commit-msg cannot replace the claim check.
|
|
394
|
-
if (evidenceSource) {
|
|
395
|
-
const path = join(root, evidenceFile);
|
|
396
|
-
await writeFile(path, evidenceSource);
|
|
397
|
-
await chmod(path, 0o755);
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// `core.hooksPath` tells git to read ONE directory for EVERY event, so leaving it
|
|
401
|
-
// pointing at a directory of no shims stops the repository's own hooks - the ones in
|
|
402
|
-
// `.git/hooks` that predate this tool - from running, silently. Unsetting it is the
|
|
403
|
-
// only way back, and this is the only place that knows the last shim just left.
|
|
404
|
-
if (routed) {
|
|
405
|
-
await exec('git', ['config', HOOKS_PATH_KEY, wants], { cwd: root });
|
|
406
|
-
} else if (current === wants) {
|
|
407
|
-
await exec('git', ['config', '--unset', HOOKS_PATH_KEY], { cwd: root });
|
|
408
|
-
}
|
|
533
|
+
// Same bytes, two callers (R47): the write itself lives in `applyHooksInstall`
|
|
534
|
+
// and is shared with `doctor --fix`, so a fix installs what the command would.
|
|
535
|
+
const journal = [];
|
|
536
|
+
await applyHooksInstall(
|
|
537
|
+
root,
|
|
538
|
+
{ wanted, chained, obsolete, evidenceSource, evidenceFile, npm, version, routed, wants, current },
|
|
539
|
+
journal,
|
|
540
|
+
);
|
|
541
|
+
await commitJournal(root, 'hooks install', journal);
|
|
542
|
+
// Shims and routing changed, so the hooks slot of the last `check` is stale
|
|
543
|
+
// (R48). Gated on the journal: an already-current install changes nothing.
|
|
544
|
+
if (journal.length > 0) await invalidateCheckCache(root);
|
|
409
545
|
|
|
410
546
|
const notes = [];
|
|
411
547
|
const chainedEvents = new Set(chained.map((c) => c.event));
|
|
@@ -453,7 +589,17 @@ async function installHooks(root, flags) {
|
|
|
453
589
|
}
|
|
454
590
|
|
|
455
591
|
if (isJson(flags)) {
|
|
456
|
-
emitJson({
|
|
592
|
+
emitJson({
|
|
593
|
+
ok: true,
|
|
594
|
+
command: 'hooks install',
|
|
595
|
+
root,
|
|
596
|
+
hooksPath: leaves,
|
|
597
|
+
previousHooksPath: current,
|
|
598
|
+
plan,
|
|
599
|
+
filesWritten: true,
|
|
600
|
+
notes,
|
|
601
|
+
...diffField(diffs, flags),
|
|
602
|
+
});
|
|
457
603
|
return EXIT.OK;
|
|
458
604
|
}
|
|
459
605
|
emit(
|
|
@@ -468,6 +614,8 @@ async function installHooks(root, flags) {
|
|
|
468
614
|
),
|
|
469
615
|
{ maxBytes: flags.maxBytes ?? 0 },
|
|
470
616
|
);
|
|
617
|
+
// After the report: the report says what, the diff shows it (R50).
|
|
618
|
+
emitDiffs(diffs, flags);
|
|
471
619
|
for (const n of notes) note(`note ${n}`);
|
|
472
620
|
note(`note never committed or pushed (ADR-001); review, then commit yourself`);
|
|
473
621
|
return EXIT.OK;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// `simt ignore` — render the one ignore list into every reader's dialect
|
|
2
|
+
// (plan 0003 R52).
|
|
3
|
+
//
|
|
4
|
+
// Shape copied from `ci`: the plan is printed first, nothing is written
|
|
5
|
+
// without `--yes`, `--diff` shows the same plan as unified diffs and `undo`
|
|
6
|
+
// restores the set (R49). Three of the four targets are generated from the
|
|
7
|
+
// list; `.claude/settings.json` is merged, never replaced — see src/ignore.mjs
|
|
8
|
+
// for why that distinction exists.
|
|
9
|
+
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
|
|
13
|
+
import { CLI } from '../name.mjs';
|
|
14
|
+
import { commitJournal, journaledWriteFile } from '../journal.mjs';
|
|
15
|
+
import { diffField, emitDiffs, readBefore } from '../diff.mjs';
|
|
16
|
+
import { CLAUDE_SETTINGS, IGNORE_FILE, parseIgnoreList, planIgnore } from '../ignore.mjs';
|
|
17
|
+
|
|
18
|
+
export async function ignoreCommand(args, flags) {
|
|
19
|
+
const root = flags.root ?? process.cwd();
|
|
20
|
+
|
|
21
|
+
let source;
|
|
22
|
+
try {
|
|
23
|
+
source = await readBefore(join(root, IGNORE_FILE));
|
|
24
|
+
} catch (error) {
|
|
25
|
+
return fail(`${IGNORE_FILE} could not be read: ${error.message}`, EXIT.FAIL, `fix the file's permissions, then re-run: ${CLI} ignore`);
|
|
26
|
+
}
|
|
27
|
+
if (source === null) {
|
|
28
|
+
// A missing list is a missing input, not an empty one: rendering four empty
|
|
29
|
+
// files from a file that does not exist would answer the request with
|
|
30
|
+
// silence the reader has no way to tell from success.
|
|
31
|
+
return fail(
|
|
32
|
+
`no ${IGNORE_FILE} in ${root}`,
|
|
33
|
+
EXIT.FAIL,
|
|
34
|
+
`create ${IGNORE_FILE} with one path or glob per line (# comments), then: ${CLI} ignore`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const entries = parseIgnoreList(source);
|
|
39
|
+
if (entries.length === 0) {
|
|
40
|
+
return fail(
|
|
41
|
+
`${IGNORE_FILE} parses to zero entries`,
|
|
42
|
+
EXIT.FAIL,
|
|
43
|
+
`add one path or glob per line to ${IGNORE_FILE}, then: ${CLI} ignore`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const plan = await planIgnore(entries, (path) => readBefore(join(root, path)));
|
|
48
|
+
if (!plan.ok) {
|
|
49
|
+
return fail(plan.error, EXIT.FAIL, `repair ${CLAUDE_SETTINGS} by hand, then re-run: ${CLI} ignore`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const mode = writeMode(flags);
|
|
53
|
+
const changed = plan.targets.filter((t) => t.action !== 'unchanged');
|
|
54
|
+
const diffs =
|
|
55
|
+
flags.diff === true
|
|
56
|
+
? changed.map((t) => ({ path: t.path, before: t.before, after: t.after }))
|
|
57
|
+
: [];
|
|
58
|
+
|
|
59
|
+
// Nothing to do: the report says so from the same plan the write would have
|
|
60
|
+
// used, so "unchanged" cannot disagree with what `--yes` would have written.
|
|
61
|
+
if (changed.length === 0) {
|
|
62
|
+
if (isJson(flags)) {
|
|
63
|
+
emitJson({
|
|
64
|
+
ok: true,
|
|
65
|
+
command: 'ignore',
|
|
66
|
+
root,
|
|
67
|
+
source: IGNORE_FILE,
|
|
68
|
+
entries: entries.length,
|
|
69
|
+
files: plan.targets.map((t) => ({ path: t.path, action: t.action, bytes: byteLen(t.after) })),
|
|
70
|
+
written: false,
|
|
71
|
+
dryRun: false,
|
|
72
|
+
});
|
|
73
|
+
} else {
|
|
74
|
+
emit(plan.targets.map((t) => `current ${t.path} ${byteLen(t.after)} bytes`).join('\n'));
|
|
75
|
+
note('every target already matches the list; nothing to write');
|
|
76
|
+
}
|
|
77
|
+
return EXIT.OK;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const lines = [
|
|
81
|
+
`repo ${root}`,
|
|
82
|
+
`source ${IGNORE_FILE} (${entries.length} entr${entries.length === 1 ? 'y' : 'ies'})`,
|
|
83
|
+
'',
|
|
84
|
+
];
|
|
85
|
+
for (const t of plan.targets) {
|
|
86
|
+
lines.push(`file ${t.path}`, `action ${t.action}`);
|
|
87
|
+
if (t.action === 'update' && t.before !== null) {
|
|
88
|
+
// An update whose before side has lines the list does not: those lines
|
|
89
|
+
// are about to stop existing. Named here because a preview that only says
|
|
90
|
+
// "update" hides the deletion inside the diff the reader has to ask for.
|
|
91
|
+
const dropped = countDropped(t.before, t.after);
|
|
92
|
+
if (dropped > 0) lines.push(`replaces ${dropped} line(s) not in ${IGNORE_FILE} (shown by --diff)`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Exactly one JSON document per run, emitted once on whichever path ends it:
|
|
97
|
+
// a preview and its write are two phases of ONE answer, and a reader that
|
|
98
|
+
// parses the first document gets "written: false" while the run writes.
|
|
99
|
+
const payload = (written) => ({
|
|
100
|
+
ok: true,
|
|
101
|
+
command: 'ignore',
|
|
102
|
+
root,
|
|
103
|
+
source: IGNORE_FILE,
|
|
104
|
+
entries: entries.length,
|
|
105
|
+
files: plan.targets.map((t) => ({ path: t.path, action: t.action, bytes: byteLen(t.after) })),
|
|
106
|
+
written,
|
|
107
|
+
dryRun: !mode.apply,
|
|
108
|
+
...diffField(diffs, flags),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (!isJson(flags)) emit(lines.join('\n'));
|
|
112
|
+
|
|
113
|
+
if (!mode.apply) {
|
|
114
|
+
// After the report: the report says what, the diff shows it (R50).
|
|
115
|
+
emitDiffs(diffs, flags);
|
|
116
|
+
if (isJson(flags)) {
|
|
117
|
+
emitJson(payload(false));
|
|
118
|
+
} else {
|
|
119
|
+
note('');
|
|
120
|
+
note(mode.dryRun ? 'dry-run nothing written' : `dry-run nothing written; apply with: ${CLI} ignore --yes`);
|
|
121
|
+
}
|
|
122
|
+
return EXIT.OK;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Journaled so `undo` restores this run (R49). Every note writes to stderr,
|
|
126
|
+
// so the `--json` document remains the only bytes on stdout.
|
|
127
|
+
const journal = [];
|
|
128
|
+
for (const t of changed) {
|
|
129
|
+
await journaledWriteFile(root, journal, join(root, t.path), t.after);
|
|
130
|
+
note(`wrote ${t.path} (${t.action})`);
|
|
131
|
+
}
|
|
132
|
+
await commitJournal(root, 'ignore', journal);
|
|
133
|
+
|
|
134
|
+
if (isJson(flags)) emitJson(payload(true));
|
|
135
|
+
// After the report: the report says what, the diff shows it (R50).
|
|
136
|
+
emitDiffs(diffs, flags);
|
|
137
|
+
return EXIT.OK;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function byteLen(text) {
|
|
141
|
+
return Buffer.byteLength(text, 'utf8');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* How many lines the new bytes drop from the old: lines the current file has
|
|
146
|
+
* that the list does not produce. Used only for the plan's `replaces` note —
|
|
147
|
+
* the diff is the authoritative statement of what goes.
|
|
148
|
+
*/
|
|
149
|
+
function countDropped(before, after) {
|
|
150
|
+
const kept = new Set(after.split('\n'));
|
|
151
|
+
return before.split('\n').filter((line) => line.trim() !== '' && !kept.has(line)).length;
|
|
152
|
+
}
|