simt-dev 0.6.3 → 0.7.1
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 +162 -841
- package/README.md +21 -135
- package/agents/simt-agent.md +84 -0
- package/bin/simt.mjs +19 -55
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
- package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
- package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
- package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
- package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
- package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
- package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
- package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
- package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
- package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
- package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
- package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
- package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
- package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
- package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
- package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
- package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
- package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
- package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
- package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
- package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
- package/package.json +13 -3
- package/rules/core.md +9 -2
- package/rules/mdc/slice-commit.mdc +57 -4
- package/rules/mdc/test-gate.mdc +2 -0
- package/rules/stacks/android.md +3 -3
- package/rules/stacks/connectiq.md +2 -2
- package/rules/stacks/dart.md +1 -1
- package/rules/stacks/deno.md +1 -1
- package/rules/stacks/dotnet.md +1 -1
- package/rules/stacks/elixir.md +1 -1
- package/rules/stacks/go.md +1 -1
- package/rules/stacks/jvm.md +3 -2
- package/rules/stacks/node.md +2 -2
- package/rules/stacks/python.md +7 -6
- package/rules/stacks/ruby.md +1 -1
- package/rules/stacks/rust.md +1 -1
- package/rules/stacks/swift.md +1 -1
- package/rules/stacks/typescript.md +2 -2
- package/rules/stacks/uv.md +3 -2
- package/skills/simt-commands/SKILL.md +70 -0
- package/src/advice/advice.mjs +7 -3
- package/src/agenthooks/decide.mjs +141 -9
- package/src/agenthooks/opencode.mjs +72 -0
- package/src/agenthooks/prompt.mjs +10 -3
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/api.mjs +29 -0
- package/src/argv.mjs +37 -3
- package/src/bench/ablate.mjs +31 -14
- package/src/bench/adapters.json +16 -0
- package/src/bench/corpus.mjs +150 -0
- package/src/bench/grade.mjs +23 -49
- package/src/bench/mine.mjs +247 -36
- package/src/bench/probes.mjs +104 -68
- package/src/bench/process.mjs +128 -0
- package/src/bench/report.mjs +260 -84
- package/src/bench/run.mjs +251 -150
- package/src/bench/variants.mjs +112 -40
- package/src/brief/assemble.mjs +44 -3
- package/src/ci/workflow.mjs +46 -14
- package/src/cli.mjs +45 -0
- package/src/commands/agent.mjs +205 -0
- package/src/commands/agenthook.mjs +113 -20
- package/src/commands/bench.mjs +192 -18
- package/src/commands/brief.mjs +5 -4
- package/src/commands/ci.mjs +2 -1
- package/src/commands/compare.mjs +50 -49
- package/src/commands/completions.mjs +2 -6
- package/src/commands/decide.mjs +10 -9
- package/src/commands/distill.mjs +4 -3
- package/src/commands/doctor.mjs +11 -7
- package/src/commands/evidence.mjs +19 -12
- package/src/commands/explain.mjs +0 -1
- package/src/commands/gate.mjs +98 -28
- package/src/commands/graph.mjs +20 -8
- package/src/commands/harness.mjs +3 -2
- package/src/commands/help.mjs +160 -31
- package/src/commands/hooks.mjs +163 -50
- package/src/commands/ignore.mjs +2 -1
- package/src/commands/impact.mjs +8 -9
- package/src/commands/index.mjs +278 -180
- package/src/commands/init.mjs +171 -38
- package/src/commands/lease.mjs +206 -39
- package/src/commands/lessons.mjs +72 -15
- package/src/commands/local.mjs +3 -3
- package/src/commands/map.mjs +47 -27
- package/src/commands/mcp.mjs +0 -1
- package/src/commands/pack.mjs +9 -6
- package/src/commands/plan.mjs +6 -5
- package/src/commands/plugin.mjs +3 -3
- package/src/commands/prbody.mjs +3 -3
- package/src/commands/repoindex.mjs +5 -3
- package/src/commands/review.mjs +2 -2
- package/src/commands/rules.mjs +124 -33
- package/src/commands/shared.mjs +2 -3
- package/src/commands/skills.mjs +82 -45
- package/src/commands/skillscompile.mjs +12 -12
- package/src/commands/skillsinstall.mjs +14 -2
- package/src/commands/slice.mjs +396 -26
- package/src/commands/spec.mjs +151 -0
- package/src/commands/status.mjs +4 -7
- package/src/commands/sync.mjs +50 -33
- package/src/commands/tests.mjs +11 -11
- package/src/commands/tokens.mjs +2 -2
- package/src/commands/undo.mjs +29 -31
- package/src/commands/upgrade.mjs +40 -4
- package/src/commands/why.mjs +19 -12
- package/src/compare/hooksstate.mjs +23 -1
- package/src/compare/learn.mjs +82 -57
- package/src/compare/parallel.mjs +50 -0
- package/src/compare/row.mjs +26 -3
- package/src/completions.mjs +5 -0
- package/src/context.mjs +64 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index-doc.mjs +46 -0
- package/src/decisions/index.mjs +109 -11
- package/src/diff.mjs +1 -1
- package/src/distill/distill.mjs +17 -2
- package/src/distill/engine.mjs +5 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +18 -6
- package/src/graph/load.mjs +42 -20
- package/src/hooks/capability.mjs +17 -4
- package/src/hooks/dispatcher.mjs +513 -36
- package/src/hooks/gates.mjs +0 -43
- package/src/hooks/resolve.mjs +11 -10
- package/src/hooks/shim.mjs +17 -9
- package/src/identity.mjs +96 -15
- package/src/index/extract.mjs +192 -11
- package/src/index/persist.mjs +307 -47
- package/src/index/store.mjs +263 -60
- package/src/index/tests.mjs +6 -35
- package/src/journal.mjs +372 -84
- package/src/kernel/card.mjs +37 -12
- package/src/kernel/checks.mjs +44 -9
- package/src/kernel/variants.mjs +27 -16
- package/src/lease.mjs +97 -15
- package/src/ledger/index.mjs +120 -25
- package/src/local/client.mjs +4 -6
- package/src/locate.mjs +33 -9
- package/src/manifest/schema.mjs +213 -0
- package/src/mcp/resources.mjs +12 -13
- package/src/mcp/server.mjs +151 -38
- package/src/mcp/tools.mjs +90 -16
- package/src/measure/session.mjs +3 -2
- package/src/memory/decide.mjs +98 -43
- package/src/memory/lessons.mjs +168 -33
- package/src/name.mjs +9 -0
- package/src/node-version.mjs +37 -0
- package/src/output/index.mjs +111 -3
- package/src/packs/manage.mjs +17 -3
- package/src/packs.mjs +20 -2
- package/src/plan/index.mjs +31 -8
- package/src/plan/template.mjs +27 -5
- package/src/plugins.mjs +6 -2
- package/src/prbody.mjs +7 -5
- package/src/projection/baselines.mjs +1 -1
- package/src/projection/disclosure.mjs +2 -2
- package/src/projection/loaders.mjs +41 -1
- package/src/projection/lock.mjs +31 -14
- package/src/projection/manifest.mjs +89 -53
- package/src/projection/markers.mjs +43 -3
- package/src/projection/mdc.mjs +34 -7
- package/src/projection/project.mjs +162 -18
- package/src/projection/rules.mjs +58 -6
- package/src/projection/surfaces.mjs +9 -1
- package/src/repo-root.mjs +88 -0
- package/src/resolve/body.mjs +4 -0
- package/src/resolve/fingerprint.mjs +24 -15
- package/src/resolve/local.mjs +58 -0
- package/src/review/critic.mjs +6 -3
- package/src/secrets.mjs +165 -22
- package/src/state.mjs +76 -0
- package/src/status.mjs +9 -4
- package/src/toolwords.mjs +8 -0
- package/src/tsconfig.mjs +39 -99
- package/src/workspace.mjs +55 -10
- package/templates/plan/tracker.json +1 -1
- package/docs/decisions/README.md +0 -591
package/src/commands/hooks.mjs
CHANGED
|
@@ -12,31 +12,32 @@
|
|
|
12
12
|
// DEFAULT location, which core.hooksPath being unset does not reveal, so a
|
|
13
13
|
// foreign hook is found by asking git rather than by reading config.
|
|
14
14
|
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import { commitJournal, journaledRemoveFile, journaledWriteFile } from '../journal.mjs';
|
|
15
|
+
import { mkdir, readFile, readdir, realpath, rm, stat } from 'node:fs/promises';
|
|
16
|
+
import { repoContext } from '../context.mjs';
|
|
17
|
+
import { commitJournal, journaledGitConfig, journaledRemoveFile, journaledWriteFile } from '../journal.mjs';
|
|
18
18
|
import { diffField, emitDiffs, readBefore } from '../diff.mjs';
|
|
19
19
|
import { invalidateCheckCache } from '../status.mjs';
|
|
20
20
|
import { isAbsolute, join, relative } from 'node:path';
|
|
21
|
-
import { promisify } from 'node:util';
|
|
22
21
|
|
|
23
22
|
import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
|
|
24
23
|
import { MANIFEST, readManifest } from '../projection/manifest.mjs';
|
|
25
|
-
import { EVENTS, HOOKS_DIR, HOOKS_PATH_KEY, SHIM_MARKER, fileFor, renderChainedShim, renderEvidenceShim, renderShim } from '../hooks/shim.mjs';
|
|
24
|
+
import { CHAIN_MARKER, EVENTS, HOOKS_DIR, HOOKS_DIR_LEGACY, HOOKS_PATH_KEY, SHIM_MARKER, fileFor, renderChainedShim, renderEvidenceShim, renderShim } from '../hooks/shim.mjs';
|
|
26
25
|
import { GATE_KEY, gateCount, readHooksPath, readHooksState, wantsShim } from '../compare/hooksstate.mjs';
|
|
27
26
|
import { GATES } from '../hooks/gates.mjs';
|
|
28
|
-
import {
|
|
27
|
+
import { gateNames } from '../hooks/gates.mjs';
|
|
29
28
|
import { renderGateReport, runGates } from '../hooks/dispatcher.mjs';
|
|
30
29
|
import { CLI } from '../name.mjs';
|
|
31
30
|
import { MANIFEST_FILE } from '../name.mjs';
|
|
32
31
|
import { packageVersion, hasNpm } from '../package-version.mjs';
|
|
33
|
-
|
|
34
|
-
const exec = promisify(execFile);
|
|
32
|
+
import { commandRoot } from '../repo-root.mjs';
|
|
35
33
|
|
|
36
34
|
export async function hooksCommand(sub, args, flags) {
|
|
37
|
-
const root = flags
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
const root = commandRoot(flags, args);
|
|
36
|
+
// One context for the subcommand: install and status each ask about the same
|
|
37
|
+
// repository, and `doctor --fix` asks alongside them (review 0003 Q67).
|
|
38
|
+
const ctx = repoContext(root);
|
|
39
|
+
if (sub === 'install') return installHooks(root, flags, ctx);
|
|
40
|
+
if (sub === 'status') return hooksStatus(root, flags, ctx);
|
|
40
41
|
if (sub === 'run') return runHook(args, flags);
|
|
41
42
|
return fail(`unknown hooks subcommand ${sub}`, EXIT.USAGE, `run: ${CLI} hooks status`);
|
|
42
43
|
}
|
|
@@ -44,17 +45,23 @@ export async function hooksCommand(sub, args, flags) {
|
|
|
44
45
|
/** The shim execs this; the event name is baked into the shim, so it is arg 0. */
|
|
45
46
|
async function runHook(args, flags) {
|
|
46
47
|
const event = args[0];
|
|
47
|
-
const root = flags
|
|
48
|
+
const root = commandRoot(flags);
|
|
49
|
+
const ctx = repoContext(root);
|
|
48
50
|
if (!EVENTS.includes(event)) {
|
|
49
51
|
return fail(`unknown hook event ${event}`, EXIT.USAGE, `known events: ${EVENTS.join(', ')}`);
|
|
50
52
|
}
|
|
51
|
-
const { manifest, exists, error } = await
|
|
53
|
+
const { manifest, exists, error } = await ctx.manifest();
|
|
52
54
|
if (error) return fail(`${MANIFEST_FILE}: ${error}`, EXIT.CONFIG);
|
|
53
55
|
if (!exists) {
|
|
54
56
|
return fail(`${root} has no ${MANIFEST_FILE}; the hook cannot know its gate list`, EXIT.CONFIG, `run: ${CLI} init --root ${root}`);
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
|
|
59
|
+
// git feeds pre-push its ref list on stdin, and the docs-only skip (ADR-113)
|
|
60
|
+
// needs it. A manual run from a terminal has a TTY and reads empty, which the
|
|
61
|
+
// dispatcher treats as "no push to classify" and answers with the full suite.
|
|
62
|
+
const pushed = event === 'pre-push' ? await readStdin() : undefined;
|
|
63
|
+
|
|
64
|
+
const result = await runGates(event, { root, manifest, pushed });
|
|
58
65
|
if (result.error) return fail(result.error, EXIT.USAGE);
|
|
59
66
|
|
|
60
67
|
if (isJson(flags)) {
|
|
@@ -63,10 +70,33 @@ async function runHook(args, flags) {
|
|
|
63
70
|
}
|
|
64
71
|
|
|
65
72
|
for (const n of result.notes ?? []) note(`note ${n}`);
|
|
66
|
-
emit(renderGateReport(event, manifest.gates[GATE_KEY[event]] ?? [], result).join('\n')
|
|
73
|
+
emit(renderGateReport(event, manifest.gates[GATE_KEY[event]] ?? [], result).join('\n'));
|
|
67
74
|
return result.failed ? EXIT.FAIL : EXIT.OK;
|
|
68
75
|
}
|
|
69
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Read all of stdin, or '' when there is none to read.
|
|
79
|
+
*
|
|
80
|
+
* The pre-push shim execs this command, so git's ref list arrives on the
|
|
81
|
+
* dispatcher's stdin. A TTY (a manual run from a terminal) has no ref list and
|
|
82
|
+
* reads empty - and empty is the fail-closed answer: the dispatcher cannot
|
|
83
|
+
* classify a push it was never told about, so the suite runs.
|
|
84
|
+
*
|
|
85
|
+
* @returns {Promise<string>}
|
|
86
|
+
*/
|
|
87
|
+
function readStdin() {
|
|
88
|
+
return new Promise((resolve) => {
|
|
89
|
+
if (process.stdin.isTTY) {
|
|
90
|
+
resolve('');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const chunks = [];
|
|
94
|
+
process.stdin.on('data', (chunk) => chunks.push(chunk));
|
|
95
|
+
process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
96
|
+
process.stdin.resume();
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
70
100
|
/**
|
|
71
101
|
* The path to write into a chained shim: relative to the repository's top level
|
|
72
102
|
* wherever the hook lives inside it.
|
|
@@ -111,7 +141,7 @@ async function relativeTarget(root, target) {
|
|
|
111
141
|
* caller enforces in its own voice (`takeover`, and the `wanted`/`obsolete`
|
|
112
142
|
* emptiness the command refuses on)
|
|
113
143
|
*/
|
|
114
|
-
export async function planHooksInstall(root, { manifest, exists }) {
|
|
144
|
+
export async function planHooksInstall(root, { manifest, exists }, ctx = null) {
|
|
115
145
|
const declaresGate = (event) => wantsShim(manifest, exists, event);
|
|
116
146
|
const state = await readHooksState(root, { declaresGate });
|
|
117
147
|
const current = state.current;
|
|
@@ -123,6 +153,24 @@ export async function planHooksInstall(root, { manifest, exists }) {
|
|
|
123
153
|
// install where there is no node_modules to pin in.
|
|
124
154
|
const version = packageVersion();
|
|
125
155
|
const npm = await hasNpm(root);
|
|
156
|
+
// Q31: installed shims name the repo's own installer in their remedy.
|
|
157
|
+
// Lockfile first, `packageManager` next, npm default — the one resolver.
|
|
158
|
+
let manager = null;
|
|
159
|
+
if (npm) {
|
|
160
|
+
const { resolveNodeRunner } = await import('../ecosystem/node-runner.mjs');
|
|
161
|
+
let packageManager = null;
|
|
162
|
+
try {
|
|
163
|
+
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
164
|
+
if (typeof pkg.packageManager === 'string') packageManager = pkg.packageManager;
|
|
165
|
+
} catch {
|
|
166
|
+
// Unreadable here leaves the npm default; the manifest path reports it.
|
|
167
|
+
}
|
|
168
|
+
// The dynamic import stays: a hook run must not pull the fingerprint module in
|
|
169
|
+
// just by loading this one. A caller that already holds a context passes it and
|
|
170
|
+
// the walk is shared (review 0003 Q67).
|
|
171
|
+
const detected = ctx ? await ctx.fingerprint() : await (await import('../resolve/fingerprint.mjs')).fingerprint(root);
|
|
172
|
+
manager = resolveNodeRunner({ facts: detected.facts, packageManager }).id;
|
|
173
|
+
}
|
|
126
174
|
|
|
127
175
|
// Only install a shim for an event that declares at least one gate. A shim with
|
|
128
176
|
// an empty gate list enforces nothing, yet merely existing it REPLACES whatever
|
|
@@ -150,8 +198,8 @@ export async function planHooksInstall(root, { manifest, exists }) {
|
|
|
150
198
|
//
|
|
151
199
|
// `foreignHooks` lists every executable file in the directory git reads hooks from -
|
|
152
200
|
// and when that directory is this tool's own, a file a person dropped in there is
|
|
153
|
-
// listed too. Chaining it means writing a shim at `.simt
|
|
154
|
-
// `.simt
|
|
201
|
+
// listed too. Chaining it means writing a shim at `.simt/hooks/<name>` whose TARGET is
|
|
202
|
+
// `.simt/hooks/<name>`: the same path. The shim overwrites the hook it was written to
|
|
155
203
|
// preserve and then runs itself, so the hook stops working and git recurses in a loop
|
|
156
204
|
// nothing reports. Confirmed at the previous release, which printed
|
|
157
205
|
// `UPDATED .simt-hooks/mine (chained: runs .simt-hooks/mine)`.
|
|
@@ -224,7 +272,7 @@ export async function planHooksInstall(root, { manifest, exists }) {
|
|
|
224
272
|
const evidenceDeclared = manifest?.gates?.evidence === 'warn' || manifest?.gates?.evidence === 'block';
|
|
225
273
|
const evidenceFile = fileFor('commit-msg');
|
|
226
274
|
const evidenceExisting = await readIfExists(join(root, evidenceFile));
|
|
227
|
-
const evidenceSource = evidenceDeclared ? renderEvidenceShim({ npm, version }) : null;
|
|
275
|
+
const evidenceSource = evidenceDeclared ? renderEvidenceShim({ npm, manager, version }) : null;
|
|
228
276
|
const evidenceOurs = evidenceExisting !== null && evidenceExisting.includes(SHIM_MARKER);
|
|
229
277
|
if (!evidenceDeclared && evidenceOurs) obsolete.push({ event: 'commit-msg' });
|
|
230
278
|
// A foreign commit-msg would otherwise be chained onto the same path. The chain
|
|
@@ -243,6 +291,7 @@ export async function planHooksInstall(root, { manifest, exists }) {
|
|
|
243
291
|
current,
|
|
244
292
|
wants,
|
|
245
293
|
npm,
|
|
294
|
+
manager,
|
|
246
295
|
version,
|
|
247
296
|
wanted,
|
|
248
297
|
chained,
|
|
@@ -257,9 +306,50 @@ export async function planHooksInstall(root, { manifest, exists }) {
|
|
|
257
306
|
evidenceExisting,
|
|
258
307
|
routed,
|
|
259
308
|
takeover,
|
|
309
|
+
// The legacy directory's removal rides on the same planned write, so the
|
|
310
|
+
// scan is here and the deletion is in `applyHooksInstall` (ADR-122).
|
|
311
|
+
legacy: await legacyHooksScan(root),
|
|
260
312
|
};
|
|
261
313
|
}
|
|
262
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Scan the legacy top-level shim directory (ADR-122). A repository installed
|
|
317
|
+
* before the move into the state directory still carries it, and it is
|
|
318
|
+
* removable only when every entry in it is a regular file that carries a
|
|
319
|
+
* simt-dev marker: the install re-routes git to the new directory, and a
|
|
320
|
+
* file this tool cannot recognise as its own is never deleted on its way.
|
|
321
|
+
*
|
|
322
|
+
* @returns {Promise<{path: string, removable: boolean}|null>} null when the
|
|
323
|
+
* directory is absent or empty; otherwise the directory and whether the
|
|
324
|
+
* install may take it down. `applyHooksInstall` acts on this, so the
|
|
325
|
+
* judgment is made once, in the plan. Module-local: one caller needs no
|
|
326
|
+
* export, and an export is a promise to a caller (deadcode).
|
|
327
|
+
*/
|
|
328
|
+
async function legacyHooksScan(root) {
|
|
329
|
+
const dir = join(root, HOOKS_DIR_LEGACY);
|
|
330
|
+
let entries;
|
|
331
|
+
try {
|
|
332
|
+
entries = await readdir(dir);
|
|
333
|
+
} catch {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
if (entries.length === 0) return null;
|
|
337
|
+
let removable = true;
|
|
338
|
+
for (const name of entries) {
|
|
339
|
+
const st = await stat(join(dir, name));
|
|
340
|
+
if (!st.isFile()) {
|
|
341
|
+
removable = false;
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
const text = await readFile(join(dir, name), 'utf8');
|
|
345
|
+
if (!text.includes(SHIM_MARKER) && !text.includes(CHAIN_MARKER)) {
|
|
346
|
+
removable = false;
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return { path: HOOKS_DIR_LEGACY, removable };
|
|
351
|
+
}
|
|
352
|
+
|
|
263
353
|
/**
|
|
264
354
|
* Write a planned install: shims, chains, removals, and the git routing (plan 0003 R47).
|
|
265
355
|
*
|
|
@@ -268,26 +358,30 @@ export async function planHooksInstall(root, { manifest, exists }) {
|
|
|
268
358
|
* names the run that actually changed something. Reporting stays with the
|
|
269
359
|
* caller for the same reason it does in `planHooksInstall`.
|
|
270
360
|
*
|
|
271
|
-
* @returns {{wrote: string[]}} the shim paths written,
|
|
361
|
+
* @returns {{wrote: string[], removed: string[]}} the shim paths written,
|
|
362
|
+
* removed or refreshed, and the legacy-directory files taken down
|
|
272
363
|
*/
|
|
273
364
|
export async function applyHooksInstall(root, planned, journal) {
|
|
274
|
-
const { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, version, routed, wants, current } =
|
|
365
|
+
const { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, manager, version, routed, wants, current, legacy } =
|
|
275
366
|
planned;
|
|
276
|
-
// Shim and evidence bytes are journaled so `undo` restores them (R49)
|
|
277
|
-
//
|
|
278
|
-
//
|
|
367
|
+
// Shim and evidence bytes are journaled so `undo` restores them (R49), with
|
|
368
|
+
// the executable bit they are written with: a shim the run chmodded is one
|
|
369
|
+
// change, and `undo` must restore the mode it found, not just the bytes.
|
|
370
|
+
// The `core.hooksPath` routing below is journaled as a `git-config` entry
|
|
371
|
+
// for the same reason — unwinding the shims while the config still points
|
|
372
|
+
// at their directory silently stops the repository's own hooks (review 0003
|
|
373
|
+
// Q22).
|
|
279
374
|
const wrote = [];
|
|
375
|
+
const removed = [];
|
|
280
376
|
await mkdir(join(root, HOOKS_DIR), { recursive: true });
|
|
281
377
|
for (const event of wanted) {
|
|
282
378
|
const path = join(root, fileFor(event));
|
|
283
|
-
await journaledWriteFile(root, journal, path, renderShim(event, { npm, version }));
|
|
284
|
-
await chmod(path, 0o755);
|
|
379
|
+
await journaledWriteFile(root, journal, path, renderShim(event, { npm, manager, version }), { mode: 0o755 });
|
|
285
380
|
wrote.push(fileFor(event));
|
|
286
381
|
}
|
|
287
382
|
for (const c of chained) {
|
|
288
383
|
const path = join(root, fileFor(c.event));
|
|
289
|
-
await journaledWriteFile(root, journal, path, renderChainedShim(c.event, c.target));
|
|
290
|
-
await chmod(path, 0o755);
|
|
384
|
+
await journaledWriteFile(root, journal, path, renderChainedShim(c.event, c.target), { mode: 0o755 });
|
|
291
385
|
wrote.push(fileFor(c.event));
|
|
292
386
|
}
|
|
293
387
|
for (const h of obsolete) {
|
|
@@ -297,8 +391,7 @@ export async function applyHooksInstall(root, planned, journal) {
|
|
|
297
391
|
// After the chain loop, so a chained commit-msg cannot replace the claim check.
|
|
298
392
|
if (evidenceSource) {
|
|
299
393
|
const path = join(root, evidenceFile);
|
|
300
|
-
await journaledWriteFile(root, journal, path, evidenceSource);
|
|
301
|
-
await chmod(path, 0o755);
|
|
394
|
+
await journaledWriteFile(root, journal, path, evidenceSource, { mode: 0o755 });
|
|
302
395
|
wrote.push(evidenceFile);
|
|
303
396
|
}
|
|
304
397
|
// `core.hooksPath` tells git to read ONE directory for EVERY event, so leaving it
|
|
@@ -306,11 +399,23 @@ export async function applyHooksInstall(root, planned, journal) {
|
|
|
306
399
|
// `.git/hooks` that predate this tool - from running, silently. Unsetting it is the
|
|
307
400
|
// only way back, and this is the only place that knows the last shim just left.
|
|
308
401
|
if (routed) {
|
|
309
|
-
await
|
|
402
|
+
await journaledGitConfig(root, journal, HOOKS_PATH_KEY, wants);
|
|
310
403
|
} else if (current === wants) {
|
|
311
|
-
await
|
|
404
|
+
await journaledGitConfig(root, journal, HOOKS_PATH_KEY, null);
|
|
405
|
+
}
|
|
406
|
+
// The move into the state directory (ADR-122): the routing now points
|
|
407
|
+
// elsewhere, so a legacy directory left behind is the only place that looks
|
|
408
|
+
// like a hooks directory while holding nothing git reads. The scan in the
|
|
409
|
+
// plan already judged it removable — every file a simt-dev marker — so
|
|
410
|
+
// deletion is journaled per file, and `undo` restores the directory.
|
|
411
|
+
if (legacy?.removable) {
|
|
412
|
+
for (const name of await readdir(join(root, HOOKS_DIR_LEGACY))) {
|
|
413
|
+
await journaledRemoveFile(root, journal, join(root, HOOKS_DIR_LEGACY, name));
|
|
414
|
+
removed.push(`${HOOKS_DIR_LEGACY}/${name}`);
|
|
415
|
+
}
|
|
416
|
+
await rm(join(root, HOOKS_DIR_LEGACY), { recursive: true });
|
|
312
417
|
}
|
|
313
|
-
return { wrote };
|
|
418
|
+
return { wrote, removed };
|
|
314
419
|
}
|
|
315
420
|
|
|
316
421
|
/**
|
|
@@ -320,11 +425,11 @@ export async function applyHooksInstall(root, planned, journal) {
|
|
|
320
425
|
* `doctor --fix` reuses it rather than deriving a second answer.
|
|
321
426
|
*/
|
|
322
427
|
export async function plannedDiffs(root, planned) {
|
|
323
|
-
const { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, version } = planned;
|
|
428
|
+
const { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, manager, version } = planned;
|
|
324
429
|
const pairs = [];
|
|
325
430
|
for (const event of wanted) {
|
|
326
431
|
const path = fileFor(event);
|
|
327
|
-
pairs.push({ path, before: await readBefore(join(root, path)), after: renderShim(event, { npm, version }) });
|
|
432
|
+
pairs.push({ path, before: await readBefore(join(root, path)), after: renderShim(event, { npm, manager, version }) });
|
|
328
433
|
}
|
|
329
434
|
for (const c of chained) {
|
|
330
435
|
const path = fileFor(c.event);
|
|
@@ -340,8 +445,8 @@ export async function plannedDiffs(root, planned) {
|
|
|
340
445
|
return pairs;
|
|
341
446
|
}
|
|
342
447
|
|
|
343
|
-
async function installHooks(root, flags) {
|
|
344
|
-
const { manifest, exists, error } = await
|
|
448
|
+
async function installHooks(root, flags, ctx = repoContext(root)) {
|
|
449
|
+
const { manifest, exists, error } = await ctx.manifest();
|
|
345
450
|
if (error) return fail(`${MANIFEST_FILE}: ${error}`, EXIT.CONFIG);
|
|
346
451
|
if (!exists) {
|
|
347
452
|
return fail(`${root} has no ${MANIFEST_FILE}; gates must be declared before hooks can run them`, EXIT.CONFIG, `run: ${CLI} init --root ${root}`);
|
|
@@ -355,6 +460,7 @@ async function installHooks(root, flags) {
|
|
|
355
460
|
current,
|
|
356
461
|
wants,
|
|
357
462
|
npm,
|
|
463
|
+
manager,
|
|
358
464
|
version,
|
|
359
465
|
wanted,
|
|
360
466
|
chained,
|
|
@@ -366,7 +472,8 @@ async function installHooks(root, flags) {
|
|
|
366
472
|
evidenceExisting,
|
|
367
473
|
routed,
|
|
368
474
|
takeover,
|
|
369
|
-
|
|
475
|
+
legacy,
|
|
476
|
+
} = await planHooksInstall(root, { manifest, exists }, ctx);
|
|
370
477
|
|
|
371
478
|
// Read through `writeMode` like every other writer, and only now: this command
|
|
372
479
|
// used to consult `flags.yes` and `flags.dryRun` directly and write when neither
|
|
@@ -395,7 +502,6 @@ async function installHooks(root, flags) {
|
|
|
395
502
|
' and replace it with a gate that checks nothing.',
|
|
396
503
|
`REMEDY add the gates this repo can actually run to ${MANIFEST_FILE}, then re-run.`,
|
|
397
504
|
].join('\n'),
|
|
398
|
-
{ maxBytes: flags.maxBytes ?? 0 },
|
|
399
505
|
);
|
|
400
506
|
return EXIT.FAIL;
|
|
401
507
|
}
|
|
@@ -403,7 +509,7 @@ async function installHooks(root, flags) {
|
|
|
403
509
|
const plan = [];
|
|
404
510
|
for (const event of wanted) {
|
|
405
511
|
const path = join(root, fileFor(event));
|
|
406
|
-
const source = renderShim(event, { npm, version });
|
|
512
|
+
const source = renderShim(event, { npm, manager, version });
|
|
407
513
|
const existing = await readIfExists(path);
|
|
408
514
|
if (existing === source) {
|
|
409
515
|
plan.push({ file: fileFor(event), action: 'unchanged' });
|
|
@@ -444,11 +550,21 @@ async function installHooks(root, flags) {
|
|
|
444
550
|
note: 'commit-msg checks a verification claim against the ledger',
|
|
445
551
|
});
|
|
446
552
|
}
|
|
553
|
+
if (legacy?.removable) {
|
|
554
|
+
plan.push({
|
|
555
|
+
file: HOOKS_DIR_LEGACY,
|
|
556
|
+
action: 'removed',
|
|
557
|
+
// Named in the plan for the same reason the obsolete shims are: the
|
|
558
|
+
// reader approving this write is about to lose a directory, and the
|
|
559
|
+
// routing line above does not say the old one goes too (ADR-122).
|
|
560
|
+
note: `legacy shim directory; the shims now live in ${HOOKS_DIR}`,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
447
563
|
|
|
448
564
|
// The routing this install leaves behind, which is not always `wants`. A repository
|
|
449
565
|
// that removed its last gate has no shims to route to, and leaving `core.hooksPath`
|
|
450
566
|
// pointed at the directory would stop its own hooks running - the plan has to say so,
|
|
451
|
-
// because a plan that prints "core.hooksPath updated .simt
|
|
567
|
+
// because a plan that prints "core.hooksPath updated .simt/hooks -> .simt/hooks" while
|
|
452
568
|
// the command unsets it is worse than no plan.
|
|
453
569
|
const leaves = routed ? wants : null;
|
|
454
570
|
const pathAction =
|
|
@@ -478,7 +594,6 @@ async function installHooks(root, flags) {
|
|
|
478
594
|
' Taking over replaces that routing. Inspect the existing hooks first,',
|
|
479
595
|
' then re-run with --yes to accept, or set core.hooksPath yourself.',
|
|
480
596
|
].join('\n'),
|
|
481
|
-
{ maxBytes: flags.maxBytes ?? 0 },
|
|
482
597
|
);
|
|
483
598
|
return EXIT.FAIL;
|
|
484
599
|
}
|
|
@@ -487,7 +602,7 @@ async function installHooks(root, flags) {
|
|
|
487
602
|
// true in preview and in apply alike (R50).
|
|
488
603
|
const diffs =
|
|
489
604
|
flags.diff === true
|
|
490
|
-
? await plannedDiffs(root, { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, version })
|
|
605
|
+
? await plannedDiffs(root, { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, manager, version })
|
|
491
606
|
: [];
|
|
492
607
|
|
|
493
608
|
if (!mode.apply) {
|
|
@@ -523,7 +638,6 @@ async function installHooks(root, flags) {
|
|
|
523
638
|
: `PLAN nothing written; apply with: ${CLI} hooks install --root ${root} --yes`,
|
|
524
639
|
manifest,
|
|
525
640
|
),
|
|
526
|
-
{ maxBytes: flags.maxBytes ?? 0 },
|
|
527
641
|
);
|
|
528
642
|
// After the report: the report says what, the diff shows it (R50).
|
|
529
643
|
emitDiffs(diffs, flags);
|
|
@@ -535,7 +649,7 @@ async function installHooks(root, flags) {
|
|
|
535
649
|
const journal = [];
|
|
536
650
|
await applyHooksInstall(
|
|
537
651
|
root,
|
|
538
|
-
{ wanted, chained, obsolete, evidenceSource, evidenceFile, npm, version, routed, wants, current },
|
|
652
|
+
{ wanted, chained, obsolete, evidenceSource, evidenceFile, npm, manager, version, routed, wants, current, legacy },
|
|
539
653
|
journal,
|
|
540
654
|
);
|
|
541
655
|
await commitJournal(root, 'hooks install', journal);
|
|
@@ -612,7 +726,6 @@ async function installHooks(root, flags) {
|
|
|
612
726
|
routed ? 'WROTE shims and set core.hooksPath' : 'WROTE no shims remain; core.hooksPath unset',
|
|
613
727
|
manifest,
|
|
614
728
|
),
|
|
615
|
-
{ maxBytes: flags.maxBytes ?? 0 },
|
|
616
729
|
);
|
|
617
730
|
// After the report: the report says what, the diff shows it (R50).
|
|
618
731
|
emitDiffs(diffs, flags);
|
|
@@ -641,8 +754,8 @@ function describeGates(manifest, key) {
|
|
|
641
754
|
}).join(', ');
|
|
642
755
|
}
|
|
643
756
|
|
|
644
|
-
async function hooksStatus(root, flags) {
|
|
645
|
-
const { manifest, exists, error } = await
|
|
757
|
+
async function hooksStatus(root, flags, ctx = repoContext(root)) {
|
|
758
|
+
const { manifest, exists, error } = await ctx.manifest();
|
|
646
759
|
if (error) return fail(`${MANIFEST_FILE}: ${error}`, EXIT.CONFIG);
|
|
647
760
|
|
|
648
761
|
const state = await readHooksState(root, { declaresGate: (e) => wantsShim(manifest, exists, e) });
|
|
@@ -727,14 +840,14 @@ async function hooksStatus(root, flags) {
|
|
|
727
840
|
const unknown = [...(manifest?.gates?.preCommit ?? []), ...(manifest?.gates?.prePush ?? [])].filter((n) => !GATES[n]);
|
|
728
841
|
if (unknown.length > 0) {
|
|
729
842
|
lines.push('', `unknown gates (the hook will fail): ${unknown.join(', ')}`);
|
|
730
|
-
lines.push(`known gates: ${
|
|
843
|
+
lines.push(`known gates: ${gateNames().join(', ')}`);
|
|
731
844
|
}
|
|
732
845
|
}
|
|
733
846
|
if (problems.length > 0) {
|
|
734
847
|
lines.push('', 'problems:');
|
|
735
848
|
for (const p of problems) lines.push(` ${p}`);
|
|
736
849
|
}
|
|
737
|
-
emit(lines.join('\n')
|
|
850
|
+
emit(lines.join('\n'));
|
|
738
851
|
return code;
|
|
739
852
|
}
|
|
740
853
|
|
package/src/commands/ignore.mjs
CHANGED
|
@@ -14,9 +14,10 @@ import { CLI } from '../name.mjs';
|
|
|
14
14
|
import { commitJournal, journaledWriteFile } from '../journal.mjs';
|
|
15
15
|
import { diffField, emitDiffs, readBefore } from '../diff.mjs';
|
|
16
16
|
import { CLAUDE_SETTINGS, IGNORE_FILE, parseIgnoreList, planIgnore } from '../ignore.mjs';
|
|
17
|
+
import { commandRoot } from '../repo-root.mjs';
|
|
17
18
|
|
|
18
19
|
export async function ignoreCommand(args, flags) {
|
|
19
|
-
const root = flags
|
|
20
|
+
const root = commandRoot(flags);
|
|
20
21
|
|
|
21
22
|
let source;
|
|
22
23
|
try {
|
package/src/commands/impact.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
9
9
|
import { basename, join, resolve } from 'node:path';
|
|
10
10
|
import { promisify } from 'node:util';
|
|
11
11
|
|
|
12
|
-
import { EXIT,
|
|
12
|
+
import { EXIT, fail, present } from '../output/index.mjs';
|
|
13
13
|
import { CLI } from '../name.mjs';
|
|
14
14
|
import { discover } from '../compare/discover.mjs';
|
|
15
15
|
import { extract } from '../index/extract.mjs';
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
import { decisionHits, scopeHits, sortHits, sourceRoots } from '../index/govern.mjs';
|
|
26
26
|
import { decisionRecords, ruleScopes } from './why.mjs';
|
|
27
27
|
import { resolveWorkspace } from '../workspace.mjs';
|
|
28
|
+
import { commandRoot } from '../repo-root.mjs';
|
|
28
29
|
|
|
29
30
|
const exec = promisify(execFile);
|
|
30
31
|
const TEXT_EXT = new Set(['js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'json', 'yaml', 'yml', 'md', 'toml', 'xml', 'py', 'go', 'kt', 'java', 'rs']);
|
|
@@ -35,7 +36,7 @@ export async function impactCommand(args, flags, commands = []) {
|
|
|
35
36
|
if (args.length > 1) return fail(`unknown impact argument ${args[1]}`, EXIT.USAGE, `run: ${CLI} impact <file|symbol>`);
|
|
36
37
|
const target = args[0];
|
|
37
38
|
if (!target) return fail(`a file or a symbol is required`, EXIT.USAGE, `run: ${CLI} impact <file|symbol>`);
|
|
38
|
-
const root = flags
|
|
39
|
+
const root = commandRoot(flags);
|
|
39
40
|
const listed = await tracked(root);
|
|
40
41
|
if (listed.error) return listed.error;
|
|
41
42
|
const files = new Set(listed.files);
|
|
@@ -63,11 +64,7 @@ export async function impactCommand(args, flags, commands = []) {
|
|
|
63
64
|
consumers: consumers.references,
|
|
64
65
|
truncated,
|
|
65
66
|
};
|
|
66
|
-
|
|
67
|
-
emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
|
|
68
|
-
return EXIT.OK;
|
|
69
|
-
}
|
|
70
|
-
emit(render(payload), { maxBytes: flags.maxBytes ?? 0 });
|
|
67
|
+
present(payload, () => render(payload), flags);
|
|
71
68
|
return EXIT.OK;
|
|
72
69
|
}
|
|
73
70
|
|
|
@@ -191,8 +188,10 @@ function textExt(file) {
|
|
|
191
188
|
|
|
192
189
|
async function tracked(root) {
|
|
193
190
|
try {
|
|
194
|
-
|
|
195
|
-
|
|
191
|
+
// `-z`: without it git C-quotes a non-ASCII path and the reader's real
|
|
192
|
+
// filename never matches it (review 0003 Q39).
|
|
193
|
+
const { stdout } = await exec('git', ['ls-files', '-z'], { cwd: root, maxBuffer: 16 << 20 });
|
|
194
|
+
return { files: stdout.split('\0').filter(Boolean) };
|
|
196
195
|
} catch (error) {
|
|
197
196
|
return {
|
|
198
197
|
error: fail(
|