pumuki 6.3.13 → 6.3.14

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 (49) hide show
  1. package/README.md +95 -7
  2. package/VERSION +1 -1
  3. package/bin/pumuki-mcp-enterprise.js +5 -0
  4. package/bin/pumuki-pre-write.js +11 -0
  5. package/docs/API_REFERENCE.md +2 -1
  6. package/docs/INSTALLATION.md +101 -54
  7. package/docs/MCP_SERVERS.md +167 -74
  8. package/docs/PUMUKI_FULL_VALIDATION_CHECKLIST.md +46 -45
  9. package/docs/PUMUKI_OPENSPEC_SDD_ROADMAP.md +55 -0
  10. package/docs/README.md +5 -0
  11. package/docs/REFRACTOR_PROGRESS.md +102 -3
  12. package/docs/USAGE.md +115 -8
  13. package/docs/validation/README.md +2 -0
  14. package/docs/validation/phase12-go-no-go-report.md +73 -0
  15. package/docs/validation/post-phase12-next-lot-decision.md +75 -0
  16. package/integrations/config/skillsRuleSet.ts +53 -6
  17. package/integrations/evidence/buildEvidence.ts +42 -3
  18. package/integrations/evidence/generateEvidence.test.ts +59 -0
  19. package/integrations/evidence/readEvidence.test.ts +61 -0
  20. package/integrations/evidence/schema.test.ts +81 -0
  21. package/integrations/evidence/schema.ts +11 -0
  22. package/integrations/evidence/writeEvidence.test.ts +18 -0
  23. package/integrations/evidence/writeEvidence.ts +11 -0
  24. package/integrations/git/resolveGitRefs.ts +2 -2
  25. package/integrations/git/runPlatformGate.ts +64 -0
  26. package/integrations/git/runPlatformGateEvidence.ts +13 -0
  27. package/integrations/git/stageRunners.ts +10 -1
  28. package/integrations/lifecycle/artifacts.ts +57 -4
  29. package/integrations/lifecycle/cli.ts +248 -12
  30. package/integrations/lifecycle/constants.ts +1 -0
  31. package/integrations/lifecycle/gitService.ts +1 -0
  32. package/integrations/lifecycle/install.ts +24 -1
  33. package/integrations/lifecycle/openSpecBootstrap.ts +190 -0
  34. package/integrations/lifecycle/state.ts +57 -0
  35. package/integrations/lifecycle/uninstall.ts +3 -1
  36. package/integrations/lifecycle/update.ts +11 -0
  37. package/integrations/mcp/enterpriseServer.cli.ts +12 -0
  38. package/integrations/mcp/enterpriseServer.ts +762 -0
  39. package/integrations/mcp/index.ts +1 -0
  40. package/integrations/sdd/index.ts +11 -0
  41. package/integrations/sdd/openSpecCli.ts +180 -0
  42. package/integrations/sdd/policy.ts +190 -0
  43. package/integrations/sdd/sessionStore.ts +152 -0
  44. package/integrations/sdd/types.ts +69 -0
  45. package/package.json +10 -4
  46. package/scripts/framework-menu-runner-path-lib.ts +10 -3
  47. package/scripts/framework-menu.ts +86 -5
  48. package/scripts/package-install-smoke-gate-lib.ts +6 -1
  49. package/scripts/package-install-smoke-lifecycle-lib.ts +3 -0
@@ -11,6 +11,7 @@ import type {
11
11
  LedgerEntry,
12
12
  PlatformState,
13
13
  RulesetState,
14
+ SddMetrics,
14
15
  SnapshotFinding,
15
16
  } from './schema';
16
17
  import { resolveHumanIntent } from './humanIntent';
@@ -28,6 +29,7 @@ export type BuildEvidenceParams = {
28
29
  humanIntent?: HumanIntentState | null;
29
30
  detectedPlatforms: Record<string, PlatformState>;
30
31
  loadedRulesets: ReadonlyArray<RulesetState>;
32
+ sddMetrics?: SddMetrics;
31
33
  };
