pumuki 6.3.26 → 6.3.28

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.
Files changed (76) hide show
  1. package/README.md +3 -1
  2. package/bin/pumuki-mcp-enterprise-stdio.js +5 -0
  3. package/bin/pumuki-mcp-evidence-stdio.js +5 -0
  4. package/core/gate/conditionMatches.ts +1 -21
  5. package/core/gate/evaluateGate.js +5 -0
  6. package/core/gate/evaluateRules.js +5 -0
  7. package/core/gate/evaluateRules.ts +1 -24
  8. package/core/gate/scopeMatcher.ts +84 -0
  9. package/docs/EXECUTION_BOARD.md +749 -376
  10. package/docs/MCP_SERVERS.md +41 -2
  11. package/docs/README.md +6 -2
  12. package/docs/REFRACTOR_PROGRESS.md +374 -6
  13. package/docs/validation/README.md +11 -1
  14. package/docs/validation/p9-ruralgo-bug-registry.md +607 -0
  15. package/docs/validation/p9-ruralgo-fork-validation-tracking.md +904 -0
  16. package/docs/validation/real-repo-manual-e2e-ruralgo-fork.md +372 -0
  17. package/integrations/config/skillsCompliance.ts +212 -0
  18. package/integrations/evidence/integrity.ts +352 -0
  19. package/integrations/evidence/rulesCoverage.ts +94 -0
  20. package/integrations/evidence/schema.test.ts +16 -0
  21. package/integrations/evidence/schema.ts +41 -0
  22. package/integrations/evidence/writeEvidence.test.ts +68 -0
  23. package/integrations/evidence/writeEvidence.ts +23 -2
  24. package/integrations/gate/evaluateAiGate.ts +382 -15
  25. package/integrations/gate/stagePolicies.ts +70 -15
  26. package/integrations/gate/waivers.ts +209 -0
  27. package/integrations/git/findingTraceability.ts +3 -23
  28. package/integrations/git/index.js +5 -0
  29. package/integrations/git/runCliCommand.ts +16 -0
  30. package/integrations/git/runPlatformGate.ts +53 -1
  31. package/integrations/git/runPlatformGateEvaluation.ts +13 -0
  32. package/integrations/git/stageRunners.ts +168 -5
  33. package/integrations/lifecycle/adapter.templates.json +72 -5
  34. package/integrations/lifecycle/adapter.ts +78 -4
  35. package/integrations/lifecycle/cli.ts +384 -14
  36. package/integrations/lifecycle/doctor.ts +534 -0
  37. package/integrations/lifecycle/hookBlock.ts +2 -1
  38. package/integrations/lifecycle/index.js +5 -0
  39. package/integrations/lifecycle/install.ts +115 -3
  40. package/integrations/lifecycle/openSpecBootstrap.ts +68 -8
  41. package/integrations/lifecycle/preWriteAutomation.ts +142 -0
  42. package/integrations/mcp/aiGateCheck.ts +6 -0
  43. package/integrations/mcp/aiGateReceipt.ts +188 -0
  44. package/integrations/mcp/enterpriseServer.ts +14 -1
  45. package/integrations/mcp/enterpriseStdioServer.cli.ts +315 -0
  46. package/integrations/mcp/evidenceStdioServer.cli.ts +342 -0
  47. package/integrations/mcp/index.js +5 -0
  48. package/integrations/sdd/index.js +5 -0
  49. package/integrations/sdd/index.ts +2 -0
  50. package/integrations/sdd/policy.ts +191 -2
  51. package/integrations/sdd/sessionStore.ts +139 -19
  52. package/integrations/sdd/syncDocs.ts +180 -0
  53. package/integrations/sdd/types.ts +4 -1
  54. package/integrations/telemetry/structuredTelemetry.ts +197 -0
  55. package/package.json +27 -8
  56. package/scripts/build-p9-validation-manifests.ts +53 -0
  57. package/scripts/check-p9-ruralgo-baseline-clean.ts +200 -0
  58. package/scripts/check-p9-ruralgo-baseline-versioned.ts +198 -0
  59. package/scripts/check-p9-ruralgo-branch-ready.ts +215 -0
  60. package/scripts/check-p9-ruralgo-install-health.ts +288 -0
  61. package/scripts/check-p9-ruralgo-runtime-ready.ts +188 -0
  62. package/scripts/check-package-manifest.ts +49 -0
  63. package/scripts/check-tracking-single-active.sh +40 -0
  64. package/scripts/framework-menu-consumer-preflight-lib.ts +31 -0
  65. package/scripts/framework-menu-consumer-runtime-lib.ts +3 -3
  66. package/scripts/framework-menu-legacy-audit-lib.ts +35 -7
  67. package/scripts/framework-menu-matrix-evidence-lib.ts +6 -2
  68. package/scripts/manage-library.sh +1 -1
  69. package/scripts/p9-ruralgo-baseline-clean-lib.ts +117 -0
  70. package/scripts/p9-ruralgo-baseline-versioned-lib.ts +119 -0
  71. package/scripts/p9-ruralgo-branch-ready-lib.ts +128 -0
  72. package/scripts/p9-ruralgo-install-health-lib.ts +121 -0
  73. package/scripts/p9-ruralgo-runtime-ready-lib.ts +149 -0
  74. package/scripts/p9-validation-manifests-lib.ts +366 -0
  75. package/scripts/package-manifest-lib.ts +9 -0
  76. package/skills.lock.json +1 -1
