pumuki 6.3.37 → 6.3.39

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.
@@ -6,9 +6,17 @@ import {
6
6
  } from './resolveGitRefs';
7
7
  import { runPlatformGate } from './runPlatformGate';
8
8
  import { GitService } from './GitService';
9
- import { emitAuditSummaryNotificationFromEvidence } from '../notifications/emitAuditSummaryNotification';
9
+ import {
10
+ evaluateGitAtomicity,
11
+ type GitAtomicityEvaluation,
12
+ type GitAtomicityStage,
13
+ } from './gitAtomicity';
14
+ import {
15
+ emitAuditSummaryNotificationFromEvidence,
16
+ emitGateBlockedNotification,
17
+ } from '../notifications/emitAuditSummaryNotification';
10
18
  import { readFileSync } from 'node:fs';
11
- import { readEvidenceResult } from '../evidence/readEvidence';
19
+ import { readEvidence, readEvidenceResult } from '../evidence/readEvidence';
12
20
  import type { EvidenceReadResult } from '../evidence/readEvidence';
13
21
 
14
22
  const PRE_PUSH_UPSTREAM_REQUIRED_MESSAGE =
@@ -16,6 +24,20 @@ const PRE_PUSH_UPSTREAM_REQUIRED_MESSAGE =
16
24
 
17
25
  const PRE_COMMIT_EVIDENCE_MAX_AGE_SECONDS = 900;
18
26
  const PRE_PUSH_EVIDENCE_MAX_AGE_SECONDS = 1800;
27
+ const DEFAULT_BLOCKED_REMEDIATION = 'Corrige la causa del bloqueo y vuelve a ejecutar el gate.';
28
+
29
+ const BLOCKED_REMEDIATION_BY_CODE: Readonly<Record<string, string>> = {
30
+ EVIDENCE_MISSING: 'Regenera .ai_evidence.json ejecutando una auditoría.',
31
+ EVIDENCE_INVALID: 'Corrige/regenera .ai_evidence.json y vuelve a ejecutar el gate.',
32
+ EVIDENCE_CHAIN_INVALID: 'Regenera evidencia para restaurar la cadena criptográfica.',
33
+ EVIDENCE_STALE: 'Refresca evidencia antes de continuar.',
34
+ EVIDENCE_REPO_ROOT_MISMATCH: 'Regenera evidencia desde este mismo repositorio.',
35
+ EVIDENCE_BRANCH_MISMATCH: 'Regenera evidencia en la rama actual y reintenta.',
36
+ EVIDENCE_RULES_COVERAGE_MISSING: 'Ejecuta auditoría completa para recalcular rules_coverage.',
37
+ EVIDENCE_RULES_COVERAGE_INCOMPLETE: 'Asegura coverage_ratio=1 y unevaluated=0.',
38
+ GITFLOW_PROTECTED_BRANCH: 'Trabaja en feature/* y evita ramas protegidas.',
39
+ PRE_PUSH_UPSTREAM_MISSING: 'Ejecuta: git push --set-upstream origin <branch>',
40
+ };
19
41
 
