@sabaiway/agent-workflow-kit 14.33.0 → 14.34.0

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.
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ // attribution.mjs — the writer of the advisor's `attribution` item: when the project's AGENTS.md block
3
+ // reads off, it sets Claude Code's `attribution` setting in the project .claude/settings.json so the
4
+ // harness adds no Co-Authored-By trailer, PR text or session link. Preview by default; --apply writes.
5
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
6
+ import { join, resolve } from 'node:path';
7
+ import { CLAUDE_DIR, EXPECTED_WORKFLOW_VERSION, SETTINGS_FILE, preflightVelocityProfile } from './velocity-profile.mjs';
8
+ import { formatJson } from './gate-hook.mjs';
9
+ import { isDirectRun } from './direct-run.mjs';
10
+
11
+ export const ATTRIBUTION_KEY = 'attribution';
12
+ export const LEGACY_ATTRIBUTION_KEY = 'includeCoAuthoredBy';
13
+ // The one form the writer writes: `false` would make a CLI older than v2.1.281 skip the whole file.
14
+ export const ATTRIBUTION_HIDDEN = Object.freeze({ commit: '', pr: '', sessionUrl: false });
15
+ export const ATTRIBUTION_MALFORMED = 'ATTRIBUTION_MALFORMED';
16
+ export const ATTRIBUTION_STAMP = 'ATTRIBUTION_STAMP';
17
+ export const ATTRIBUTION_SHARED_NOTICE = `notice: ${SETTINGS_FILE} is the project-scope settings file — in a visible deployment it is committed and shared with everyone on this project, as the AGENTS.md block is`;
18
+
19
+ const EXIT_OK = 0;
20
+ const EXIT_REFUSED = 1;
21
+ const EXIT_USAGE = 2;
22
+ const USAGE = 'usage: attribution [--cwd <root>] [--apply]';
23
+ const CHOICE_LINE = /^> \*\*Agent attribution: ([^*\s]+)\*\*/mu;
24
+ const CHOICES = ['off', 'on'];
25
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
26
+ const hasOwn = (object, key) => isObject(object) && Object.hasOwn(object, key);
27
+ const refusal = (code, message) => Object.assign(new Error(`${code}: ${message}`), { code, exitCode: EXIT_REFUSED });
28
+ const usageError = (message) => Object.assign(new Error(message), { exitCode: EXIT_USAGE });
29
+
30
+ // off, on, or null for an absent file, an absent line or another value; any other read failure throws.
31
+ export const readAttributionChoice = (root, deps = {}) => {
32
+ let text;
33
+ try {
34
+ text = String((deps.readFile ?? readFileSync)(join(root, 'AGENTS.md'), 'utf8'));
35
+ } catch (error) {
36
+ if (error?.code === 'ENOENT') return null;
37
+ throw error;
38
+ }
39
+ const value = text.match(CHOICE_LINE)?.[1];
40
+ return CHOICES.includes(value) ? value : null;
41
+ };
42
+
43
+ // Per key, the local file's value while it carries the key, else the project's; an object is taken whole.
44
+ export const effectiveAttribution = (project, local) => Object.fromEntries([ATTRIBUTION_KEY, LEGACY_ATTRIBUTION_KEY]
45
+ .map((key) => [key, hasOwn(local, key) ? local[key] : project?.[key]])
46
+ .filter(([, value]) => value !== undefined));
47
+
48
+ export const attributionHidesText = (effective) => {
49
+ const value = effective?.[ATTRIBUTION_KEY];
50
+ if (value === false) return true;
51
+ if (isObject(value) && value.commit === '' && value.pr === '') return true;
52
+ const setsText = hasOwn(value, 'commit') || hasOwn(value, 'pr');
53
+ return !setsText && effective?.[LEGACY_ATTRIBUTION_KEY] === false;
54
+ };
55
+
56
+ // The wrong-typed key of one settings file, or null: attribution is an object or a boolean, the legacy key a boolean.
57
+ export const wrongTypedKey = (data) => {
58
+ if (hasOwn(data, ATTRIBUTION_KEY) && !isObject(data[ATTRIBUTION_KEY]) && typeof data[ATTRIBUTION_KEY] !== 'boolean') {
59
+ return `${ATTRIBUTION_KEY} must be an object or a boolean`;
60
+ }
61
+ if (hasOwn(data, LEGACY_ATTRIBUTION_KEY) && typeof data[LEGACY_ATTRIBUTION_KEY] !== 'boolean') return `${LEGACY_ATTRIBUTION_KEY} must be a boolean`;
62
+ return null;
63
+ };
64
+
65
+ export const writeAttribution = ({ cwd, dryRun = true } = {}, deps = {}) => {
66
+ const root = resolve(cwd ?? deps.cwd ?? process.cwd());
67
+ const preflight = preflightVelocityProfile({ cwd: root }, deps);
68
+ const before = preflight.projectSettings.data ?? null;
69
+ const malformed = wrongTypedKey(before);
70
+ if (malformed) throw refusal(ATTRIBUTION_MALFORMED, `${SETTINGS_FILE}: ${malformed} — nothing written`);
71
+ const planned = !attributionHidesText(effectiveAttribution(before, preflight.localSettings.data));
72
+ const after = planned ? { ...before, [ATTRIBUTION_KEY]: { ...ATTRIBUTION_HIDDEN } } : before;
73
+ const result = { root, planned, wrote: false, before, after, claudeDirAbsent: preflight.claudeDirAbsent };
74
+ if (dryRun || !planned) return result;
75
+ if (!preflight.stampOk) {
76
+ throw refusal(ATTRIBUTION_STAMP, `not a deployed agent-workflow project at lineage ${EXPECTED_WORKFLOW_VERSION} (found ${preflight.stamp ?? 'none'}) — run /agent-workflow-kit upgrade first`);
77
+ }
78
+ if (preflight.claudeDirAbsent) (deps.mkdir ?? mkdirSync)(join(root, CLAUDE_DIR), { recursive: true });
79
+ (deps.writeFile ?? writeFileSync)(join(root, SETTINGS_FILE), formatJson(after, preflight.projectSettings.eol ?? '\n'), 'utf8');
80
+ return { ...result, wrote: true };
81
+ };
82
+
83
+ export const formatResult = (result, dryRun) => {
84
+ if (!result.planned) return ['nothing to change: the settings already hide the trailer and the PR text'];
85
+ const object = JSON.stringify(ATTRIBUTION_HIDDEN);
86
+ return dryRun ? [`would set ${ATTRIBUTION_KEY} (${SETTINGS_FILE}): ${object}`, ATTRIBUTION_SHARED_NOTICE]
87
+ : [`set ${ATTRIBUTION_KEY} (${SETTINGS_FILE}): ${object}`];
88
+ };
89
+
90
+ export const parseArgs = (argv) => {
91
+ if (argv.length === 1 && ['--help', '-h'].includes(argv[0])) return { help: true };
92
+ const args = { help: false, apply: false, cwd: undefined };
93
+ for (let index = 0; index < argv.length; index += 1) {
94
+ const flag = argv[index];
95
+ if (flag === '--apply') args.apply = true;
96
+ else if (flag === '--cwd') {
97
+ const value = argv[index + 1];
98
+ if (value === undefined || value.startsWith('-')) throw usageError('--cwd takes a value');
99
+ args.cwd = value;
100
+ index += 1;
101
+ } else throw usageError(`unknown argument: ${flag}`);
102
+ }
103
+ return args;
104
+ };
105
+
106
+ export const main = (argv = process.argv.slice(2), deps = {}) => {
107
+ const log = deps.log ?? console.log;
108
+ const errlog = deps.errlog ?? console.error;
109
+ let args;
110
+ try {
111
+ args = parseArgs(argv);
112
+ if (args.help) {
113
+ log(USAGE);
114
+ return EXIT_OK;
115
+ }
116
+ args.root = resolve(deps.cwd ?? process.cwd(), args.cwd ?? '.');
117
+ if (!statSync(args.root, { throwIfNoEntry: false })?.isDirectory()) throw usageError(`--cwd is not a directory: ${args.root}`);
118
+ } catch (error) {
119
+ errlog(error.message);
120
+ errlog(USAGE);
121
+ return EXIT_USAGE;
122
+ }
123
+ try {
124
+ const result = writeAttribution({ cwd: args.root, dryRun: !args.apply }, deps);
125
+ for (const line of formatResult(result, !args.apply)) log(line);
126
+ return EXIT_OK;
127
+ } catch (error) {
128
+ errlog(error?.message ?? String(error));
129
+ return EXIT_REFUSED;
130
+ }
131
+ };
132
+
133
+ if (isDirectRun(import.meta.url)) process.exitCode = main(process.argv.slice(2));
@@ -135,6 +135,18 @@ export const executorTemplate = (posture, deps = {}) => {
135
135
  return { name: EXECUTOR_VEHICLE, content: deriveExecutorBody(bundled.content, posture), rederive: true };
136
136
  };
