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/commands/help.mjs
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
|
-
import { emit } from '../output/index.mjs';
|
|
2
|
-
import { parse } from '../argv.mjs';
|
|
3
|
-
import { CLI, MANIFEST_FILE } from '../name.mjs';
|
|
1
|
+
import { emit, fail, jsonFailMode } from '../output/index.mjs';
|
|
2
|
+
import { parse, rawJsonFlag } from '../argv.mjs';
|
|
3
|
+
import { CLI, MANIFEST_FILE, STATE_DIR } from '../name.mjs';
|
|
4
|
+
import { SESSION_ENV } from '../agenthooks/policy.mjs';
|
|
4
5
|
import { DEFAULT_DIRECTORIES, DEFAULT_TEMPLATE } from '../plan/template.mjs';
|
|
5
6
|
|
|
6
7
|
const COMMANDS = `${CLI} init [path] adopt or scaffold a repo into the ecosystem
|
|
7
8
|
(dry-run unless --yes; conflicts block without
|
|
8
9
|
--accept-conflicts; in place unless --backup;
|
|
9
10
|
never commits or pushes)
|
|
10
|
-
${CLI} doctor
|
|
11
|
+
${CLI} doctor [--fix] classify a repo and check config health (with --fix:
|
|
12
|
+
apply the remedies flagged safe and list the rest;
|
|
13
|
+
dry-run unless --yes)
|
|
14
|
+
${CLI} status --line one line from the last check, for prompts and tmux
|
|
15
|
+
(as of the last check; STALE after new commits)
|
|
11
16
|
${CLI} check the gate: projection drift, manifest validity, policy
|
|
12
17
|
${CLI} sync [--yes] regenerate the projection (idempotent)
|
|
13
18
|
(prints the plan and exits 1 on drift without --yes,
|
|
@@ -58,20 +63,41 @@ ${CLI} agent-hooks install write this repo's policies into the harnesses tha
|
|
|
58
63
|
key it does not own; Codex needs the hook trusted)
|
|
59
64
|
${CLI} agent-hooks status what each harness will run, and what is not trusted yet
|
|
60
65
|
${CLI} plugin <claude|gemini|cursor> generate a harness package from the same tables
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
66
|
+
(dry-run unless --yes; writes a self-contained runtime, the
|
|
67
|
+
hooks and MCP registration, and for cursor the kernel rules;
|
|
68
|
+
the skill registry is workspace data and is not shipped)
|
|
69
|
+
${CLI} completions <shell> print a bash, zsh or fish completion script
|
|
70
|
+
(generated from the command registry, so every command
|
|
71
|
+
completes; deterministic bytes; stdout only - redirect
|
|
72
|
+
or source it)
|
|
64
73
|
${CLI} plan status count tracker rows
|
|
65
74
|
(${MANIFEST_FILE} key plans when set, otherwise
|
|
66
75
|
the ${DEFAULT_TEMPLATE} template:
|
|
67
76
|
${DEFAULT_DIRECTORIES.join(' and ')})
|
|
68
77
|
${CLI} plan next [<id>] one ready slice's section, or the one named; ~1 KB
|
|
69
78
|
${CLI} plan done <id> [<sha>] mark one row; dry-run unless --yes
|
|
79
|
+
${CLI} slice start <id> give a slice its own worktree and branch, preload the
|
|
80
|
+
brief, and mark the row In progress (dry-run unless
|
|
81
|
+
--yes; the worktree is ${STATE_DIR}/worktrees/<id> on
|
|
82
|
+
branch slice/<id>, the brief ${STATE_DIR}/brief.md in it)
|
|
83
|
+
${CLI} slice finish <id> run the pre-commit gates, commit what is staged with a
|
|
84
|
+
Conventional subject from the row's title, then record
|
|
85
|
+
the SHA and the --test command on the row (dry-run
|
|
86
|
+
unless --yes; --type picks the type, default feat)
|
|
87
|
+
${CLI} lease [<paths>...] claim files for one session and a TTL, or list the
|
|
88
|
+
claims (dry-run unless --yes; the session is --session
|
|
89
|
+
<id> or ${SESSION_ENV}; a pre-edit hook refuses an edit
|
|
90
|
+
to a path another session holds — ADR-101)
|
|
70
91
|
${CLI} graph lint check the skill registry against the skills on disk
|
|
71
92
|
(byte counts, dangling edges, unresolvable variants)
|
|
72
93
|
${CLI} ci simt-dev's own .github files: create or refresh them
|
|
73
94
|
(dry-run unless --yes; refreshes the templates' own copies
|
|
74
95
|
and no repository else's)
|
|
96
|
+
${CLI} ignore [--yes] render the one .simtignore list into .cursorignore,
|
|
97
|
+
.aiderignore, .geminiignore and Claude's
|
|
98
|
+
permissions.deny Read(...) entries (dry-run unless
|
|
99
|
+
--yes; --diff prints the plan as unified diffs; the
|
|
100
|
+
Claude settings file is merged, never replaced)
|
|
75
101
|
${CLI} compare org-wide matrix across every sibling repo
|
|
76
102
|
(read-only; exits non-zero on defects, not on
|
|
77
103
|
repos that have not adopted yet)
|
|
@@ -93,6 +119,11 @@ ${CLI} map [--task "<text>"] [--level outline|names|signatures]
|
|
|
93
119
|
--budget bytes (default 2048; 0 is unlimited). outline
|
|
94
120
|
is paths, names adds exports, signatures adds the line.
|
|
95
121
|
--recall reports gold-file recall@10 on mined tasks
|
|
122
|
+
${CLI} pr-body [--base <ref>] a pull request body: the evidence block for this tree,
|
|
123
|
+
the decision records the diff touches, the gate
|
|
124
|
+
state from the last check, and the residual risk
|
|
125
|
+
in ADR-008 vocabulary (read-only; the exit code
|
|
126
|
+
never carries the verdict)
|
|
96
127
|
${CLI} evidence the runs recorded against the tree on disk
|
|
97
128
|
${CLI} evidence check <file> a commit message that claims a run this tree does not
|
|
98
129
|
have: a warning, or a block when gates.evidence is "block"
|
|
@@ -100,10 +131,17 @@ ${CLI} lessons list deterministic lesson candidates in .simt/lessons.
|
|
|
100
131
|
and promoted lessons in docs/lessons/*.md
|
|
101
132
|
${CLI} lessons review [<id>] promote a lesson candidate to docs/lessons/NNNN-<slug>.md
|
|
102
133
|
(dry-run unless --yes; writes Appendix A frontmatter)
|
|
134
|
+
${CLI} explain <CODE> what a check finding means: the decision behind it, how
|
|
135
|
+
to fix it, and a concrete trigger (DRIFT, UNMANAGED,
|
|
136
|
+
PACK-DIGEST-MISMATCH, ...; case-insensitive)
|
|
103
137
|
${CLI} decide [<title>] scaffold an ADR with the next free number, a Status: line,
|
|
104
138
|
a rulings skeleton, and structured simt-affects +
|
|
105
139
|
simt-supersedes frontmatter (dry-run unless --yes;
|
|
106
140
|
--check lints ADRs for prose-only partial supersessions)
|
|
141
|
+
${CLI} undo restore the last journaled set of writes, but only
|
|
142
|
+
when every file in it is untouched since (dry-run
|
|
143
|
+
unless --yes; a touched file refuses the whole undo
|
|
144
|
+
and nothing is restored)
|
|
107
145
|
${CLI} local doctor probe configured local model endpoints (chat and embed)
|
|
108
146
|
(reads local in ${MANIFEST_FILE}; opens no socket when
|
|
109
147
|
unset; refuses non-loopback URLs without --allow-remote)
|
|
@@ -192,6 +230,9 @@ const FLAGS = `--json structured output (compact; one document,
|
|
|
192
230
|
--task "<text>" the task text used for capability resolution
|
|
193
231
|
--paths <globs> comma-separated path globs used for path resolution
|
|
194
232
|
--root <path> repository root (default: cwd)
|
|
233
|
+
--base <ref> pr-body: the ref the body diffs against
|
|
234
|
+
(default: origin/main, then main; falls back to the
|
|
235
|
+
worktree diff against HEAD when neither exists)
|
|
195
236
|
--out <path> plugin: where the generated package lands (required)
|
|
196
237
|
--surfaces <ids> comma-separated editors to write rule files for;
|
|
197
238
|
"" means AGENTS.md only (default: every surface,
|
|
@@ -202,6 +243,13 @@ const FLAGS = `--json structured output (compact; one document,
|
|
|
202
243
|
--dry-run report what would change, change nothing
|
|
203
244
|
--yes assume yes; required in automation, never prompts
|
|
204
245
|
--check report drift and write nothing (sync, ci)
|
|
246
|
+
--diff print the plan as unified diffs (every writer;
|
|
247
|
+
changes nothing about what the run writes)
|
|
248
|
+
--line status: print the one-line gate summary from the last
|
|
249
|
+
check (the only mode status has)
|
|
250
|
+
--fix doctor: apply the remedies flagged safe (local,
|
|
251
|
+
idempotent, journaled); list the rest (dry-run
|
|
252
|
+
unless --yes)
|
|
205
253
|
--frozen check: the lock must match this projection; an output
|
|
206
254
|
change no input change explains fails the gate
|
|
207
255
|
--explain sync: name each line dropped as already inferable, with
|
|
@@ -238,8 +286,10 @@ const FLAGS = `--json structured output (compact; one document,
|
|
|
238
286
|
--reps <n> bench run: repetitions per task and arm (default: 3)
|
|
239
287
|
--regrade <run> bench run: re-grade a stored run from its archived agent patch
|
|
240
288
|
--compare <a> <b> bench report: compare two arms with deltas and p-values
|
|
241
|
-
--test <cmd> plan done: the test command that closes the row
|
|
289
|
+
--test <cmd> plan done / slice finish: the test command that closes the row
|
|
242
290
|
--state <name> plan done: target state (default: Complete)
|
|
291
|
+
--type <name> slice finish: the Conventional Commits type (default: feat)
|
|
292
|
+
--ttl <seconds> lease: how long a claim lasts (default: 3600)
|
|
243
293
|
--level <name> map: detail level (outline, names, signatures)
|
|
244
294
|
--supersedes <spec> decide: superseded ADR or ruling (ADR-NNN or ADR-NNN:<ruling>[:<kind>]; repeatable)
|
|
245
295
|
--source <spec> pack new: where the pack's bytes come from
|
|
@@ -268,9 +318,12 @@ const PRINCIPLES = `design rules:
|
|
|
268
318
|
bounded output one fact per line, fixed field order, explicit TRUNCATED lines`;
|
|
269
319
|
|
|
270
320
|
export async function help(argv) {
|
|
271
|
-
const
|
|
272
|
-
if (!ok)
|
|
273
|
-
|
|
321
|
+
const parsed = parse(argv);
|
|
322
|
+
if (!parsed.ok) {
|
|
323
|
+
jsonFailMode(rawJsonFlag(argv));
|
|
324
|
+
return fail(parsed.error.message, parsed.error.code);
|
|
325
|
+
}
|
|
326
|
+
const { flags } = parsed.value;
|
|
274
327
|
if (flags.version) {
|
|
275
328
|
emit((await version()).trim());
|
|
276
329
|
return 0;
|
|
@@ -300,10 +353,6 @@ export async function help(argv) {
|
|
|
300
353
|
}
|
|
301
354
|
|
|
302
355
|
async function version() {
|
|
303
|
-
const {
|
|
304
|
-
|
|
305
|
-
const { dirname, join } = await import('node:path');
|
|
306
|
-
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
307
|
-
const pkg = JSON.parse(await readFile(join(root, '..', 'package.json'), 'utf8'));
|
|
308
|
-
return `simt-dev ${pkg.version}\n`;
|
|
356
|
+
const { describeVersion } = await import('../package-version.mjs');
|
|
357
|
+
return `${describeVersion()}\n`;
|
|
309
358
|
}
|
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
|
+
}
|