@@ -0,0 +1,209 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { isAbsolute, resolve } from 'node:path';
3
+ import { z } from 'zod';
4
+ import type { AiGateStage, AiGateViolation } from './evaluateAiGate';
5
+
6
+ const DEFAULT_AI_GATE_WAIVER_PATH = '.pumuki/waivers/ai-gate.json';
7
+
8
+ const stageSchema = z.enum(['PRE_WRITE', 'PRE_COMMIT', 'PRE_PUSH', 'CI']);
9
+
10
+ const waiverEntrySchema = z.object({
11
+ id: z.string().min(1),
12
+ code: z.string().min(1),
13
+ owner: z.string().min(1),
14
+ reason: z.string().min(1),
15
+ created_at: z.string().datetime({ offset: true }),
16
+ expires_at: z.string().datetime({ offset: true }),
17
+ stage: z.union([stageSchema, z.array(stageSchema).min(1)]).optional(),
18
+ branch: z.string().min(1).optional(),
19
+ });
20
+
21
+ const waiverFileSchema = z.object({
22
+ version: z.literal('1'),
23
+ waivers: z.array(waiverEntrySchema),
24
+ });
25
+
26
+ type AiGateWaiverEntry = z.infer<typeof waiverEntrySchema>;
27
+
28
+ export type AppliedAiGateWaiver = {
29
+ id: string;
30
+ code: string;
31
+ owner: string;
32
+ reason: string;
33
+ expires_at: string;
34
+ ttl_seconds: number;
35
+ branch?: string;
36
+ };
37
+
38
+ export type AiGateWaiverApplyResult = {
39
+ path: string;
40
+ status: 'none' | 'applied' | 'invalid';
41
+ invalid_reason: string | null;
42
+ violations: ReadonlyArray<AiGateViolation>;
43
+ applied: ReadonlyArray<AppliedAiGateWaiver>;
44
+ };
45
+
46
+ const parseDate = (value: string): Date | null => {
47
+ const parsed = Date.parse(value);
48
+ if (!Number.isFinite(parsed)) {
49
+ return null;
50
+ }
51
+ return new Date(parsed);
52
+ };
53
+
54
+ const toTtlSeconds = (expiresAt: Date, now: Date): number =>
55
+ Math.max(0, Math.floor((expiresAt.getTime() - now.getTime()) / 1000));
56
+
57
+ const matchesStage = (
58
+ waiver: AiGateWaiverEntry,
59
+ stage: AiGateStage
60
+ ): boolean => {
61
+ if (!waiver.stage) {
62
+ return true;
63
+ }
64
+ if (Array.isArray(waiver.stage)) {
65
+ return waiver.stage.includes(stage);
66
+ }
67
+ return waiver.stage === stage;
68
+ };
69
+
70
+ const matchesBranch = (
71
+ waiver: AiGateWaiverEntry,
72
+ branch: string | null
73
+ ): boolean => {
74
+ if (!waiver.branch) {
75
+ return true;
76
+ }
77
+ if (!branch) {
78
+ return false;
79
+ }
80
+ return waiver.branch === branch;
81
+ };
82
+
83
+ const matchesViolationCode = (
84
+ waiver: AiGateWaiverEntry,
85
+ violationCode: string
86
+ ): boolean =>
87
+ waiver.code === '*' || waiver.code === violationCode;
88
+
89
+ export const resolveAiGateWaiverPath = (repoRoot: string): string => {
90
+ const candidate = process.env.PUMUKI_AI_GATE_WAIVER_PATH?.trim();
91
+ if (!candidate) {
92
+ return resolve(repoRoot, DEFAULT_AI_GATE_WAIVER_PATH);
93
+ }
94
+ if (isAbsolute(candidate)) {
95
+ return candidate;
96
+ }
97
+ return resolve(repoRoot, candidate);
98
+ };
99
+
100
+ const toAppliedWaiver = (
101
+ waiver: AiGateWaiverEntry,
102
+ now: Date
103
+ ): AppliedAiGateWaiver | null => {
104
+ const expiresAt = parseDate(waiver.expires_at);
105
+ if (!expiresAt) {
106
+ return null;
107
+ }
108
+ const ttlSeconds = toTtlSeconds(expiresAt, now);
109
+ if (ttlSeconds <= 0) {
110
+ return null;
111
+ }
112
+ return {
113
+ id: waiver.id,
114
+ code: waiver.code,
115
+ owner: waiver.owner,
116
+ reason: waiver.reason,
117
+ expires_at: waiver.expires_at,
118
+ ttl_seconds: ttlSeconds,
119
+ branch: waiver.branch,
120
+ };
121
+ };
122
+
123
+ export const applyAiGateWaivers = (params: {
124
+ repoRoot: string;
125
+ stage: AiGateStage;
126
+ branch: string | null;
127
+ violations: ReadonlyArray<AiGateViolation>;
128
+ now?: Date;
129
+ }): AiGateWaiverApplyResult => {
130
+ const path = resolveAiGateWaiverPath(params.repoRoot);
131
+ if (!existsSync(path)) {
132
+ return {
133
+ path,
134
+ status: 'none',
135
+ invalid_reason: null,
136
+ violations: params.violations,
137
+ applied: [],
138
+ };
139
+ }
140
+
141
+ let parsed: unknown;
142
+ try {
143
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
144
+ } catch (error) {
145
+ return {
146
+ path,
147
+ status: 'invalid',
148
+ invalid_reason: error instanceof Error ? error.message : String(error),
149
+ violations: params.violations,
150
+ applied: [],
151
+ };
152
+ }
153
+
154
+ const validation = waiverFileSchema.safeParse(parsed);
155
+ if (!validation.success) {
156
+ return {
157
+ path,
158
+ status: 'invalid',
159
+ invalid_reason: validation.error.issues[0]?.message ?? 'invalid_schema',
160
+ violations: params.violations,
161
+ applied: [],
162
+ };
163
+ }
164
+
165
+ const now = params.now ?? new Date();
166
+ const activeWaivers = validation.data.waivers
167
+ .filter((waiver) => matchesStage(waiver, params.stage))
168
+ .filter((waiver) => matchesBranch(waiver, params.branch))
169
+ .map((waiver) => ({
170
+ raw: waiver,
171
+ normalized: toAppliedWaiver(waiver, now),
172
+ }))
173
+ .filter((entry): entry is { raw: AiGateWaiverEntry; normalized: AppliedAiGateWaiver } =>
174
+ entry.normalized !== null
175
+ );
176
+
177
+ if (activeWaivers.length === 0) {
178
+ return {
179
+ path,
180
+ status: 'none',
181
+ invalid_reason: null,
182
+ violations: params.violations,
183
+ applied: [],
184
+ };
185
+ }
186
+
187
+ const remaining: AiGateViolation[] = [];
188
+ const applied = new Map<string, AppliedAiGateWaiver>();
189
+ for (const violation of params.violations) {
190
+ const match = activeWaivers.find((entry) =>
191
+ matchesViolationCode(entry.raw, violation.code)
192
+ );
193
+ if (!match) {
194
+ remaining.push(violation);
195
+ continue;
196
+ }
197
+ applied.set(match.normalized.id, match.normalized);
198
+ }
199
+
200
+ return {
201
+ path,
202
+ status: applied.size > 0 ? 'applied' : 'none',
203
+ invalid_reason: null,
204
+ violations: remaining,
205
+ applied: [...applied.values()].sort((left, right) =>
206
+ left.id.localeCompare(right.id)
207
+ ),
208
+ };
209
+ };
@@ -1,9 +1,12 @@
1
1
  import type { Fact } from '../../core/facts/Fact';