20
42
  type StageRunnerDependencies = {
21
43
  resolvePolicyForStage: typeof resolvePolicyForStage;
@@ -23,13 +45,28 @@ type StageRunnerDependencies = {
23
45
  resolvePrePushBootstrapBaseRef: typeof resolvePrePushBootstrapBaseRef;
24
46
  resolveCiBaseRef: typeof resolveCiBaseRef;
25
47
  runPlatformGate: typeof runPlatformGate;
48
+ evaluateGitAtomicity: (params: {
49
+ repoRoot: string;
50
+ stage: GitAtomicityStage;
51
+ fromRef?: string;
52
+ toRef?: string;
53
+ }) => GitAtomicityEvaluation;
26
54
  resolveRepoRoot: () => string;
27
55
  readPrePushStdin: () => string;
28
56
  notifyAuditSummaryFromEvidence: (params: {
29
57
  repoRoot: string;
30
58
  stage: 'PRE_COMMIT' | 'PRE_PUSH' | 'CI';
31
59
  }) => void;
60
+ notifyGateBlocked: (params: {
61
+ repoRoot: string;
62
+ stage: 'PRE_COMMIT' | 'PRE_PUSH' | 'CI';
63
+ totalViolations: number;
64
+ causeCode: string;
65
+ causeMessage: string;
66
+ remediation: string;
67
+ }) => void;
32
68
  readEvidenceResult: (repoRoot: string) => EvidenceReadResult;
69
+ readEvidence: typeof readEvidence;
33
70
  now: () => number;
34
71
  writeHookGateSummary: (message: string) => void;
35
72
  isQuietMode: () => boolean;
@@ -41,6 +78,13 @@ const defaultDependencies: StageRunnerDependencies = {
41
78
  resolvePrePushBootstrapBaseRef,
42
79
  resolveCiBaseRef,
43
80
  runPlatformGate,
81
+ evaluateGitAtomicity: (params) =>
82
+ evaluateGitAtomicity({
83
+ repoRoot: params.repoRoot,
84
+ stage: params.stage,
85
+ fromRef: params.fromRef,
86
+ toRef: params.toRef,
87
+ }),
44
88
  resolveRepoRoot: () => new GitService().resolveRepoRoot(),
45
89
  readPrePushStdin: () => {
46
90
  const envInput = process.env.PUMUKI_PRE_PUSH_STDIN;
@@ -62,7 +106,11 @@ const defaultDependencies: StageRunnerDependencies = {
62
106
  stage,
63
107
  });
64
108
  },
109
+ notifyGateBlocked: (params) => {
110
+ emitGateBlockedNotification(params);
111
+ },
65
112
  readEvidenceResult,
113
+ readEvidence,
66
114
  now: () => Date.now(),
67
115
  writeHookGateSummary: (message) => {
68
116
  process.stdout.write(`${message}\n`);
@@ -87,6 +135,32 @@ const notifyAuditSummaryForStage = (
87
135
  });
88
136
  };
89
137
 
138
+ const notifyGateBlockedForStage = (params: {
139
+ dependencies: StageRunnerDependencies;
140
+ stage: 'PRE_COMMIT' | 'PRE_PUSH' | 'CI';
141
+ fallbackCauseCode: string;
142
+ fallbackCauseMessage: string;
143
+ fallbackRemediation: string;
144
+ }): void => {
145
+ const repoRoot = params.dependencies.resolveRepoRoot();
146
+ const evidence = params.dependencies.readEvidence(repoRoot);
147
+ const firstViolation = evidence?.ai_gate.violations[0];
148
+ const causeCode = firstViolation?.code ?? params.fallbackCauseCode;
149
+ const causeMessage = firstViolation?.message ?? params.fallbackCauseMessage;
150
+ const remediation =
151
+ BLOCKED_REMEDIATION_BY_CODE[causeCode]
152
+ ?? params.fallbackRemediation
153
+ ?? DEFAULT_BLOCKED_REMEDIATION;
154
+ params.dependencies.notifyGateBlocked({
155
+ repoRoot,
156
+ stage: params.stage,
157
+ totalViolations: evidence?.ai_gate.violations.length ?? 0,
158
+ causeCode,
159
+ causeMessage,
160
+ remediation,
161
+ });
162
+ };
163
+
90
164
  const ZERO_HASH = /^0+$/;
91
165
 
92
166
  const toEvidenceAgeSeconds = (
@@ -159,10 +233,53 @@ const shouldAllowBootstrapPrePush = (rawInput: string): boolean => {
159
233
  return false;
160
234
  };
161
235
 
236
+ const enforceGitAtomicityGate = (params: {
237
+ dependencies: StageRunnerDependencies;
238
+ repoRoot: string;
239
+ stage: 'PRE_COMMIT' | 'PRE_PUSH' | 'CI';
240
+ fromRef?: string;
241
+ toRef?: string;
242
+ }): boolean => {
243
+ const atomicity = params.dependencies.evaluateGitAtomicity({
244
+ repoRoot: params.repoRoot,
245
+ stage: params.stage,
246
+ fromRef: params.fromRef,
247
+ toRef: params.toRef,
248
+ });
249
+ if (!atomicity.enabled || atomicity.allowed) {
250
+ return false;
251
+ }
252
+ const firstViolation = atomicity.violations[0];
253
+ if (!firstViolation) {
254
+ return false;
255
+ }
256
+ process.stderr.write(`[pumuki][git-atomicity] ${firstViolation.code}: ${firstViolation.message}\n`);
257
+ params.dependencies.notifyGateBlocked({
258
+ repoRoot: params.repoRoot,
259
+ stage: params.stage,
260
+ totalViolations: atomicity.violations.length,
261
+ causeCode: firstViolation.code,
262
+ causeMessage: firstViolation.message,
263
+ remediation: firstViolation.remediation,
264
+ });
265
+ notifyAuditSummaryForStage(params.dependencies, params.stage);
266
+ return true;
267
+ };
268
+
162
269
  export async function runPreCommitStage(
163
270
  dependencies: Partial<StageRunnerDependencies> = {}
164
271
  ): Promise<number> {
165
272
  const activeDependencies = getDependencies(dependencies);
273
+ const repoRoot = activeDependencies.resolveRepoRoot();
274
+ if (
275
+ enforceGitAtomicityGate({
276
+ dependencies: activeDependencies,
277
+ repoRoot,
278
+ stage: 'PRE_COMMIT',
279
+ })
280
+ ) {
281
+ return 1;
282
+ }
166
283
  const resolved = activeDependencies.resolvePolicyForStage('PRE_COMMIT');
167
284
  const exitCode = await activeDependencies.runPlatformGate({
168
285
  policy: resolved.policy,
@@ -177,6 +294,15 @@ export async function runPreCommitStage(
177
294
  policyTrace: resolved.trace,
178
295
  exitCode,
179
296
  });
297
+ if (exitCode !== 0) {
298
+ notifyGateBlockedForStage({
299
+ dependencies: activeDependencies,
300
+ stage: 'PRE_COMMIT',
301
+ fallbackCauseCode: 'PRE_COMMIT_GATE_BLOCKED',
302
+ fallbackCauseMessage: 'Gate blocked pre-commit stage.',
303
+ fallbackRemediation: DEFAULT_BLOCKED_REMEDIATION,
304
+ });
305
+ }
180
306
  notifyAuditSummaryForStage(activeDependencies, 'PRE_COMMIT');
181
307
  return exitCode;
182
308
  }
@@ -185,17 +311,30 @@ export async function runPrePushStage(
185
311
  dependencies: Partial<StageRunnerDependencies> = {}
186
312
  ): Promise<number> {
187
313
  const activeDependencies = getDependencies(dependencies);
314
+ const repoRoot = activeDependencies.resolveRepoRoot();
188
315
  const upstreamRef = activeDependencies.resolveUpstreamRef();
189
316
  if (!upstreamRef) {
190
317
  const prePushInput = activeDependencies.readPrePushStdin();
191
318
  if (shouldAllowBootstrapPrePush(prePushInput)) {
319
+ const bootstrapBaseRef = activeDependencies.resolvePrePushBootstrapBaseRef();
320
+ if (
321
+ enforceGitAtomicityGate({
322
+ dependencies: activeDependencies,
323
+ repoRoot,
324
+ stage: 'PRE_PUSH',
325
+ fromRef: bootstrapBaseRef,
326
+ toRef: 'HEAD',
327
+ })
328
+ ) {
329
+ return 1;
330
+ }
192
331
  const resolved = activeDependencies.resolvePolicyForStage('PRE_PUSH');
193
332
  const exitCode = await activeDependencies.runPlatformGate({
194
333
  policy: resolved.policy,
195
334
  policyTrace: resolved.trace,
196
335
  scope: {
197
336
  kind: 'range',
198
- fromRef: activeDependencies.resolvePrePushBootstrapBaseRef(),
337
+ fromRef: bootstrapBaseRef,
199
338
  toRef: 'HEAD',
200
339
  },
201
340
  });
@@ -209,10 +348,29 @@ export async function runPrePushStage(
209
348
  return exitCode;
210
349
  }
211
350
  process.stderr.write(`${PRE_PUSH_UPSTREAM_REQUIRED_MESSAGE}\n`);
351
+ notifyGateBlockedForStage({
352
+ dependencies: activeDependencies,
353
+ stage: 'PRE_PUSH',
354
+ fallbackCauseCode: 'PRE_PUSH_UPSTREAM_MISSING',
355
+ fallbackCauseMessage: 'Branch has no upstream tracking reference.',
356
+ fallbackRemediation: 'Ejecuta: git push --set-upstream origin <branch>',
357
+ });
212
358
  notifyAuditSummaryForStage(activeDependencies, 'PRE_PUSH');
213
359
  return 1;
214
360
  }
215
361
 
362
+ if (
363
+ enforceGitAtomicityGate({
364
+ dependencies: activeDependencies,
365
+ repoRoot,
366
+ stage: 'PRE_PUSH',
367
+ fromRef: upstreamRef,
368
+ toRef: 'HEAD',
369
+ })
370
+ ) {
371
+ return 1;
372
+ }
373
+
216
374
  const resolved = activeDependencies.resolvePolicyForStage('PRE_PUSH');
217
375
  const exitCode = await activeDependencies.runPlatformGate({
218
376
  policy: resolved.policy,
@@ -229,6 +387,15 @@ export async function runPrePushStage(
229
387
  policyTrace: resolved.trace,
230
388
  exitCode,
231
389
  });
390
+ if (exitCode !== 0) {
391
+ notifyGateBlockedForStage({
392
+ dependencies: activeDependencies,
393
+ stage: 'PRE_PUSH',
394
+ fallbackCauseCode: 'PRE_PUSH_GATE_BLOCKED',
395
+ fallbackCauseMessage: 'Gate blocked pre-push stage.',
396
+ fallbackRemediation: DEFAULT_BLOCKED_REMEDIATION,
397
+ });
398
+ }
232
399
  notifyAuditSummaryForStage(activeDependencies, 'PRE_PUSH');
233
400
  return exitCode;
234
401
  }
@@ -237,16 +404,38 @@ export async function runCiStage(
237
404
  dependencies: Partial<StageRunnerDependencies> = {}
238
405
  ): Promise<number> {
239
406
  const activeDependencies = getDependencies(dependencies);
407
+ const repoRoot = activeDependencies.resolveRepoRoot();
408
+ const ciBaseRef = activeDependencies.resolveCiBaseRef();
409
+ if (
410
+ enforceGitAtomicityGate({
411
+ dependencies: activeDependencies,
412
+ repoRoot,
413
+ stage: 'CI',
414
+ fromRef: ciBaseRef,
415
+ toRef: 'HEAD',
416
+ })
417
+ ) {
418
+ return 1;
419
+ }
240
420
  const resolved = activeDependencies.resolvePolicyForStage('CI');
241
421
  const exitCode = await activeDependencies.runPlatformGate({
242
422
  policy: resolved.policy,
243
423
  policyTrace: resolved.trace,
244
424
  scope: {
245
425
  kind: 'range',
246
- fromRef: activeDependencies.resolveCiBaseRef(),
426
+ fromRef: ciBaseRef,
247
427
  toRef: 'HEAD',
248
428
  },
249
429
  });
430
+ if (exitCode !== 0) {
431
+ notifyGateBlockedForStage({
432
+ dependencies: activeDependencies,
433
+ stage: 'CI',
434
+ fallbackCauseCode: 'CI_GATE_BLOCKED',
435
+ fallbackCauseMessage: 'Gate blocked CI stage.',
436
+ fallbackRemediation: DEFAULT_BLOCKED_REMEDIATION,
437
+ });
438
+ }
250
439
  notifyAuditSummaryForStage(activeDependencies, 'CI');
251
440
  return exitCode;
252
441
  }
@@ -5,16 +5,16 @@
5
5
  "payload": {
6
6
  "hooks": {
7
7
  "pre_write": {
8
- "command": "npx --yes pumuki-pre-write"
8
+ "command": "npx --yes --package pumuki@latest pumuki-pre-write"
9
9
  },
10
10
  "pre_commit": {
11
- "command": "npx --yes pumuki-pre-commit"
11
+ "command": "npx --yes --package pumuki@latest pumuki-pre-commit"
12
12
  },
13
13
  "pre_push": {
14
- "command": "npx --yes pumuki-pre-push"
14
+ "command": "npx --yes --package pumuki@latest pumuki-pre-push"
15
15
  },
16
16
  "ci": {
17
- "command": "npx --yes pumuki-ci"
17
+ "command": "npx --yes --package pumuki@latest pumuki-ci"
18
18
  }
19
19
  },
20
20
  "mcp": {
@@ -35,16 +35,16 @@
35
35
  "pumuki": {
36
36
  "hooks": {
37
37
  "pre_write": {
38
- "command": "npx --yes pumuki-pre-write"
38
+ "command": "npx --yes --package pumuki@latest pumuki-pre-write"
39
39
  },
40
40
  "pre_commit": {
41
- "command": "npx --yes pumuki-pre-commit"
41
+ "command": "npx --yes --package pumuki@latest pumuki-pre-commit"
42
42
  },
43
43
  "pre_push": {
44
- "command": "npx --yes pumuki-pre-push"
44
+ "command": "npx --yes --package pumuki@latest pumuki-pre-push"
45
45
  },
46
46
  "ci": {
47
- "command": "npx --yes pumuki-ci"
47
+ "command": "npx --yes --package pumuki@latest pumuki-ci"
48
48
  }
49
49
  }
50
50
  },
@@ -97,16 +97,16 @@
97
97
  },
98
98
  "pumukiHooks": {
99
99
  "pre_write": {
100
- "command": "npx --yes pumuki-pre-write"
100
+ "command": "npx --yes --package pumuki@latest pumuki-pre-write"
101
101
  },
102
102
  "pre_commit": {
103
- "command": "npx --yes pumuki-pre-commit"
103
+ "command": "npx --yes --package pumuki@latest pumuki-pre-commit"
104
104
  },
105
105
  "pre_push": {
106
- "command": "npx --yes pumuki-pre-push"
106
+ "command": "npx --yes --package pumuki@latest pumuki-pre-push"
107
107
  },
108
108
  "ci": {
109
- "command": "npx --yes pumuki-ci"
109
+ "command": "npx --yes --package pumuki@latest pumuki-ci"
110
110
  }
111
111
  }
112
112
  }
@@ -116,10 +116,10 @@
116
116
  {
117
117
  "path": ".codeium/adapter/hooks.json",
118
118
  "payload": {
119
- "preCommand": "npx --yes pumuki-pre-write",
120
- "postCommand": "npx --yes pumuki-pre-commit",
121
- "pushCommand": "npx --yes pumuki-pre-push",
122
- "ciCommand": "npx --yes pumuki-ci",
119
+ "preCommand": "npx --yes --package pumuki@latest pumuki-pre-write",
120
+ "postCommand": "npx --yes --package pumuki@latest pumuki-pre-commit",
121
+ "pushCommand": "npx --yes --package pumuki@latest pumuki-pre-push",
122
+ "ciCommand": "npx --yes --package pumuki@latest pumuki-ci",
123
123
  "mcpCommand": "npx --yes --package pumuki@latest pumuki-mcp-enterprise-stdio"
124
124
  }
125
125
  },
@@ -169,16 +169,16 @@
169
169
  "payload": {
170
170
  "hooks": {
171
171
  "pre_write": {
172
- "command": "npx --yes pumuki-pre-write"
172
+ "command": "npx --yes --package pumuki@latest pumuki-pre-write"
173
173
  },
174
174
  "pre_commit": {
175
- "command": "npx --yes pumuki-pre-commit"
175
+ "command": "npx --yes --package pumuki@latest pumuki-pre-commit"
176
176
  },
177
177
  "pre_push": {
178
- "command": "npx --yes pumuki-pre-push"
178
+ "command": "npx --yes --package pumuki@latest pumuki-pre-push"
179
179
  },
180
180
  "ci": {
181
- "command": "npx --yes pumuki-ci"
181
+ "command": "npx --yes --package pumuki@latest pumuki-ci"
182
182
  }
183
183
  },
184
184
  "mcp": {
@@ -38,7 +38,10 @@ import {
38
38
  } from '../sdd';
39
39
  import { evaluateAiGate } from '../gate/evaluateAiGate';
40
40
  import { runEnterpriseAiGateCheck } from '../mcp/aiGateCheck';
41
- import { emitAuditSummaryNotificationFromAiGate } from '../notifications/emitAuditSummaryNotification';
41
+ import {
42
+ emitAuditSummaryNotificationFromAiGate,
43
+ emitGateBlockedNotification,
44
+ } from '../notifications/emitAuditSummaryNotification';
42
45
  import {
43
46
  buildPreWriteAutomationTrace,
44
47
  type PreWriteAutomationTrace,
@@ -972,6 +975,9 @@ const printDoctorReport = (
972
975
  ): void => {
973
976
  writeInfo(`[pumuki] repo: ${report.repoRoot}`);
974
977
  writeInfo(`[pumuki] package version: ${report.packageVersion}`);
978
+ writeInfo(
979
+ `[pumuki] hooks path: ${report.hooksDirectory} (${report.hooksDirectoryResolution})`
980
+ );
975
981
  writeInfo(
976
982
  `[pumuki] tracked node_modules paths: ${report.trackedNodeModulesPaths.length}`
977
983
  );
@@ -1055,12 +1061,14 @@ type PreWriteOpenSpecBootstrapTrace = {
1055
1061
 
1056
1062
  type LifecycleCliDependencies = {
1057
1063
  emitAuditSummaryNotificationFromAiGate: typeof emitAuditSummaryNotificationFromAiGate;
1064
+ emitGateBlockedNotification: typeof emitGateBlockedNotification;
1058
1065
  runPlatformGate: typeof runPlatformGate;
1059
1066
  collectRemoteCiDiagnostics: typeof collectRemoteCiDiagnostics;
1060
1067
  };
1061
1068
 
1062
1069
  const defaultLifecycleCliDependencies: LifecycleCliDependencies = {
1063
1070
  emitAuditSummaryNotificationFromAiGate,
1071
+ emitGateBlockedNotification,
1064
1072
  runPlatformGate,
1065
1073
  collectRemoteCiDiagnostics,
1066
1074
  };
@@ -1082,6 +1090,9 @@ const PRE_WRITE_HINTS_BY_CODE: Readonly<Record<string, string>> = {
1082
1090
  MCP_ENTERPRISE_RECEIPT_REPO_ROOT_MISMATCH: 'Genera el recibo MCP en este mismo repositorio.',
1083
1091
  };
1084
1092
 
1093
+ const PRE_WRITE_DEFAULT_REMEDIATION =
1094
+ 'Corrige la causa bloqueante y vuelve a ejecutar: npx --yes --package pumuki@latest pumuki-pre-write';
1095
+
1085
1096
  const PRE_WRITE_OPENSPEC_AUTOREMEDIABLE_CODES = new Set<string>([
1086
1097
  'OPENSPEC_MISSING',
1087
1098
  'OPENSPEC_VERSION_UNSUPPORTED',
@@ -1113,6 +1124,16 @@ const resolvePreWriteNextAction = (params: {
1113
1124
  };
1114
1125
  };
1115
1126
 
1127
+ const resolvePreWriteBlockedRemediation = (params: {
1128
+ causeCode: string;
1129
+ nextAction?: PreWriteValidationEnvelope['next_action'];
1130
+ }): string => {
1131
+ if (params.nextAction?.command) {
1132
+ return params.nextAction.command;
1133
+ }
1134
+ return PRE_WRITE_HINTS_BY_CODE[params.causeCode] ?? PRE_WRITE_DEFAULT_REMEDIATION;
1135
+ };
1136
+
1116
1137
  const wrapPreWritePanelLine = (value: string, width: number): string[] => {
1117
1138
  if (width < 20 || value.length <= width) {
1118
1139
  return [value];
@@ -1404,6 +1425,9 @@ export const runLifecycleCli = async (
1404
1425
  writeInfo(`[pumuki] package version: ${status.packageVersion}`);
1405
1426
  writeInfo(`[pumuki] lifecycle installed: ${status.lifecycleState.installed ?? 'false'}`);
1406
1427
  writeInfo(`[pumuki] lifecycle version: ${status.lifecycleState.version ?? 'unknown'}`);
1428
+ writeInfo(
1429
+ `[pumuki] hooks path: ${status.hooksDirectory} (${status.hooksDirectoryResolution})`
1430
+ );
1407
1431
  writeInfo(
1408
1432
  `[pumuki] hooks: pre-commit=${status.hookStatus['pre-commit'].managedBlockPresent ? 'managed' : 'missing'}, pre-push=${status.hookStatus['pre-push'].managedBlockPresent ? 'managed' : 'missing'}`
1409
1433
  );
@@ -1818,9 +1842,34 @@ export const runLifecycleCli = async (
1818
1842
  });
1819
1843
  }
1820
1844
  if (!result.decision.allowed) {
1845
+ activeDependencies.emitGateBlockedNotification({
1846
+ repoRoot: process.cwd(),
1847
+ stage: result.stage,
1848
+ totalViolations: aiGate?.violations.length ?? 0,
1849
+ causeCode: result.decision.code,
1850
+ causeMessage: result.decision.message,
1851
+ remediation: resolvePreWriteBlockedRemediation({
1852
+ causeCode: result.decision.code,
1853
+ nextAction,
1854
+ }),
1855
+ });
1821
1856
  return 1;
1822
1857
  }
1823
1858
  if (aiGate && !aiGate.allowed) {
1859
+ const firstViolation = aiGate.violations[0];
1860
+ const causeCode = firstViolation?.code ?? 'AI_GATE_BLOCKED';
1861
+ const causeMessage = firstViolation?.message ?? 'AI gate blocked PRE_WRITE stage.';
1862
+ activeDependencies.emitGateBlockedNotification({
1863
+ repoRoot: process.cwd(),
1864
+ stage: result.stage,
1865
+ totalViolations: aiGate.violations.length,
1866
+ causeCode,
1867
+ causeMessage,
1868
+ remediation: resolvePreWriteBlockedRemediation({
1869
+ causeCode,
1870
+ nextAction,
1871
+ }),
1872
+ });
1824
1873
  return 1;
1825
1874
  }
1826
1875
  return 0;
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { readEvidenceResult } from '../evidence/readEvidence';
4
4
  import { resolvePolicyForStage } from '../gate/stagePolicies';
5
- import { getPumukiHooksStatus } from './hookManager';
5
+ import { getPumukiHooksStatus, resolvePumukiHooksDirectory } from './hookManager';
6
6
  import { LifecycleGitService, type ILifecycleGitService } from './gitService';
7
7
  import { getCurrentPumukiVersion } from './packageInfo';
8
8
  import {
@@ -75,6 +75,8 @@ export type LifecycleDoctorReport = {
75
75
  lifecycleState: LifecycleState;
76
76
  trackedNodeModulesPaths: ReadonlyArray<string>;
77
77
  hookStatus: ReturnType<typeof getPumukiHooksStatus>;
78
+ hooksDirectory: string;
79
+ hooksDirectoryResolution: 'git-rev-parse' | 'git-config' | 'default';
78
80
  policyValidation: LifecyclePolicyValidationSnapshot;
79
81
  issues: ReadonlyArray<DoctorIssue>;
80
82
  deep?: DoctorDeepReport;
@@ -83,6 +85,7 @@ export type LifecycleDoctorReport = {
83
85
  const buildDoctorIssues = (params: {
84
86
  trackedNodeModulesPaths: ReadonlyArray<string>;
85
87
  hookStatus: ReturnType<typeof getPumukiHooksStatus>;
88
+ hooksDirectory: string;
86
89
  lifecycleState: LifecycleState;
87
90
  }): ReadonlyArray<DoctorIssue> => {
88
91
  const issues: DoctorIssue[] = [];
@@ -99,10 +102,16 @@ const buildDoctorIssues = (params: {
99
102
  params.lifecycleState.installed === 'true' &&
100
103
  !Object.values(params.hookStatus).every((entry) => entry.managedBlockPresent)
101
104
  ) {
105
+ const totalHooks = Object.keys(params.hookStatus).length;
106
+ const managedHooks = Object.values(params.hookStatus).filter(
107
+ (entry) => entry.managedBlockPresent
108
+ ).length;
102
109
  issues.push({
103
110
  severity: 'warning',
104
111
  message:
105
- 'Lifecycle state says installed=true but one or more managed hook blocks are missing.',
112
+ `Lifecycle state says installed=true but managed hook blocks are incomplete (${managedHooks}/${totalHooks}). ` +
113
+ `Effective hooks path: ${params.hooksDirectory}. ` +
114
+ 'If you use versioned hooks via core.hooksPath, ensure those hooks include the PUMUKI MANAGED block or rerun "pumuki install".',
106
115
  });
107
116
  }
108
117
 
@@ -517,7 +526,7 @@ const buildCompatibilityContract = (params: {
517
526
  overall: overallCompatible ? 'compatible' : 'incompatible',
518
527
  pumuki: {
519
528
  installed: pumukiInstalled,
520
- version: getCurrentPumukiVersion(),
529
+ version: getCurrentPumukiVersion({ repoRoot: params.repoRoot }),
521
530
  },
522
531
  openspec: {
523
532
  required: openSpecRequired,
@@ -635,12 +644,14 @@ export const runLifecycleDoctor = (params?: {
635
644
  const cwd = params?.cwd ?? process.cwd();
636
645
  const repoRoot = git.resolveRepoRoot(cwd);
637
646
  const trackedNodeModulesPaths = git.trackedNodeModulesPaths(repoRoot);
647
+ const hooksDirectory = resolvePumukiHooksDirectory(repoRoot);
638
648
  const hookStatus = getPumukiHooksStatus(repoRoot);
639
649
  const lifecycleState = readLifecycleState(git, repoRoot);
640
650
 
641
651
  const issues = buildDoctorIssues({
642
652
  trackedNodeModulesPaths,
643
653
  hookStatus,
654
+ hooksDirectory: hooksDirectory.path,
644
655
  lifecycleState,
645
656
  });
646
657
  const deep = params?.deep
@@ -654,10 +665,12 @@ export const runLifecycleDoctor = (params?: {
654
665
 
655
666
  return {
656
667
  repoRoot,
657
- packageVersion: getCurrentPumukiVersion(),
668
+ packageVersion: getCurrentPumukiVersion({ repoRoot }),
658
669
  lifecycleState,
659
670
  trackedNodeModulesPaths,
660
671
  hookStatus,
672
+ hooksDirectory: hooksDirectory.path,
673
+ hooksDirectoryResolution: hooksDirectory.source,
661
674
  policyValidation: readLifecyclePolicyValidationSnapshot(repoRoot),
662
675
  issues,
663
676
  deep,
@@ -24,26 +24,52 @@ const managedBlockPattern = new RegExp(
24
24
  'g'
25
25
  );
26
26
 
27
+ const toHookRunner = (params: {
28
+ hook: PumukiManagedHook;
29
+ runner: string;
30
+ }): string => {
31
+ if (params.hook === 'pre-push') {
32
+ return ` PUMUKI_PRE_PUSH_STDIN="$PUMUKI_PRE_PUSH_STDIN" ${params.runner} "$@"`;
33
+ }
34
+ return ` ${params.runner}`;
35
+ };
36
+
27
37
  export const buildPumukiManagedHookBlock = (hook: PumukiManagedHook): string => {
28
38
  const cli = HOOK_COMMANDS[hook];
29
- const prePushCommand =
30
- ' PUMUKI_PRE_PUSH_STDIN="$(cat)"\n' +
31
- ` PUMUKI_PRE_PUSH_STDIN="$PUMUKI_PRE_PUSH_STDIN" npx --yes ${cli} "$@"`;
32
- const hookCommand =
33
- hook === 'pre-push' ? prePushCommand : ` npx --yes ${cli}`;
39
+ const localBinPath = `./node_modules/.bin/${cli}`;
40
+ const localNodeEntry = `./node_modules/pumuki/bin/${cli}.js`;
34
41
 
35
42
  return [
36
43
  PUMUKI_MANAGED_BLOCK_START,
37
- 'if command -v npx >/dev/null 2>&1; then',
38
- hookCommand,
44
+ ...(hook === 'pre-push' ? ['PUMUKI_PRE_PUSH_STDIN="$(cat)"'] : []),
45
+ `if [ -x "${localBinPath}" ]; then`,
46
+ toHookRunner({
47
+ hook,
48
+ runner: localBinPath,
49
+ }),
50
+ `elif [ -f "${localNodeEntry}" ] && command -v node >/dev/null 2>&1; then`,
51
+ toHookRunner({
52
+ hook,
53
+ runner: `node ${localNodeEntry}`,
54
+ }),
55
+ `elif command -v ${cli} >/dev/null 2>&1; then`,
56
+ toHookRunner({
57
+ hook,
58
+ runner: cli,
59
+ }),
60
+ 'elif command -v npx >/dev/null 2>&1; then',
61
+ toHookRunner({
62
+ hook,
63
+ runner: `npx --yes --package pumuki@latest ${cli}`,
64
+ }),
65
+ 'else',
66
+ ` echo "[pumuki] unable to resolve ${cli} runner. Install dependencies or ensure npx is available." >&2`,
67
+ ' exit 1',
68
+ 'fi',
39
69
  ' status=$?',
40
70
  ' if [ "$status" -ne 0 ]; then',
41
71
  ' exit "$status"',
42
72
  ' fi',
43
- 'else',
44
- ' echo "[pumuki] npx was not found in PATH." >&2',
45
- ' exit 1',
46
- 'fi',
47
73
  PUMUKI_MANAGED_BLOCK_END,
48
74
  ].join('\n');
49
75
  };