32
34
 
33
35
  const normalizeLines = (lines?: EvidenceLines): EvidenceLines | undefined => {
@@ -96,6 +98,30 @@ const normalizeFinding = (finding: BuildFindingInput): SnapshotFinding => {
96
98
  };
97
99
  };
98
100
 
101
+ const pickDeterministicDuplicateFinding = (
102
+ current: SnapshotFinding,
103
+ candidate: SnapshotFinding
104
+ ): SnapshotFinding => {
105
+ const bySeverity = severityRank[candidate.severity] - severityRank[current.severity];
106
+ if (bySeverity > 0) {
107
+ return candidate;
108
+ }
109
+ if (bySeverity < 0) {
110
+ return current;
111
+ }
112
+
113
+ const tuple = (finding: SnapshotFinding): string => {
114
+ return [
115
+ finding.code,
116
+ finding.message,
117
+ finding.matchedBy ?? '',
118
+ finding.source ?? '',
119
+ ].join('\u0000');
120
+ };
121
+
122
+ return tuple(candidate).localeCompare(tuple(current)) < 0 ? candidate : current;
123
+ };
124
+
99
125
  const severityRank: Record<Severity, number> = {
100
126
  INFO: 0,
101
127
  WARN: 1,
@@ -228,9 +254,11 @@ const normalizeAndDedupeFindings = (
228
254
  for (const finding of findings) {
229
255
  const normalized = normalizeFinding(finding);
230
256
  const key = findingKey(normalized);
231
- if (!unique.has(key)) {
232
- unique.set(key, normalized);
233
- }
257
+ const current = unique.get(key);
258
+ unique.set(
259
+ key,
260
+ current ? pickDeterministicDuplicateFinding(current, normalized) : normalized
261
+ );
234
262
  }
235
263
  const deduped = Array.from(unique.values()).sort(compareFindingEntries);
236
264
  const consolidated = consolidateEquivalentFindings(deduped);
@@ -371,6 +399,17 @@ export function buildEvidence(params: BuildEvidenceParams): AiEvidenceV2_1 {
371
399
  total_violations: normalizedFindings.length,
372
400
  by_severity: severity,
373
401
  },
402
+ sdd_metrics: params.sddMetrics
403
+ ? {
404
+ enforced: params.sddMetrics.enforced,
405
+ stage: params.sddMetrics.stage,
406
+ decision: {
407
+ allowed: params.sddMetrics.decision.allowed,
408
+ code: params.sddMetrics.decision.code,
409
+ message: params.sddMetrics.decision.message,
410
+ },
411
+ }
412
+ : undefined,
374
413
  consolidation:
375
414
  consolidatedFindings.suppressed.length > 0
376
415
  ? { suppressed: consolidatedFindings.suppressed }
@@ -121,3 +121,62 @@ test('generateEvidence respeta gateOutcome explícito al componer build + write'
121
121
  });
122
122
  });
123
123
  });