137
137
 
138
+ // The executor ensure's cell 6: a regular placed executor whose bytes differ from the body derived from
139
+ // the settings; the placed file's read failure throws.
140
+ export const executorBodyDiffers = (projectDir, deps = {}) => {
141
+ const fs = readFsDeps(deps);
142
+ const abs = join(projectDir, EXECUTOR_VEHICLE_REL);
143
+ const stat = lstatNoFollow(abs, fs);
144
+ if (stat === null || stat.isSymbolicLink() || !stat.isFile()) return false;
145
+ const { posture } = readExecutorPosture(projectDir, deps);
146
+ if (posture === null) return false;
147
+ return fs.readFile(abs, UTF8) !== executorTemplate(posture, deps).content;
148
+ };
149
+
138
150
  const READ_ONLY_TOOLS = new Set(['Read', 'Grep', 'Glob']);
139
151
 
140
152
  // The YAML subset a vehicle's frontmatter is read with: a bare scalar, a single- or double-quoted
@@ -41,9 +41,7 @@
41
41
  // boundary). Read-only; dependency-free; Node >= 22. No side effects on import.
42
42
 
43
43
  import { readFileSync, lstatSync } from 'node:fs';
44
- import { resolve } from 'node:path';
45
44
  import { fileURLToPath } from 'node:url';