2
2
  import type { Finding } from '../../core/gate/Finding';
3
+ import { matchesScope } from '../../core/gate/scopeMatcher';
3
4
  import type { Condition } from '../../core/rules/Condition';
4
5
  import type { RuleDefinition } from '../../core/rules/RuleDefinition';
5
6
  import type { RuleSet } from '../../core/rules/RuleSet';
6
7
 
8
+ type RuleScope = RuleDefinition['scope'];
9
+
7
10
  type Trace = {
8
11
  matched: boolean;
9
12
  filePath?: string;
@@ -12,29 +15,6 @@ type Trace = {
12
15
  source?: string;
13
16
  };
14
17
 
15
- type RuleScope = RuleDefinition['scope'];
16
-
17
- const extractPrefix = (pattern: string): string => {
18
- const wildcardIndex = pattern.indexOf('*');
19
- return wildcardIndex === -1 ? pattern : pattern.slice(0, wildcardIndex);
20
- };
21
-
22
- const matchesAnyPrefix = (path: string, patterns: ReadonlyArray<string>): boolean => {
23
- return patterns.some((pattern) => path.startsWith(extractPrefix(pattern)));
24
- };
25
-
26
- const matchesScope = (path: string, scope?: RuleScope): boolean => {
27
- const include = scope?.include;
28
- const exclude = scope?.exclude;
29
- if (exclude && exclude.length > 0 && matchesAnyPrefix(path, exclude)) {
30
- return false;
31
- }
32
- if (!include || include.length === 0) {
33
- return true;
34
- }
35
- return matchesAnyPrefix(path, include);
36
- };
37
-
38
18
  const normalizePath = (path: string): string => path.replace(/\\/g, '/');
39
19
 
40
20
  const sortedUniqueLines = (lines: ReadonlyArray<number>): readonly number[] | undefined => {
@@ -0,0 +1,5 @@
1
+ 'use strict';
2
+
3
+ require('tsx/cjs');
4
+
5
+ module.exports = require('./index.ts');
@@ -1,4 +1,10 @@
1
1
  export const runCliCommand = (runner: () => Promise<number>): void => {
2
+ const hasQuietFlag = process.argv.slice(2).includes('--quiet');
3
+ const previousQuietMode = process.env.PUMUKI_HOOK_SUMMARY_QUIET;
4
+ if (hasQuietFlag) {
5
+ process.env.PUMUKI_HOOK_SUMMARY_QUIET = '1';
6
+ }
7
+
2
8
  void runner()
3
9
  .then((code) => {
4
10
  process.exitCode = code;
@@ -7,5 +13,15 @@ export const runCliCommand = (runner: () => Promise<number>): void => {
7
13
  const message = error instanceof Error ? error.message : 'Unexpected CLI runner error.';
8
14
  process.stderr.write(`${message}\n`);
9
15
  process.exitCode = 1;
16
+ })
17
+ .finally(() => {
18
+ if (!hasQuietFlag) {
19
+ return;
20
+ }
21
+ if (typeof previousQuietMode === 'undefined') {
22
+ delete process.env.PUMUKI_HOOK_SUMMARY_QUIET;
23
+ } else {
24
+ process.env.PUMUKI_HOOK_SUMMARY_QUIET = previousQuietMode;
25
+ }
10
26
  });
11
27
  };
@@ -188,6 +188,41 @@ const toSkillsUnsupportedAutoRulesBlockingFinding = (params: {
188
188
  };
189
189
  };
190
190
 
191
+ const toSkillsComplianceBlockingFinding = (params: {
192
+ stage: 'PRE_COMMIT' | 'PRE_PUSH' | 'CI';
193
+ skillsCompliance?: {
194
+ missing_rule_ids: string[];
195
+ by_file: Array<{
196
+ file_path: string;
197
+ missing_rule_ids: string[];
198
+ }>;
199
+ };
200
+ }): Finding | undefined => {
201
+ const missingRuleIds = params.skillsCompliance?.missing_rule_ids ?? [];
202
+ if (missingRuleIds.length === 0) {
203
+ return undefined;
204
+ }
205
+
206
+ const filesWithMissing = (params.skillsCompliance?.by_file ?? [])
207
+ .filter((entry) => entry.missing_rule_ids.length > 0)
208
+ .map((entry) => entry.file_path)
209
+ .sort((left, right) => left.localeCompare(right))
210
+ .join(', ');
211
+
212
+ return {
213
+ ruleId: 'governance.skills.compliance.incomplete',
214
+ severity: 'ERROR',
215
+ code: 'SKILLS_COMPLIANCE_INCOMPLETE_HIGH',
216
+ message:
217
+ `Skills compliance incomplete at ${params.stage}: ` +
218
+ `missing_rule_ids=[${[...missingRuleIds].sort().join(', ')}]. ` +
219
+ `affected_files=[${filesWithMissing}].`,
220
+ filePath: '.ai_evidence.json',
221
+ matchedBy: 'SkillsComplianceGuard',
222
+ source: 'skills-compliance',
223
+ };
224
+ };
225
+
191
226
  export async function runPlatformGate(params: {
192
227
  policy: GatePolicy;
193
228
  auditMode?: 'gate' | 'engine';
@@ -307,6 +342,15 @@ export async function runPlatformGate(params: {
307
342
  unsupportedAutoRuleIds: skillsRuleSet.unsupportedAutoRuleIds ?? [],
308
343
  })
309
344
  : undefined;
345
+ const skillsComplianceBlockingFinding =
346
+ params.policy.stage === 'PRE_COMMIT' ||
347
+ params.policy.stage === 'PRE_PUSH' ||
348
+ params.policy.stage === 'CI'
349
+ ? toSkillsComplianceBlockingFinding({
350
+ stage: params.policy.stage,
351
+ skillsCompliance: coverage?.skillsCompliance,
352
+ })
353
+ : undefined;
310
354
  const rulesCoverage = coverage
311
355
  ? {
312
356
  stage: params.policy.stage,
@@ -334,6 +378,11 @@ export async function runPlatformGate(params: {
334
378
  coverage.activeRuleIds.length === 0
335
379
  ? 1
336
380
  : Number((coverage.evaluatedRuleIds.length / coverage.activeRuleIds.length).toFixed(6)),
381
+ ...(coverage.skillsCompliance
382
+ ? {
383
+ skills_compliance: coverage.skillsCompliance,
384
+ }
385
+ : {}),
337
386
  }
338
387
  : createEmptySnapshotRulesCoverage(params.policy.stage);
339
388
  const currentBranch = resolveCurrentBranch(git, repoRoot);
@@ -352,13 +401,15 @@ export async function runPlatformGate(params: {
352
401
  ? [
353
402
  sddBlockingFinding,
354
403
  ...(unsupportedSkillsMappingFinding ? [unsupportedSkillsMappingFinding] : []),
404
+ ...(skillsComplianceBlockingFinding ? [skillsComplianceBlockingFinding] : []),
355
405
  ...(coverageBlockingFinding ? [coverageBlockingFinding] : []),
356
406
  ...tddBddEvaluation.findings,
357
407
  ...findings,
358
408
  ]
359
- : unsupportedSkillsMappingFinding || coverageBlockingFinding || tddBddEvaluation.findings.length > 0
409
+ : unsupportedSkillsMappingFinding || skillsComplianceBlockingFinding || coverageBlockingFinding || tddBddEvaluation.findings.length > 0
360
410
  ? [
361
411
  ...(unsupportedSkillsMappingFinding ? [unsupportedSkillsMappingFinding] : []),
412
+ ...(skillsComplianceBlockingFinding ? [skillsComplianceBlockingFinding] : []),
362
413
  ...(coverageBlockingFinding ? [coverageBlockingFinding] : []),
363
414
  ...tddBddEvaluation.findings,
364
415
  ...findings,
@@ -368,6 +419,7 @@ export async function runPlatformGate(params: {
368
419
  const gateOutcome =
369
420
  sddBlockingFinding ||
370
421
  unsupportedSkillsMappingFinding ||
422
+ skillsComplianceBlockingFinding ||
371
423
  coverageBlockingFinding ||
372
424
  hasTddBddBlockingFinding
373
425
  ? 'BLOCK'
@@ -12,6 +12,10 @@ import {
12
12
  loadSkillsRuleSetForStage,
13
13
  type SkillsRuleSetLoadResult,
14
14
  } from '../config/skillsRuleSet';
15
+ import {
16
+ evaluateSkillsCompliance,
17
+ type SkillsComplianceSnapshot,
18
+ } from '../config/skillsCompliance';
15
19
  import { applyHeuristicSeverityForStage } from '../gate/stagePolicies';
16
20
  import {
17
21
  detectPlatformsFromFacts,
@@ -45,6 +49,7 @@ type PlatformGateEvaluationResult = {
45
49
  matchedRuleIds: ReadonlyArray<string>;
46
50
  unmatchedRuleIds: ReadonlyArray<string>;
47
51
  unevaluatedRuleIds: ReadonlyArray<string>;
52
+ skillsCompliance?: SkillsComplianceSnapshot;
48
53
  };
49
54
  findings: ReadonlyArray<Finding>;
50
55
  };
@@ -218,6 +223,13 @@ export const evaluatePlatformGateFindings = (
218
223
  const unevaluatedRuleIds = activeRuleIds.filter(
219
224
  (ruleId) => !evaluatedRuleIdsSet.has(ruleId)
220
225
  );
226
+ const skillsCompliance = evaluateSkillsCompliance({
227
+ skillsRuleSet,
228
+ observedFilePaths,
229
+ activeRuleIds,
230
+ evaluatedRuleIds,
231
+ matchedRuleIds,
232
+ });
221
233
 
222
234
  return {
223
235
  detectedPlatforms,
@@ -243,6 +255,7 @@ export const evaluatePlatformGateFindings = (
243
255
  matchedRuleIds,
244
256
  unmatchedRuleIds,
245
257
  unevaluatedRuleIds,
258
+ ...(skillsCompliance ? { skillsCompliance } : {}),
246
259
  },
247
260
  findings,
248
261
  };
@@ -1,8 +1,11 @@
1
1
  import { resolvePolicyForStage } from '../gate/stagePolicies';
2
+ import type { ResolvedStagePolicy } from '../gate/stagePolicies';
2
3
  import { resolveCiBaseRef, resolveUpstreamRef } from './resolveGitRefs';
3
4
  import { runPlatformGate } from './runPlatformGate';
4
5
  import { GitService } from './GitService';
5
6
  import { emitAuditSummaryNotificationFromEvidence } from '../notifications/emitAuditSummaryNotification';
7
+ import { readEvidenceResult, type EvidenceReadResult } from '../evidence/readEvidence';
8
+ import { emitStructuredTelemetry } from '../telemetry/structuredTelemetry';
6
9
 
7
10
  const PRE_PUSH_UPSTREAM_REQUIRED_MESSAGE =
8
11
  'pumuki pre-push blocked: branch has no upstream tracking reference. Configure upstream first (for example: git push --set-upstream origin <branch>) and retry.';
@@ -13,10 +16,14 @@ type StageRunnerDependencies = {
13
16
  resolveCiBaseRef: typeof resolveCiBaseRef;
14
17
  runPlatformGate: typeof runPlatformGate;
15
18
  resolveRepoRoot: () => string;
19
+ readEvidenceResult: (repoRoot: string) => EvidenceReadResult;
20
+ env: NodeJS.ProcessEnv;
21
+ writeStdout: (message: string) => void;
16
22
  notifyAuditSummaryFromEvidence: (params: {
17
23
  repoRoot: string;
18
24
  stage: 'PRE_COMMIT' | 'PRE_PUSH' | 'CI';
19
25
  }) => void;
26
+ emitStructuredTelemetry: typeof emitStructuredTelemetry;
20
27
  };
21
28
 
22
29
  const defaultDependencies: StageRunnerDependencies = {
@@ -25,12 +32,18 @@ const defaultDependencies: StageRunnerDependencies = {
25
32
  resolveCiBaseRef,
26
33
  runPlatformGate,
27
34
  resolveRepoRoot: () => new GitService().resolveRepoRoot(),
35
+ readEvidenceResult,
36
+ env: process.env,
37
+ writeStdout: (message: string) => {
38
+ process.stdout.write(`${message}\n`);
39
+ },
28
40
  notifyAuditSummaryFromEvidence: ({ repoRoot, stage }) => {
29
41
  emitAuditSummaryNotificationFromEvidence({
30
42
  repoRoot,
31
43
  stage,
32
44
  });
33
45
  },
46
+ emitStructuredTelemetry,
34
47
  };
35
48
 
36
49
  const getDependencies = (
@@ -42,18 +55,138 @@ const getDependencies = (
42
55
 
43
56
  const notifyAuditSummaryForStage = (
44
57
  dependencies: StageRunnerDependencies,
58
+ repoRoot: string,
45
59
  stage: 'PRE_COMMIT' | 'PRE_PUSH' | 'CI'
46
60
  ): void => {
47
61
  dependencies.notifyAuditSummaryFromEvidence({
48
- repoRoot: dependencies.resolveRepoRoot(),
62
+ repoRoot,
49
63
  stage,
50
64
  });
51
65
  };
52
66
 
67
+ const isQuietHookSummaryEnabled = (env: NodeJS.ProcessEnv): boolean => {
68
+ const value = env.PUMUKI_HOOK_SUMMARY_QUIET?.trim().toLowerCase();
69
+ return value === '1' || value === 'true' || value === 'yes';
70
+ };
71
+
72
+ const resolveEvidenceAgeSeconds = (timestamp: string): string => {
73
+ const generatedAtMillis = Date.parse(timestamp);
74
+ if (!Number.isFinite(generatedAtMillis)) {
75
+ return 'n/a';
76
+ }
77
+ return String(Math.max(0, Math.floor((Date.now() - generatedAtMillis) / 1000)));
78
+ };
79
+
80
+ const resolveEvidenceIntegrityStatus = (evidenceResult: EvidenceReadResult): string => {
81
+ if (evidenceResult.kind !== 'valid') {
82
+ return 'n/a';
83
+ }
84
+ return evidenceResult.evidence.integrity ? 'valid' : 'missing';
85
+ };
86
+
87
+ const resolveEvidenceChainHash = (evidenceResult: EvidenceReadResult): string => {
88
+ if (evidenceResult.kind !== 'valid') {
89
+ return 'n/a';
90
+ }
91
+ return evidenceResult.evidence.integrity?.chain_hash ?? 'n/a';
92
+ };
93
+
94
+ const resolvePolicyBundle = (trace?: ResolvedStagePolicy['trace']): string =>
95
+ trace?.bundle ?? 'n/a';
96
+
97
+ const resolvePolicyHash = (trace?: ResolvedStagePolicy['trace']): string =>
98
+ trace?.hash ?? 'n/a';
99
+
100
+ const resolvePolicyVersion = (trace?: ResolvedStagePolicy['trace']): string =>
101
+ trace?.version ?? 'n/a';
102
+
103
+ const resolvePolicySignature = (trace?: ResolvedStagePolicy['trace']): string =>
104
+ trace?.signature ?? 'n/a';
105
+
106
+ const emitHookGateSummary = (params: {
107
+ dependencies: StageRunnerDependencies;
108
+ repoRoot: string;
109
+ stage: 'PRE_COMMIT' | 'PRE_PUSH' | 'CI';
110
+ policyTrace?: ResolvedStagePolicy['trace'];
111
+ exitCode: number;
112
+ printSummary?: boolean;
113
+ }): void => {
114
+ const evidenceResult = params.dependencies.readEvidenceResult(params.repoRoot);
115
+ const decision = params.exitCode === 0 ? 'ALLOW' : 'BLOCK';
116
+ const status = params.exitCode === 0 ? 'ALLOWED' : 'BLOCKED';
117
+ const outcome =
118
+ evidenceResult.kind === 'valid' ? evidenceResult.evidence.snapshot.outcome : 'n/a';
119
+ const evidenceAgeSeconds =
120
+ evidenceResult.kind === 'valid'
121
+ ? resolveEvidenceAgeSeconds(evidenceResult.evidence.timestamp)
122
+ : 'n/a';
123
+ const policyTrace = params.policyTrace;
124
+ params.dependencies.emitStructuredTelemetry({
125
+ repoRoot: params.repoRoot,
126
+ env: params.dependencies.env,
127
+ event: {
128
+ schema_version: '1',
129
+ timestamp: new Date().toISOString(),
130
+ source: 'pumuki',
131
+ channel: 'hook_gate',
132
+ event: 'hook_gate.evaluated',
133
+ stage: params.stage,
134
+ repo_root: params.repoRoot,
135
+ status,
136
+ decision,
137
+ policy: {
138
+ source: policyTrace?.source ?? 'n/a',
139
+ bundle: resolvePolicyBundle(policyTrace),
140
+ hash: resolvePolicyHash(policyTrace),
141
+ version: resolvePolicyVersion(policyTrace),
142
+ signature: resolvePolicySignature(policyTrace),
143
+ },
144
+ evidence: {
145
+ kind: evidenceResult.kind,
146
+ age_seconds:
147
+ evidenceResult.kind === 'valid'
148
+ ? Number.parseInt(evidenceAgeSeconds, 10)
149
+ : null,
150
+ max_age_seconds: null,
151
+ source: 'local_file_ai_evidence',
152
+ path: `${params.repoRoot}/.ai_evidence.json`,
153
+ digest: null,
154
+ generated_at:
155
+ evidenceResult.kind === 'valid' ? evidenceResult.evidence.timestamp : null,
156
+ integrity_status: resolveEvidenceIntegrityStatus(evidenceResult),
157
+ chain_hash: resolveEvidenceChainHash(evidenceResult),
158
+ },
159
+ metrics: {
160
+ violations_total:
161
+ evidenceResult.kind === 'valid'
162
+ ? evidenceResult.evidence.severity_metrics.total_violations
163
+ : 0,
164
+ violations_error:
165
+ evidenceResult.kind === 'valid'
166
+ ? evidenceResult.evidence.severity_metrics.by_severity.ERROR
167
+ : 0,
168
+ violations_warn:
169
+ evidenceResult.kind === 'valid'
170
+ ? evidenceResult.evidence.severity_metrics.by_severity.WARN
171
+ : 0,
172
+ },
173
+ },
174
+ });
175
+
176
+ if ((params.printSummary ?? true) && isQuietHookSummaryEnabled(params.dependencies.env)) {
177
+ return;
178
+ }
179
+
180
+ params.dependencies.writeStdout(
181
+ `[pumuki][hook-gate] stage=${params.stage} policy_bundle=${resolvePolicyBundle(policyTrace)} policy_hash=${resolvePolicyHash(policyTrace)} policy_version=${resolvePolicyVersion(policyTrace)} policy_signature=${resolvePolicySignature(policyTrace)} decision=${decision} outcome=${outcome} evidence_kind=${evidenceResult.kind} evidence_age_seconds=${evidenceAgeSeconds} evidence_integrity=${resolveEvidenceIntegrityStatus(evidenceResult)} evidence_chain_hash=${resolveEvidenceChainHash(evidenceResult)}`
182
+ );
183
+ };
184
+
53
185
  export async function runPreCommitStage(
54
186
  dependencies: Partial<StageRunnerDependencies> = {}
55
187
  ): Promise<number> {
56
188
  const activeDependencies = getDependencies(dependencies);
189
+ const repoRoot = activeDependencies.resolveRepoRoot();
57
190
  const resolved = activeDependencies.resolvePolicyForStage('PRE_COMMIT');
58
191
  const exitCode = await activeDependencies.runPlatformGate({
59
192
  policy: resolved.policy,
@@ -62,7 +195,14 @@ export async function runPreCommitStage(
62
195
  kind: 'staged',
63
196
  },
64
197
  });
65
- notifyAuditSummaryForStage(activeDependencies, 'PRE_COMMIT');
198
+ emitHookGateSummary({
199
+ dependencies: activeDependencies,
200
+ repoRoot,
201
+ stage: 'PRE_COMMIT',
202
+ policyTrace: resolved.trace,
203
+ exitCode,
204
+ });
205
+ notifyAuditSummaryForStage(activeDependencies, repoRoot, 'PRE_COMMIT');
66
206
  return exitCode;
67
207
  }
68
208
 
@@ -70,10 +210,17 @@ export async function runPrePushStage(
70
210
  dependencies: Partial<StageRunnerDependencies> = {}
71
211
  ): Promise<number> {
72
212
  const activeDependencies = getDependencies(dependencies);
213
+ const repoRoot = activeDependencies.resolveRepoRoot();
73
214
  const upstreamRef = activeDependencies.resolveUpstreamRef();
74
215
  if (!upstreamRef) {
75
216
  process.stderr.write(`${PRE_PUSH_UPSTREAM_REQUIRED_MESSAGE}\n`);
76
- notifyAuditSummaryForStage(activeDependencies, 'PRE_PUSH');
217
+ emitHookGateSummary({
218
+ dependencies: activeDependencies,
219
+ repoRoot,
220
+ stage: 'PRE_PUSH',
221
+ exitCode: 1,
222
+ });
223
+ notifyAuditSummaryForStage(activeDependencies, repoRoot, 'PRE_PUSH');
77
224
  return 1;
78
225
  }
79
226
 
@@ -87,7 +234,14 @@ export async function runPrePushStage(
87
234
  toRef: 'HEAD',
88
235
  },
89
236
  });
90
- notifyAuditSummaryForStage(activeDependencies, 'PRE_PUSH');
237
+ emitHookGateSummary({
238
+ dependencies: activeDependencies,
239
+ repoRoot,
240
+ stage: 'PRE_PUSH',
241
+ policyTrace: resolved.trace,
242
+ exitCode,
243
+ });
244
+ notifyAuditSummaryForStage(activeDependencies, repoRoot, 'PRE_PUSH');
91
245
  return exitCode;
92
246
  }
93
247
 
@@ -95,6 +249,7 @@ export async function runCiStage(
95
249
  dependencies: Partial<StageRunnerDependencies> = {}
96
250
  ): Promise<number> {
97
251
  const activeDependencies = getDependencies(dependencies);
252
+ const repoRoot = activeDependencies.resolveRepoRoot();
98
253
  const resolved = activeDependencies.resolvePolicyForStage('CI');
99
254
  const exitCode = await activeDependencies.runPlatformGate({
100
255
  policy: resolved.policy,
@@ -105,6 +260,14 @@ export async function runCiStage(
105
260
  toRef: 'HEAD',
106
261
  },
107
262
  });
108
- notifyAuditSummaryForStage(activeDependencies, 'CI');
263
+ emitHookGateSummary({
264
+ dependencies: activeDependencies,
265
+ repoRoot,
266
+ stage: 'CI',
267
+ policyTrace: resolved.trace,
268
+ exitCode,
269
+ printSummary: false,
270
+ });
271
+ notifyAuditSummaryForStage(activeDependencies, repoRoot, 'CI');
109
272
  return exitCode;
110
273
  }