124
+
125
+ test('generateEvidence persiste contrato SDD cuando se informa bloqueo de policy', async () => {
126
+ await withTempDir('pumuki-generate-evidence-sdd-contract-', async (tempRoot) => {
127
+ initGitRepo(tempRoot);
128
+ await withCwd(tempRoot, async () => {
129
+ const result = generateEvidence({
130
+ stage: 'PRE_PUSH',
131
+ gateOutcome: 'BLOCK',
132
+ findings: [
133
+ {
134
+ ruleId: 'sdd.policy.blocked',
135
+ severity: 'ERROR',
136
+ code: 'SDD_VALIDATION_FAILED',
137
+ message: 'OpenSpec validation failed',
138
+ filePath: 'openspec/changes',
139
+ matchedBy: 'SddPolicy',
140
+ source: 'sdd-policy',
141
+ },
142
+ ],
143
+ detectedPlatforms: {},
144
+ loadedRulesets: [{ platform: 'policy', bundle: 'gate-policy.default.PRE_PUSH', hash: 'hash-policy' }],
145
+ sddMetrics: {
146
+ enforced: true,
147
+ stage: 'PRE_PUSH',
148
+ decision: {
149
+ allowed: false,
150
+ code: 'SDD_VALIDATION_FAILED',
151
+ message: 'OpenSpec validation failed',
152
+ },
153
+ },
154
+ });
155
+
156
+ assert.equal(result.evidence.snapshot.findings[0]?.source, 'sdd-policy');
157
+ assert.deepEqual(result.evidence.sdd_metrics, {
158
+ enforced: true,
159
+ stage: 'PRE_PUSH',
160
+ decision: {
161
+ allowed: false,
162
+ code: 'SDD_VALIDATION_FAILED',
163
+ message: 'OpenSpec validation failed',
164
+ },
165
+ });
166
+ assert.equal(result.write.ok, true);
167
+
168
+ const persisted = JSON.parse(readFileSync(join(tempRoot, '.ai_evidence.json'), 'utf8')) as AiEvidenceV2_1;
169
+ assert.equal(persisted.snapshot.findings[0]?.source, 'sdd-policy');
170
+ assert.equal(persisted.ai_gate.violations[0]?.source, 'sdd-policy');
171
+ assert.deepEqual(persisted.sdd_metrics, {
172
+ enforced: true,
173
+ stage: 'PRE_PUSH',
174
+ decision: {
175
+ allowed: false,
176
+ code: 'SDD_VALIDATION_FAILED',
177
+ message: 'OpenSpec validation failed',
178
+ },
179
+ });
180
+ });
181
+ });
182
+ });
@@ -57,6 +57,67 @@ test('readEvidenceResult devuelve valid cuando el archivo tiene version 2.1', as
57
57
  });
58
58
  });
59
59
 