46
- import { spawnSync } from 'node:child_process';
47
45
  import { createHash } from 'node:crypto';
48
46
  import { isDirectRun } from './direct-run.mjs';
49
47
  import { computeTreeFingerprint, buildState, decideCheck, quoteReportName, shellQuoteArg } from './review-state.mjs';
@@ -54,25 +52,14 @@ import {
54
52
  import { resolveLcovPath } from './coverage-check.mjs';
55
53
  import { GATES_REL, loadDeclaration } from './run-gates.mjs';
56
54
  import { computeFlowDecision } from './flow-check.mjs';
57
- import { GIT_MAX_BUFFER, resolveGitLocation } from './git-env.mjs';
55
+ import { resolveGitLocation } from './git-env.mjs';
58
56
  import { escapeForDisplay } from './repo-lex.mjs';
59
57
 
60
58
  const usageFail = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { exitCode: 2 });
61
59
  const sha = (text) => createHash('sha256').update(text).digest('hex');
62
60
 
63
- const gitLine = (args, cwd) => {
64
- const r = spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
65
- if (r.error || r.status !== 0) return null;
66
- return r.stdout.toString('utf8').replace(/\r?\n$/, '');
67
- };
68
-
69
- // resolveGitHooksPath(projectDir) → the ABSOLUTE hooks dir git itself reports (a linked worktree
70
- // answers with ITS OWN hooks path — never a hardcoded `.git/hooks`), or null outside a git tree.
71
- // The one home consumers (the recommendations guard-install probe) read instead of re-deriving it.
72
- export const resolveGitHooksPath = (projectDir) => {
73
- const line = gitLine(['rev-parse', '--git-path', 'hooks'], projectDir);
74
- return line == null ? null : resolve(projectDir, line);
75
- };
61
+ // The hooks-path question and the pre-commit reader live in git-hooks.mjs, their writer's home.
62
+ export { resolveGitHooksPath, readPreCommitState, canonicalGuardLineRe, INSTALLER_MARKER_RE } from './git-hooks.mjs';
76
63
 
77
64
  // How many offending paths the index-lag refusal names before it states a remainder count: enough
78
65
  // to act on, bounded so a wide lag cannot bury a pre-commit hook's output.
@@ -5,7 +5,7 @@
5
5
  // gates docs/ai/gates.json seed-if-missing (an existing declaration is authored content)
6
6
  // autonomy docs/ai/autonomy.json seed-if-missing (same)
7
7
  // vehicles docs/ai/vehicles.json seed-if-missing (same)