60
+ test('readEvidenceResult preserva contrato SDD (sdd_metrics + source sdd-policy)', async () => {
61
+ await withTempDir('pumuki-read-evidence-sdd-contract-', async (tempRoot) => {
62
+ const evidence = sampleEvidence();
63
+ evidence.snapshot.stage = 'PRE_PUSH';
64
+ evidence.snapshot.outcome = 'BLOCK';
65
+ evidence.snapshot.findings = [
66
+ {
67
+ ruleId: 'sdd.policy.blocked',
68
+ severity: 'ERROR',
69
+ code: 'SDD_VALIDATION_FAILED',
70
+ message: 'OpenSpec validation failed',
71
+ file: 'openspec/changes',
72
+ matchedBy: 'SddPolicy',
73
+ source: 'sdd-policy',
74
+ },
75
+ ];
76
+ evidence.ledger = [
77
+ {
78
+ ruleId: 'sdd.policy.blocked',
79
+ file: 'openspec/changes',
80
+ firstSeen: '2026-02-18T10:00:00.000Z',
81
+ lastSeen: '2026-02-18T10:01:00.000Z',
82
+ },
83
+ ];
84
+ evidence.ai_gate.status = 'BLOCKED';
85
+ evidence.ai_gate.violations = [
86
+ {
87
+ ruleId: 'sdd.policy.blocked',
88
+ level: 'ERROR',
89
+ code: 'SDD_VALIDATION_FAILED',
90
+ message: 'OpenSpec validation failed',
91
+ file: 'openspec/changes',
92
+ matchedBy: 'SddPolicy',
93
+ source: 'sdd-policy',
94
+ },
95
+ ];
96
+ evidence.severity_metrics.gate_status = 'BLOCKED';
97
+ evidence.severity_metrics.total_violations = 1;
98
+ evidence.severity_metrics.by_severity.ERROR = 1;
99
+ evidence.sdd_metrics = {
100
+ enforced: true,
101
+ stage: 'PRE_PUSH',
102
+ decision: {
103
+ allowed: false,
104
+ code: 'SDD_VALIDATION_FAILED',
105
+ message: 'OpenSpec validation failed',
106
+ },
107
+ };
108
+
109
+ writeFileSync(join(tempRoot, '.ai_evidence.json'), JSON.stringify(evidence, null, 2), 'utf8');
110
+
111
+ const result = readEvidenceResult(tempRoot);
112
+ assert.equal(result.kind, 'valid');
113
+ if (result.kind === 'valid') {
114
+ assert.equal(result.evidence.snapshot.findings[0]?.source, 'sdd-policy');
115
+ assert.equal(result.evidence.ai_gate.violations[0]?.source, 'sdd-policy');
116
+ assert.deepEqual(result.evidence.sdd_metrics, evidence.sdd_metrics);
117
+ }
118
+ });
119
+ });
120
+
60
121
  test('readEvidenceResult devuelve invalid y versión cuando el schema es de otra versión', async () => {
61
122
  await withTempDir('pumuki-read-evidence-invalid-version-', async (tempRoot) => {
62
123
  writeFileSync(
@@ -81,6 +81,87 @@ test('AiEvidenceV2_1 soporta snapshot/ledger/platforms/rulesets con contrato 2.1
81
81
  assert.equal(evidence.ai_gate.violations[0]?.level, 'ERROR');
82
82
  });
83
83
 
84
+ test('AiEvidenceV2_1 soporta contrato SDD en evidencia (sdd_metrics + source sdd-policy)', () => {
85
+ const evidence: AiEvidenceV2_1 = {
86
+ version: '2.1',
87
+ timestamp: '2026-02-18T10:00:00.000Z',
88
+ snapshot: {
89
+ stage: 'PRE_PUSH',
90
+ outcome: 'BLOCK',
91
+ findings: [
92
+ sampleFinding({
93
+ ruleId: 'sdd.policy.blocked',
94
+ severity: 'ERROR',
95
+ code: 'SDD_VALIDATION_FAILED',
96
+ message: 'OpenSpec validation failed',
97
+ file: 'openspec/changes',
98
+ lines: undefined,
99
+ matchedBy: 'SddPolicy',
100
+ source: 'sdd-policy',
101
+ }),
102
+ ],
103
+ },
104
+ ledger: [
105
+ {
106
+ ruleId: 'sdd.policy.blocked',
107
+ file: 'openspec/changes',
108
+ firstSeen: '2026-02-18T09:58:00.000Z',
109
+ lastSeen: '2026-02-18T10:00:00.000Z',
110
+ },
111
+ ],
112
+ platforms: {},
113
+ rulesets: [{ platform: 'policy', bundle: 'gate-policy.default.PRE_PUSH', hash: 'hash-policy' }],
114
+ human_intent: null,
115
+ ai_gate: {
116
+ status: 'BLOCKED',
117
+ violations: [
118
+ sampleViolation({
119
+ ruleId: 'sdd.policy.blocked',
120
+ code: 'SDD_VALIDATION_FAILED',
121
+ message: 'OpenSpec validation failed',
122
+ file: 'openspec/changes',
123
+ lines: undefined,
124
+ matchedBy: 'SddPolicy',
125
+ source: 'sdd-policy',
126
+ }),
127
+ ],
128
+ human_intent: null,
129
+ },
130
+ severity_metrics: {
131
+ gate_status: 'BLOCKED',
132
+ total_violations: 1,
133
+ by_severity: {
134
+ INFO: 0,
135
+ WARN: 0,
136
+ ERROR: 1,
137
+ CRITICAL: 0,
138
+ },
139
+ },
140
+ sdd_metrics: {
141
+ enforced: true,
142
+ stage: 'PRE_PUSH',
143
+ decision: {
144
+ allowed: false,
145
+ code: 'SDD_VALIDATION_FAILED',
146
+ message: 'OpenSpec validation failed',
147
+ },
148
+ },
149
+ };
150
+
151
+ assert.equal(evidence.snapshot.findings[0]?.source, 'sdd-policy');
152
+ assert.equal(evidence.snapshot.findings[0]?.matchedBy, 'SddPolicy');
153
+ assert.equal(evidence.ai_gate.violations[0]?.source, 'sdd-policy');
154
+ assert.deepEqual(evidence.sdd_metrics, {
155
+ enforced: true,
156
+ stage: 'PRE_PUSH',
157
+ decision: {
158
+ allowed: false,
159
+ code: 'SDD_VALIDATION_FAILED',
160
+ message: 'OpenSpec validation failed',
161
+ },
162
+ });
163
+ });
164
+
84
165
  test('EvidenceLines acepta string, number y array numérico según contrato', () => {
85
166
  const stringLines = sampleFinding({ lines: 'L12-L14' });
86
167
  const numberLines = sampleFinding({ lines: 12 });
@@ -67,6 +67,16 @@ export type CompatibilityViolation = {
67
67
  source?: string;
68
68
  };
69
69
 
70
+ export type SddMetrics = {
71
+ enforced: boolean;
72
+ stage: GateStage;
73
+ decision: {
74
+ allowed: boolean;
75
+ code: string;
76
+ message: string;
77
+ };
78
+ };
79
+
70
80
  export type ConsolidationSuppressedFinding = {
71
81
  ruleId: string;
72
82
  file: string;
@@ -95,6 +105,7 @@ export type AiEvidenceV2_1 = {
95
105
  total_violations: number;
96
106
  by_severity: Record<Severity, number>;
97
107
  };
108
+ sdd_metrics?: SddMetrics;
98
109
  consolidation?: {
99
110
  suppressed: ConsolidationSuppressedFinding[];
100
111
  };
@@ -93,6 +93,15 @@ const sampleEvidence = (repoRoot: string): AiEvidenceV2_1 => ({
93
93
  INFO: 0,
94
94
  },
95
95
  },
96
+ sdd_metrics: {
97
+ enforced: true,
98
+ stage: 'PRE_PUSH',
99
+ decision: {
100
+ allowed: true,
101
+ code: 'ALLOWED',
102
+ message: 'sdd policy passed',
103
+ },
104
+ },
96
105
  });
97
106
 
98
107
  test('writeEvidence escribe archivo estable y normaliza paths/orden/lineas', async () => {
@@ -137,6 +146,15 @@ test('writeEvidence escribe archivo estable y normaliza paths/orden/lineas', asy
137
146
  ['z.rule', 'FileContent', 'git:staged'],
138
147
  ]
139
148
  );
149
+ assert.deepEqual(written.sdd_metrics, {
150
+ enforced: true,
151
+ stage: 'PRE_PUSH',
152
+ decision: {
153
+ allowed: true,
154
+ code: 'ALLOWED',
155
+ message: 'sdd policy passed',
156
+ },
157
+ });
140
158
  });
141
159
  });
142
160
  });
@@ -169,6 +169,17 @@ const toStableEvidence = (
169
169
  total_violations: evidence.severity_metrics.total_violations,
170
170
  by_severity: bySeverity,
171
171
  },
172
+ sdd_metrics: evidence.sdd_metrics
173
+ ? {
174
+ enforced: evidence.sdd_metrics.enforced,
175
+ stage: evidence.sdd_metrics.stage,
176
+ decision: {
177
+ allowed: evidence.sdd_metrics.decision.allowed,
178
+ code: evidence.sdd_metrics.decision.code,
179
+ message: evidence.sdd_metrics.decision.message,
180
+ },
181
+ }
182
+ : undefined,
172
183
  };
173
184
  };
174
185
 
@@ -26,11 +26,11 @@ const resolveDefaultCiBaseRef = (): string => {
26
26
  return 'HEAD';
27
27
  };
28
28
 
29
- export const resolveUpstreamRef = (): string => {
29
+ export const resolveUpstreamRef = (): string | null => {
30
30
  try {
31
31
  return runGit(['rev-parse', '@{u}']);
32
32
  } catch {
33
- return 'HEAD';
33
+ return null;
34
34
  }
35
35
  };
36
36
 
@@ -1,12 +1,17 @@
1
1
  import { evaluateGate } from '../../core/gate/evaluateGate';
2
+ import type { Finding } from '../../core/gate/Finding';
2
3
  import type { GatePolicy } from '../../core/gate/GatePolicy';
4
+ import type { RuleSet } from '../../core/rules/RuleSet';
5
+ import type { SkillsRuleSetLoadResult } from '../config/skillsRuleSet';
3
6
  import type { ResolvedStagePolicy } from '../gate/stagePolicies';
7
+ import type { DetectedPlatforms } from '../platform/detectPlatforms';
4
8
  import { GitService, type IGitService } from './GitService';
5
9
  import { EvidenceService, type IEvidenceService } from './EvidenceService';
6
10
  import { evaluatePlatformGateFindings } from './runPlatformGateEvaluation';
7
11
  import { resolveFactsForGateScope, type GateScope } from './runPlatformGateFacts';
8
12
  import { emitPlatformGateEvidence } from './runPlatformGateEvidence';
9
13
  import { printGateFindings } from './runPlatformGateOutput';
14
+ import { evaluateSddPolicy, type SddDecision } from '../sdd';
10
15
 
11
16
  export type GateServices = {
12
17
  git: IGitService;
@@ -19,6 +24,10 @@ export type GateDependencies = {
19
24
  resolveFactsForGateScope: typeof resolveFactsForGateScope;
20
25
  emitPlatformGateEvidence: typeof emitPlatformGateEvidence;
21
26
  printGateFindings: typeof printGateFindings;
27
+ evaluateSddForStage: (
28
+ stage: 'PRE_COMMIT' | 'PRE_PUSH' | 'CI',
29
+ repoRoot: string
30
+ ) => Pick<SddDecision, 'allowed' | 'code' | 'message'>;
22
31
  };
23
32
 
24
33
  const defaultServices: GateServices = {
@@ -32,8 +41,23 @@ const defaultDependencies: GateDependencies = {
32
41
  resolveFactsForGateScope,
33
42
  emitPlatformGateEvidence,
34
43
  printGateFindings,
44
+ evaluateSddForStage: (stage, repoRoot) =>
45
+ evaluateSddPolicy({
46
+ stage,
47
+ repoRoot,
48
+ }).decision,
35
49
  };
36
50
 
51
+ const toSddBlockingFinding = (decision: Pick<SddDecision, 'code' | 'message'>): Finding => ({
52
+ ruleId: 'sdd.policy.blocked',
53
+ severity: 'ERROR',
54
+ code: decision.code,
55
+ message: decision.message,
56
+ filePath: 'openspec/changes',
57
+ matchedBy: 'SddPolicy',
58
+ source: 'sdd-policy',
59
+ });
60
+
37
61
  export async function runPlatformGate(params: {
38
62
  policy: GatePolicy;
39
63
  policyTrace?: ResolvedStagePolicy['trace'];
@@ -48,6 +72,45 @@ export async function runPlatformGate(params: {
48
72
  ...params.dependencies,
49
73
  };
50
74
  const repoRoot = git.resolveRepoRoot();
75
+ let sddDecision:
76
+ | Pick<SddDecision, 'allowed' | 'code' | 'message'>
77
+ | undefined;
78
+
79
+ if (
80
+ params.policy.stage === 'PRE_COMMIT' ||
81
+ params.policy.stage === 'PRE_PUSH' ||
82
+ params.policy.stage === 'CI'
83
+ ) {
84
+ sddDecision = dependencies.evaluateSddForStage(
85
+ params.policy.stage,
86
+ repoRoot
87
+ );
88
+ if (!sddDecision.allowed) {
89
+ console.log(`[pumuki][sdd] ${sddDecision.code}: ${sddDecision.message}`);
90
+ const emptyDetectedPlatforms: DetectedPlatforms = {};
91
+ const emptySkillsRuleSet: SkillsRuleSetLoadResult = {
92
+ rules: [],
93
+ activeBundles: [],
94
+ mappedHeuristicRuleIds: new Set<string>(),
95
+ requiresHeuristicFacts: false,
96
+ };
97
+ const emptyRuleSet: RuleSet = [];
98
+ dependencies.emitPlatformGateEvidence({
99
+ stage: params.policy.stage,
100
+ policyTrace: params.policyTrace,
101
+ findings: [toSddBlockingFinding(sddDecision)],
102
+ gateOutcome: 'BLOCK',
103
+ repoRoot,
104
+ detectedPlatforms: emptyDetectedPlatforms,
105
+ skillsRuleSet: emptySkillsRuleSet,
106
+ projectRules: emptyRuleSet,
107
+ heuristicRules: emptyRuleSet,
108
+ evidenceService: evidence,
109
+ sddDecision,
110
+ });
111
+ return 1;
112
+ }
113
+ }
51
114
 
52
115
  const facts = await dependencies.resolveFactsForGateScope({
53
116
  scope: params.scope,
@@ -78,6 +141,7 @@ export async function runPlatformGate(params: {
78
141
  projectRules,
79
142
  heuristicRules,
80
143
  evidenceService: evidence,
144
+ sddDecision,
81
145
  });
82
146
 
83
147
  if (decision.outcome === 'BLOCK') {
@@ -9,6 +9,7 @@ import type { ResolvedStagePolicy } from '../gate/stagePolicies';
9
9
  import type { DetectedPlatforms } from '../platform/detectPlatforms';
10
10
  import { buildBaselineRuleSetEntries } from './baselineRuleSets';
11
11
  import type { IEvidenceService } from './EvidenceService';
12
+ import type { SddDecision } from '../sdd';
12
13
 
13
14
  export type PlatformGateEvidenceDependencies = {
14
15
  generateEvidence: typeof generateEvidence;
@@ -29,6 +30,7 @@ export const emitPlatformGateEvidence = (params: {
29
30
  projectRules: RuleSet;
30
31
  heuristicRules: RuleSet;
31
32
  evidenceService: IEvidenceService;
33
+ sddDecision?: Pick<SddDecision, 'allowed' | 'code' | 'message'>;
32
34
  }, dependencies: Partial<PlatformGateEvidenceDependencies> = {}): void => {
33
35
  const activeDependencies: PlatformGateEvidenceDependencies = {
34
36
  ...defaultDependencies,
@@ -50,5 +52,16 @@ export const emitPlatformGateEvidence = (params: {
50
52
  policyTrace: params.policyTrace,
51
53
  stage: params.stage,
52
54
  }),
55
+ sddMetrics: params.sddDecision
56
+ ? {
57
+ enforced: true,
58
+ stage: params.stage,
59
+ decision: {
60
+ allowed: params.sddDecision.allowed,
61
+ code: params.sddDecision.code,
62
+ message: params.sddDecision.message,
63
+ },
64
+ }
65
+ : undefined,
53
66
  });
54
67
  };
@@ -2,6 +2,9 @@ import { resolvePolicyForStage } from '../gate/stagePolicies';
2
2
  import { resolveCiBaseRef, resolveUpstreamRef } from './resolveGitRefs';
3
3
  import { runPlatformGate } from './runPlatformGate';
4
4
 
5
+ const PRE_PUSH_UPSTREAM_REQUIRED_MESSAGE =
6
+ 'pumuki pre-push blocked: branch has no upstream tracking reference. Configure upstream first (for example: git push --set-upstream origin <branch>) and retry.';
7
+
5
8
  export async function runPreCommitStage(): Promise<number> {
6
9
  const resolved = resolvePolicyForStage('PRE_COMMIT');
7
10
  return runPlatformGate({
@@ -14,13 +17,19 @@ export async function runPreCommitStage(): Promise<number> {
14
17
  }
15
18
 
16
19
  export async function runPrePushStage(): Promise<number> {
20
+ const upstreamRef = resolveUpstreamRef();
21
+ if (!upstreamRef) {
22
+ console.error(PRE_PUSH_UPSTREAM_REQUIRED_MESSAGE);
23
+ return 1;
24
+ }
25
+
17
26
  const resolved = resolvePolicyForStage('PRE_PUSH');
18
27
  return runPlatformGate({
19
28
  policy: resolved.policy,
20
29
  policyTrace: resolved.trace,
21
30
  scope: {
22
31
  kind: 'range',
23
- fromRef: resolveUpstreamRef(),
32
+ fromRef: upstreamRef,
24
33
  toRef: 'HEAD',
25
34
  },
26
35
  });
@@ -1,5 +1,5 @@
1
- import { existsSync, unlinkSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { existsSync, lstatSync, readdirSync, rmdirSync, unlinkSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
3
  import type { ILifecycleGitService } from './gitService';
4
4
 
5
5
  const PUMUKI_ARTIFACTS = ['.ai_evidence.json', '.AI_EVIDENCE.json'] as const;
@@ -21,9 +21,46 @@ const isTrackedArtifactAlias = (params: {
21
21
  export const purgeUntrackedPumukiArtifacts = (params: {
22
22
  git: ILifecycleGitService;
23
23
  repoRoot: string;
24
+ managedOpenSpecArtifacts?: ReadonlyArray<string>;
24
25
  }): ReadonlyArray<string> => {
25
26
  const removed: string[] = [];
26
27
 
28
+ const pruneEmptyAncestors = (relativePath: string): void => {
29
+ let current = dirname(relativePath);
30
+ while (current !== '.' && current !== '') {
31
+ const absolute = join(params.repoRoot, current);
32
+ if (!existsSync(absolute)) {
33
+ break;
34
+ }
35
+ if (params.git.isPathTracked(params.repoRoot, current)) {
36
+ break;
37
+ }
38
+ if (readdirSync(absolute).length > 0) {
39
+ break;
40
+ }
41
+ rmdirSync(absolute);
42
+ current = dirname(current);
43
+ }
44
+ };
45
+
46
+ const removeUntrackedFile = (relativePath: string): boolean => {
47
+ const absolutePath = join(params.repoRoot, relativePath);
48
+ if (!existsSync(absolutePath)) {
49
+ return false;
50
+ }
51
+ if (params.git.isPathTracked(params.repoRoot, relativePath)) {
52
+ return false;
53
+ }
54
+ const stat = lstatSync(absolutePath);
55
+ if (!stat.isFile() && !stat.isSymbolicLink()) {
56
+ return false;
57
+ }
58
+ unlinkSync(absolutePath);
59
+ removed.push(relativePath);
60
+ pruneEmptyAncestors(relativePath);
61
+ return true;
62
+ };
63
+
27
64
  for (const relativePath of PUMUKI_ARTIFACTS) {
28
65
  const absolutePath = join(params.repoRoot, relativePath);
29
66
  if (!existsSync(absolutePath)) {
@@ -38,8 +75,24 @@ export const purgeUntrackedPumukiArtifacts = (params: {
38
75
  ) {
39
76
  continue;
40
77
  }
41
- unlinkSync(absolutePath);
42
- removed.push(relativePath);
78
+ removeUntrackedFile(relativePath);
79
+ }
80
+
81
+ const managedArtifacts = Array.from(
82
+ new Set((params.managedOpenSpecArtifacts ?? []).map((value) => value.trim()))
83
+ );
84
+ for (const rawPath of managedArtifacts) {
85
+ const normalized = rawPath.replace(/\\/g, '/');
86
+ if (normalized.length === 0) {
87
+ continue;
88
+ }
89
+ if (normalized.startsWith('/') || normalized.startsWith('../') || normalized.includes('/../')) {
90
+ continue;
91
+ }
92
+ if (PUMUKI_ARTIFACTS.includes(normalized as (typeof PUMUKI_ARTIFACTS)[number])) {
93
+ continue;
94
+ }
95
+ removeUntrackedFile(normalized);
43
96
  }
44
97
 
45
98
  return removed;