8
- // executor .claude/agents/executor.md re-derived from docs/ai/vehicles.json when its bytes differ; absent stays absent
8
+ // executor .claude/agents/executor.md judged against docs/ai/vehicles.json, never written: a differing body is pending
9
9
  // scripts scripts/<ADR enforcement> seed-if-missing, ADR-layout detect FIRST
10
10
  // specs scripts/<spec layer> reader + checker pairs seeded when absent, refreshed only on
11
11
  // + docs/ai/specs/index.md a shipped prior body, an edited one replaced only on the
@@ -80,16 +80,16 @@ Usage:
80
80
  --help, -h this help
81
81
 
82
82
  Every SEED is CREATE-ONLY: an existing file is preserved byte-for-byte, never clobbered and never
83
- refreshed in place. Four ops refresh instead: the orchestration onboarding note, only while it still
83
+ refreshed in place. Three ops refresh instead: the orchestration onboarding note, only while it still
84
84
  matches a canonical the kit shipped (your own wording is preserved verbatim); the spec-layer reader
85
85
  and checker pairs, only while a file's bytes are a body a release shipped (an edited file is
86
86
  preserved, the writes that depend on it — the spec store root included — are withheld behind it,
87
- and ONE printed replace line replaces every edited file only on the user's yes); the placed executor
88
- vehicle (.claude/agents/executor.md), re-derived from docs/ai/vehicles.json
89
- whenever its bytes differ from the derived body (a hand edit is replaced, never preserved; an absent
90
- file is never placed here); and the navigator index — a GENERATED artifact, regenerated
87
+ and ONE printed replace line replaces every edited file only on the user's yes); and the navigator index — a GENERATED artifact, regenerated
91
88
  whenever it is missing or stale; where a module the project's hook loads is not the bundled one,
92
89
  the project's own checker judges the navigator and a write it rejects is put back byte for byte.
90
+ The executor ensure writes nothing: a placed .claude/agents/executor.md whose bytes differ from the
91
+ body derived from docs/ai/vehicles.json is pending — the advisor's executor-vehicle.differing item
92
+ changes it on your yes; an absent file is never placed here.
93
93
  The enforcement-script ensure detects an older ADR-store layout FIRST and instructs the opt-in
94
94
  migration instead of seeding.
95
95
 
@@ -1,9 +1,9 @@
1
1
  // ensure-executor.mjs — the EIGHTH upgrade ensure, fifth in the fixed order right after `vehicles`:
2
- // the placed executor vehicle (.claude/agents/executor.md) re-derived from docs/ai/vehicles.json.
3
- // The agents writer PLACES the vehicle on consent; this ensure never does — it only keeps a placed
4
- // body equal to the one derived from the settings, and a hand edit is replaced, never preserved.
2
+ // the placed executor vehicle (.claude/agents/executor.md) judged against the body derived from
3
+ // docs/ai/vehicles.json. It never writes: the agents writer PLACES the vehicle on consent, and a placed
4
+ // body that differs is reported `pending` — the advisor's executor-vehicle.differing item re-derives it.
5
5
  //
6
- // The state table, in the order the cells are proven (the write is the LAST step):
6
+ // The state table, in the order the cells are proven:
7
7
  // | cell | .claude, .claude/agents | placed path | docs/ai/vehicles.json | answer | read before answering |
8
8
  // |------|-------------------------|-------------|-----------------------|--------|-----------------------|
9
9
  // | 0 | a symlink or not a directory | any | any | failed / wrong-node-kind | nothing |
@@ -12,30 +12,27 @@
12
12
  // | 3 | directories | regular file | unreadable (malformed, read or lstat error) | failed / vehicle-settings-unreadable | the settings only |
13
13
  // | 4 | directories | regular file | present or absent | failed / bundle-unreadable when the bundled template cannot be read | settings, bundle |
14
14
  // | 5 | directories | regular file, bytes equal to the derived body | present or absent | already-current | settings, bundle, placed file |
15
- // | 6 | directories | regular file, bytes differ | present or absent | dry-run would-re-derive; apply re-derived, or failed / write-refused | settings, bundle, placed file |
15
+ // | 6 | directories | regular file, bytes differ (executorBodyDiffers) | present or absent | pending, on the dry run and the run alike | settings, bundle, placed file |
16
16
  // | 7 | an lstat of `.claude`, `.claude/agents` or the placed path throwing other than ENOENT, or the read of the placed file throwing | | | NOT caught: the error propagates to the CLI's catch-all (`unexpected-error`) | |
17
17
  //
18
18
  // Dependency-free, Node >= 22. Every fs primitive is injectable (deps.*). No side effects on import.
19
19
 
20
- import { readFileSync, lstatSync } from 'node:fs';
20
+ import { lstatSync } from 'node:fs';
21
21
  import { join } from 'node:path';
22
- import { writeContainedFileAtomic } from './atomic-write.mjs';
23
- import { composeFailure, composeOutcome, probeSeedTarget, tmpNote } from './ensure-ops.mjs';
24
- import { assertDirSafe, AGENTS_DIR, CLAUDE_DIR, CHEAP_AGENTS_SYMLINK, EXECUTOR_VEHICLE_REL, executorTemplate, readExecutorPosture } from './cheap-agents-read.mjs';
22
+ import { composeFailure, composeOutcome, probeSeedTarget } from './ensure-ops.mjs';
23
+ import { assertDirSafe, AGENTS_DIR, CLAUDE_DIR, CHEAP_AGENTS_SYMLINK, EXECUTOR_VEHICLE_REL, executorBodyDiffers, executorTemplate, readExecutorPosture } from './cheap-agents-read.mjs';
25
24
  import { VEHICLES_REL } from './vehicle-settings.mjs';
26
25
 
27
26
  export const EXECUTOR_OP = 'executor';
28
27
 
29
28
  const BUNDLE_DIR = ['references', 'agents'];
30
- const ENCODING = 'utf8';
31
29
 
32
30
  const ok = (token, lines) => composeOutcome(EXECUTOR_OP, token, lines, false);
33
31
  const loud = (cause, ...lines) => composeFailure(EXECUTOR_OP, cause, ...lines);
34
32
  const causeOf = (err) => String((err && err.message) || err);
35
33
 
36
- export const ensureExecutor = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
34
+ export const ensureExecutor = ({ cwd, kitRoot, deps = {} }) => {
37
35
  const lstat = deps.lstat ?? lstatSync;
38
- const read = deps.readFile ?? readFileSync;
39
36
 
40
37
  try {
41
38
  assertDirSafe(join(cwd, CLAUDE_DIR), CLAUDE_DIR, { lstat });
@@ -59,29 +56,15 @@ export const ensureExecutor = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
59
56
  return loud('vehicle-settings-unreadable', `${EXECUTOR_VEHICLE_REL}: ${reason} — nothing written; fix ${VEHICLES_REL} by hand, then re-run`);
60
57
  }
61
58
 
62
- let body;
59
+ const bundleDeps = { ...deps, bundleDir: join(kitRoot, ...BUNDLE_DIR) };
63
60
  try {
64
- body = executorTemplate(posture, { ...deps, bundleDir: join(kitRoot, ...BUNDLE_DIR) }).content;
61
+ executorTemplate(posture, bundleDeps);
65
62
  } catch (err) {
66
63
  return loud('bundle-unreadable', `${EXECUTOR_VEHICLE_REL}: the bundled executor template could not be read, so nothing was written — reinstall the kit. ${causeOf(err)}`);
67
64
  }
68
65
 
69
- const existing = read(target, ENCODING);
70
- if (existing === body) {
66
+ if (!executorBodyDiffers(cwd, bundleDeps)) {
71
67
  return ok('already-current', [`${EXECUTOR_VEHICLE_REL}: the body derived from ${VEHICLES_REL} (model=${posture.model}, effort=${posture.effort}, source ${posture.source}) — nothing written`]);
72
68
  }
73
- if (dryRun) {
74
- return ok('would-re-derive', [`${EXECUTOR_VEHICLE_REL}: differs from the body derived from ${VEHICLES_REL} — would be rewritten (a hand edit is replaced, never preserved)`]);
75
- }
76
-
77
- let result;
78
- try {
79
- result = writeContainedFileAtomic(cwd, target, body, deps, { label: EXECUTOR_VEHICLE_REL });
80
- } catch (err) {
81
- return loud('write-refused', `${EXECUTOR_VEHICLE_REL}: ${causeOf(err)}`);
82
- }
83
- return ok('re-derived', [
84
- `${EXECUTOR_VEHICLE_REL}: rewritten to the body derived from ${VEHICLES_REL} (model=${posture.model}, effort=${posture.effort}) — a hand edit was replaced; set the model in ${VEHICLES_REL}`,
85
- ...tmpNote(EXECUTOR_VEHICLE_REL, result.tmpLeftBehind),
86
- ]);
69
+ return ok('pending', [`${EXECUTOR_VEHICLE_REL}: differs from the body derived from ${VEHICLES_REL} — nothing written; the advisor's executor-vehicle.differing item re-derives it on your yes`]);
87
70
  };
@@ -18,9 +18,8 @@ export const ENSURE_OPS = Object.freeze(['orchestration', 'gates', 'autonomy', '
18
18
  // ensure's pair refresh (reader or checker) — a deployed script on a body a release shipped,
19
19
  // rewritten to the bundled one; a custom body is rewritten only by the replace run the user's yes
20
20
  // starts (--replace-custom), never by a plain run.
21
- // `re-derived` is the executor ensure rewriting a placed executor whose bytes differ from the body derived from docs/ai/vehicles.json.
22
- export const WRITE_TOKENS = Object.freeze(['seeded', 'note-refreshed', 'refreshed', 'regenerated', 're-derived']);
23
- export const DRY_RUN_TOKENS = Object.freeze(['would-seed', 'would-refresh-note', 'would-refresh', 'would-regenerate', 'would-re-derive']);
21
+ export const WRITE_TOKENS = Object.freeze(['seeded', 'note-refreshed', 'refreshed', 'regenerated']);
22
+ export const DRY_RUN_TOKENS = Object.freeze(['would-seed', 'would-refresh-note', 'would-refresh', 'would-regenerate']);
24
23
 
25
24
  // The CLOSED outcome vocabulary. Closed at RUNTIME, not by convention: composing an outcome with a
26
25
  // token outside this list throws, so an op cannot quietly invent a word the mode doc has never heard
@@ -33,6 +32,7 @@ export const ENSURE_TOKENS = Object.freeze([
33
32
  'already-current',
34
33
  'already-present',
35
34
  'not-placed',
35
+ 'pending',
36
36
  'customized-preserved',
37
37
  'malformed-preserved',
38
38
  'skipped-no-node-evidence',
@@ -80,7 +80,7 @@ export const RELAYED_ENSURE_TOKENS = Object.freeze([
80
80
  'note-refreshed',
81
81
  'refreshed',
82
82
  'regenerated',
83
- 're-derived',
83
+ 'pending',
84
84
  'already-current',
85
85
  'customized-preserved',
86
86
  'malformed-preserved',
@@ -2,7 +2,8 @@
2
2
  // git-hooks.mjs — the thin writer of the two git-hook items (`enforcement`, `commit-guard`): it copies
3
3
  // the bundled scripts create-only and spawns the project's own scripts/install-git-hooks.mjs, which
4
4
  // decides the hook at the write. The leaf decides nothing about the hook: the danger check binds the
5
- // copies and the installer bytes this dry run answers, never git's hooks dir.
5
+ // copies and the installer bytes this dry run answers, never git's hooks dir. It is also the home of the
6
+ // one pre-commit reader, which the commit-guard apply runs after the installer to verify the arm.
6
7
  import { lstatSync, readFileSync, readdirSync, rmdirSync, statSync, unlinkSync } from 'node:fs';
7
8
  import { createHash } from 'node:crypto';
8
9
  import { spawnSync } from 'node:child_process';
@@ -11,6 +12,7 @@ import { fileURLToPath } from 'node:url';
11
12
  import { writeProjectFileCreateOnly } from './atomic-write.mjs';
12
13
  import { NODE_EVIDENCE_SCRIPTS } from './node-evidence.mjs';
13
14
  import { shellQuoteArg } from './repo-lex.mjs';
15
+ import { GIT_MAX_BUFFER } from './git-env.mjs';
14
16
  import { isDirectRun } from './direct-run.mjs';
15
17
 
16
18
  const TOOLS = dirname(fileURLToPath(import.meta.url));
@@ -39,6 +41,34 @@ const nodeAt = (path, lstat) => {
39
41
  const isPlainDir = (node) => node !== null && node.isDirectory() && !node.isSymbolicLink();
40
42
  const isFile = (node) => node !== null && node.isFile() && !node.isSymbolicLink();
41
43
 
44
+ // The absolute hooks dir git itself reports (a linked worktree's own, core.hooksPath), or null outside a git tree.
45
+ export const resolveGitHooksPath = (projectDir, env = process.env) => {
46
+ const result = spawnSync('git', ['rev-parse', '--git-path', 'hooks'], { cwd: projectDir, env, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
47
+ if (result.error || result.status !== 0) return null;
48
+ return resolve(projectDir, result.stdout.toString('utf8').replace(/\r?\n$/, ''));
49
+ };
50
+
51
+ export const INSTALLER_MARKER_RE = /:install-git-hooks\.mjs$/m;
52
+ const escapeRe = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
53
+ // Armed means the exact line the installer writes on the kit's own guard path; a commented or lookalike line is not.
54
+ export const canonicalGuardLineRe = (toolsDir = TOOLS) => new RegExp(`^node "${escapeRe(join(toolsDir, 'commit-guard.mjs'))}" --check$`, 'm');
55
+
56
+ // The one reader of git's pre-commit: whose hook it is, and whether the canonical guard line arms it.
57
+ export const readPreCommitState = (root, deps = {}) => {
58
+ const hooksPath = (deps.gitHooksPath ?? resolveGitHooksPath)(root, deps.env);
59
+ if (hooksPath === null) return { state: 'no-hooks-dir', armed: false };
60
+ const path = join(hooksPath, 'pre-commit');
61
+ let hook;
62
+ try {
63
+ (deps.lstat ?? lstatSync)(path);
64
+ hook = String((deps.readFile ?? readFileSync)(path, 'utf8'));
65
+ } catch (error) {
66
+ if (error?.code === 'ENOENT') return { state: 'absent', armed: false };
67
+ return { state: 'unreadable', code: error?.code ?? String(error?.message ?? error), path, armed: false };
68
+ }
69
+ return { state: INSTALLER_MARKER_RE.test(hook) ? 'managed' : 'unmanaged', armed: canonicalGuardLineRe(deps.toolsDir).test(hook) };
70
+ };
71
+
42
72
  // The dry run: what the apply copies and the installer it runs, with the bytes of each.
43
73
  export const planGitHooks = ({ root, variant, toolsDir = TOOLS, lstat = lstatSync, readFile = readFileSync }) => {
44
74
  const scripts = join(root, SCRIPTS_REL);
@@ -115,7 +145,7 @@ const rollBack = (plan, created, io) => {
115
145
 
116
146
  export const applyGitHooks = (plan, io = {}) => {
117
147
  const resolved = { lstat: lstatSync, unlink: unlinkSync, rmdir: rmdirSync, readdir: readdirSync, env: process.env,
118
- log: console.log, error: console.error, writeFile: writeProjectFileCreateOnly,
148
+ log: console.log, error: console.error, writeFile: writeProjectFileCreateOnly, toolsDir: TOOLS,
119
149
  spawn: (argv, options) => spawnSync(argv[0], argv.slice(1), options), ...io };
120
150
  const created = [];
121
151
  const leftovers = [];
@@ -145,7 +175,15 @@ export const applyGitHooks = (plan, io = {}) => {
145
175
  const child = resolved.spawn(plan.runs.argv, { cwd: plan.root, env: resolved.env, stdio: 'inherit' });
146
176
  if (child.status !== 0) return fail([`installer exited ${child.status ?? child.signal ?? child.error?.code}`]);
147
177
  for (const path of leftovers) resolved.error(`not removed: ${path}`);
148
- return EXIT_OK;
178
+ if (plan.variant !== 'commit-guard') return EXIT_OK;
179
+ // An installer older than the arm ignores --commit-guard and still exits 0, so the arm is read back under its env.
180
+ const hooksDir = resolveGitHooksPath(plan.root, resolved.env);
181
+ const hook = readPreCommitState(plan.root, { env: resolved.env, toolsDir: resolved.toolsDir, gitHooksPath: () => hooksDir });
182
+ if (hook.state === 'no-hooks-dir' || hook.armed) return EXIT_OK;
183
+ const path = join(hooksDir, 'pre-commit');
184
+ resolved.error(hook.state === 'unreadable' ? `${path} is unreadable (${hook.code}) after the installer exited 0`
185
+ : `${path} carries no commit-guard line after the installer exited 0 — an installer older than the arm ignores --commit-guard; refreshing ${INSTALLER_REL} from the kit's references/scripts/ copy is your own step`);
186
+ return EXIT_INCOMPLETE;
149
187
  };
150
188
 
151
189
  export const parseArgs = (argv) => {