pumuki 6.3.44 → 6.3.46

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.
@@ -22,6 +22,7 @@ import { readFileSync } from 'node:fs';
22
22
  import { readEvidence, readEvidenceResult } from '../evidence/readEvidence';
23
23
  import type { EvidenceReadResult } from '../evidence/readEvidence';
24
24
  import { ensureRuntimeArtifactsIgnored } from '../lifecycle/artifacts';
25
+ import { runPolicyReconcile } from '../lifecycle/policyReconcile';
25
26
 
26
27
  const PRE_PUSH_UPSTREAM_REQUIRED_MESSAGE =
27
28
  'pumuki pre-push blocked: branch has no upstream tracking reference. Configure upstream first (for example: git push --set-upstream origin <branch>) and retry.';
@@ -54,6 +55,14 @@ const BLOCKED_REMEDIATION_BY_CODE: Readonly<Record<string, string>> = {
54
55
  'Alinea upstream con la rama actual: git branch --unset-upstream && git push --set-upstream origin <branch>',
55
56
  };
56
57
 
58
+ const HOOK_POLICY_RECONCILE_CODES = new Set<string>([
59
+ 'SKILLS_PLATFORM_COVERAGE_INCOMPLETE_HIGH',
60
+ 'SKILLS_SCOPE_COMPLIANCE_INCOMPLETE_HIGH',
61
+ 'EVIDENCE_PLATFORM_SKILLS_SCOPE_INCOMPLETE',
62
+ 'EVIDENCE_PLATFORM_SKILLS_BUNDLES_MISSING',
63
+ 'EVIDENCE_CROSS_PLATFORM_CRITICAL_ENFORCEMENT_INCOMPLETE',
64
+ ]);
65
+
57
66
  type StageRunnerDependencies = {
58
67
  resolvePolicyForStage: typeof resolvePolicyForStage;
59
68
  resolveUpstreamRef: typeof resolveUpstreamRef;
@@ -89,6 +98,7 @@ type StageRunnerDependencies = {
89
98
  writeHookGateSummary: (message: string) => void;
90
99
  isQuietMode: () => boolean;
91
100
  ensureRuntimeArtifactsIgnored: (repoRoot: string) => void;
101
+ runPolicyReconcile: typeof runPolicyReconcile;
92
102
  };
93
103
 
94
104
  const defaultDependencies: StageRunnerDependencies = {
@@ -144,6 +154,7 @@ const defaultDependencies: StageRunnerDependencies = {
144
154
  } catch {
145
155
  }
146
156
  },
157
+ runPolicyReconcile,
147
158
  };
148
159
 
149
160
  const getDependencies = (
@@ -196,6 +207,93 @@ const notifyGateBlockedForStage = (params: {
196
207
  });
197
208
  };
198
209
 
210
+ const isHookPolicyAutoReconcileEnabled = (): boolean =>
211
+ process.env.PUMUKI_HOOK_POLICY_AUTO_RECONCILE !== '0';
212
+
213
+ const shouldRetryAfterPolicyReconcile = (params: {
214
+ dependencies: StageRunnerDependencies;
215
+ repoRoot: string;
216
+ stage: 'PRE_COMMIT' | 'PRE_PUSH';
217
+ }): boolean => {
218
+ const evidence = params.dependencies.readEvidence(params.repoRoot);
219
+ if (!evidence) {
220
+ return false;
221
+ }
222
+ const stageCodes = new Set<string>();
223
+ if (evidence.snapshot.stage === params.stage) {
224
+ for (const finding of evidence.snapshot.findings) {
225
+ stageCodes.add(finding.code);
226
+ }
227
+ }
228
+ for (const violation of evidence.ai_gate.violations) {
229
+ stageCodes.add(violation.code);
230
+ }
231
+ for (const code of stageCodes) {
232
+ if (HOOK_POLICY_RECONCILE_CODES.has(code)) {
233
+ return true;
234
+ }
235
+ }
236
+ return false;
237
+ };
238
+
239
+ type HookStage = 'PRE_COMMIT' | 'PRE_PUSH';
240
+ type HookPolicyTrace = NonNullable<ReturnType<typeof resolvePolicyForStage>['trace']>;
241
+
242
+ const runHookGateAttempt = async (params: {
243
+ dependencies: StageRunnerDependencies;
244
+ stage: HookStage;
245
+ scope: Parameters<typeof runPlatformGate>[0]['scope'];
246
+ }): Promise<{ exitCode: number; policyTrace: HookPolicyTrace }> => {
247
+ const resolved = params.dependencies.resolvePolicyForStage(params.stage);
248
+ const exitCode = await params.dependencies.runPlatformGate({
249
+ policy: resolved.policy,
250
+ policyTrace: resolved.trace,
251
+ scope: params.scope,
252
+ });
253
+ return {
254
+ exitCode,
255
+ policyTrace: resolved.trace,
256
+ };
257
+ };
258
+
259
+ const runHookGateWithPolicyRetry = async (params: {
260
+ dependencies: StageRunnerDependencies;
261
+ repoRoot: string;
262
+ stage: HookStage;
263
+ scope: Parameters<typeof runPlatformGate>[0]['scope'];
264
+ }): Promise<{ exitCode: number; policyTrace: HookPolicyTrace }> => {
265
+ const firstAttempt = await runHookGateAttempt({
266
+ dependencies: params.dependencies,
267
+ stage: params.stage,
268
+ scope: params.scope,
269
+ });
270
+ if (firstAttempt.exitCode === 0) {
271
+ return firstAttempt;
272
+ }
273
+ if (!isHookPolicyAutoReconcileEnabled()) {
274
+ return firstAttempt;
275
+ }
276
+ if (
277
+ !shouldRetryAfterPolicyReconcile({
278
+ dependencies: params.dependencies,
279
+ repoRoot: params.repoRoot,
280
+ stage: params.stage,
281
+ })
282
+ ) {
283
+ return firstAttempt;
284
+ }
285
+ params.dependencies.runPolicyReconcile({
286
+ repoRoot: params.repoRoot,
287
+ strict: true,
288
+ apply: true,
289
+ });
290
+ return runHookGateAttempt({
291
+ dependencies: params.dependencies,
292
+ stage: params.stage,
293
+ scope: params.scope,
294
+ });
295
+ };
296
+
199
297
  const ZERO_HASH = /^0+$/;
200
298
 
201
299
  const toEvidenceAgeSeconds = (
@@ -367,10 +465,10 @@ export async function runPreCommitStage(
367
465
  ) {
368
466
  return 1;
369
467
  }
370
- const resolved = activeDependencies.resolvePolicyForStage('PRE_COMMIT');
371
- const exitCode = await activeDependencies.runPlatformGate({
372
- policy: resolved.policy,
373
- policyTrace: resolved.trace,
468
+ const result = await runHookGateWithPolicyRetry({
469
+ dependencies: activeDependencies,
470
+ repoRoot,
471
+ stage: 'PRE_COMMIT',
374
472
  scope: {
375
473
  kind: 'staged',
376
474
  },
@@ -378,10 +476,10 @@ export async function runPreCommitStage(
378
476
  emitSuccessfulHookGateSummary({
379
477
  dependencies: activeDependencies,
380
478
  stage: 'PRE_COMMIT',
381
- policyTrace: resolved.trace,
382
- exitCode,
479
+ policyTrace: result.policyTrace,
480
+ exitCode: result.exitCode,
383
481
  });
384
- if (exitCode !== 0) {
482
+ if (result.exitCode !== 0) {
385
483
  notifyGateBlockedForStage({
386
484
  dependencies: activeDependencies,
387
485
  stage: 'PRE_COMMIT',
@@ -391,7 +489,7 @@ export async function runPreCommitStage(
391
489
  });
392
490
  }
393
491
  notifyAuditSummaryForStage(activeDependencies, 'PRE_COMMIT');
394
- return exitCode;
492
+ return result.exitCode;
395
493
  }
396
494
 
397
495
  export async function runPrePushStage(
@@ -427,10 +525,10 @@ export async function runPrePushStage(
427
525
  ) {
428
526
  return 1;
429
527
  }
430
- const resolved = activeDependencies.resolvePolicyForStage('PRE_PUSH');
431
- const exitCode = await activeDependencies.runPlatformGate({
432
- policy: resolved.policy,
433
- policyTrace: resolved.trace,
528
+ const result = await runHookGateWithPolicyRetry({
529
+ dependencies: activeDependencies,
530
+ repoRoot,
531
+ stage: 'PRE_PUSH',
434
532
  scope: {
435
533
  kind: 'range',
436
534
  fromRef: bootstrapBaseRef,
@@ -440,18 +538,18 @@ export async function runPrePushStage(
440
538
  emitSuccessfulHookGateSummary({
441
539
  dependencies: activeDependencies,
442
540
  stage: 'PRE_PUSH',
443
- policyTrace: resolved.trace,
444
- exitCode,
541
+ policyTrace: result.policyTrace,
542
+ exitCode: result.exitCode,
445
543
  });
446
544
  notifyAuditSummaryForStage(activeDependencies, 'PRE_PUSH');
447
- return exitCode;
545
+ return result.exitCode;
448
546
  }
449
547
  if (manualInvocationFallback) {
450
548
  process.stderr.write(`${PRE_PUSH_MANUAL_FALLBACK_MESSAGE}\n`);
451
- const resolved = activeDependencies.resolvePolicyForStage('PRE_PUSH');
452
- const exitCode = await activeDependencies.runPlatformGate({
453
- policy: resolved.policy,
454
- policyTrace: resolved.trace,
549
+ const result = await runHookGateWithPolicyRetry({
550
+ dependencies: activeDependencies,
551
+ repoRoot,
552
+ stage: 'PRE_PUSH',
455
553
  scope: {
456
554
  kind: 'workingTree',
457
555
  },
@@ -459,11 +557,11 @@ export async function runPrePushStage(
459
557
  emitSuccessfulHookGateSummary({
460
558
  dependencies: activeDependencies,
461
559
  stage: 'PRE_PUSH',
462
- policyTrace: resolved.trace,
463
- exitCode,
560
+ policyTrace: result.policyTrace,
561
+ exitCode: result.exitCode,
464
562
  });
465
563
  notifyAuditSummaryForStage(activeDependencies, 'PRE_PUSH');
466
- return exitCode;
564
+ return result.exitCode;
467
565
  }
468
566
  process.stderr.write(`${PRE_PUSH_UPSTREAM_REQUIRED_MESSAGE}\n`);
469
567
  notifyGateBlockedForStage({
@@ -506,10 +604,10 @@ export async function runPrePushStage(
506
604
  return 1;
507
605
  }
508
606
 
509
- const resolved = activeDependencies.resolvePolicyForStage('PRE_PUSH');
510
- const exitCode = await activeDependencies.runPlatformGate({
511
- policy: resolved.policy,
512
- policyTrace: resolved.trace,
607
+ const result = await runHookGateWithPolicyRetry({
608
+ dependencies: activeDependencies,
609
+ repoRoot,
610
+ stage: 'PRE_PUSH',
513
611
  scope: {
514
612
  kind: 'range',
515
613
  fromRef: upstreamRef,
@@ -519,10 +617,10 @@ export async function runPrePushStage(
519
617
  emitSuccessfulHookGateSummary({
520
618
  dependencies: activeDependencies,
521
619
  stage: 'PRE_PUSH',
522
- policyTrace: resolved.trace,
523
- exitCode,
620
+ policyTrace: result.policyTrace,
621
+ exitCode: result.exitCode,
524
622
  });
525
- if (exitCode !== 0) {
623
+ if (result.exitCode !== 0) {
526
624
  notifyGateBlockedForStage({
527
625
  dependencies: activeDependencies,
528
626
  stage: 'PRE_PUSH',
@@ -532,7 +630,7 @@ export async function runPrePushStage(
532
630
  });
533
631
  }
534
632
  notifyAuditSummaryForStage(activeDependencies, 'PRE_PUSH');
535
- return exitCode;
633
+ return result.exitCode;
536
634
  }
537
635
 
538
636
  export async function runCiStage(
@@ -15,6 +15,7 @@ import {
15
15
  emitGateBlockedNotification,
16
16
  } from '../notifications/emitAuditSummaryNotification';
17
17
  import { runPolicyReconcile } from './policyReconcile';
18
+ import { resolvePumukiVersionMetadata, type PumukiVersionMetadata } from './packageInfo';
18
19
 
19
20
  export type LifecycleWatchStage = 'PRE_COMMIT' | 'PRE_PUSH' | 'CI';
20
21
  export type LifecycleWatchScope = 'workingTree' | 'staged' | 'repoAndStaged' | 'repo';
@@ -49,6 +50,14 @@ export type LifecycleWatchTickResult = {
49
50
  export type LifecycleWatchResult = {
50
51
  command: 'pumuki watch';
51
52
  repoRoot: string;
53
+ version: {
54
+ effective: string;
55
+ runtime: string;
56
+ consumerInstalled: string | null;
57
+ source: PumukiVersionMetadata['source'];
58
+ driftFromRuntime: boolean;
59
+ driftWarning: string | null;
60
+ };
52
61
  stage: LifecycleWatchStage;
53
62
  scope: LifecycleWatchScope;
54
63
  intervalMs: number;
@@ -73,6 +82,7 @@ type LifecycleWatchDependencies = {
73
82
  emitAuditSummaryNotificationFromEvidence: typeof emitAuditSummaryNotificationFromEvidence;
74
83
  emitGateBlockedNotification: typeof emitGateBlockedNotification;
75
84
  runPolicyReconcile: typeof runPolicyReconcile;
85
+ resolvePumukiVersionMetadata: (params?: { repoRoot?: string }) => PumukiVersionMetadata;
76
86
  nowMs: () => number;
77
87
  sleep: (ms: number) => Promise<void>;
78
88
  };
@@ -96,6 +106,7 @@ const defaultDependencies: LifecycleWatchDependencies = {
96
106
  emitAuditSummaryNotificationFromEvidence,
97
107
  emitGateBlockedNotification,
98
108
  runPolicyReconcile,
109
+ resolvePumukiVersionMetadata,
99
110
  nowMs: () => Date.now(),
100
111
  sleep: async (ms) => {
101
112
  await sleepTimer(ms);
@@ -231,6 +242,12 @@ export const runLifecycleWatch = async (
231
242
  ...dependencies,
232
243
  };
233
244
  const repoRoot = params?.repoRoot ?? activeDependencies.resolveRepoRoot();
245
+ const versionMetadata = activeDependencies.resolvePumukiVersionMetadata({ repoRoot });
246
+ const driftFromRuntime = versionMetadata.resolvedVersion !== versionMetadata.runtimeVersion;
247
+ const driftWarning = driftFromRuntime
248
+ ? `Version drift detectado: effective=${versionMetadata.resolvedVersion} runtime=${versionMetadata.runtimeVersion}. ` +
249
+ 'Actualiza el consumer para alinear el binario local con @latest y evitar diagnósticos inconsistentes.'
250
+ : null;
234
251
  const stage = params?.stage ?? 'PRE_COMMIT';
235
252
  const scope = params?.scope ?? 'workingTree';
236
253
  const intervalMs = Math.max(250, Math.trunc(params?.intervalMs ?? 3000));
@@ -309,6 +326,7 @@ export const runLifecycleWatch = async (
309
326
  policy: resolvedPolicy.policy,
310
327
  policyTrace: resolvedPolicy.trace,
311
328
  scope: gateScope,
329
+ silent: true,
312
330
  });
313
331
  const evidence = activeDependencies.readEvidence(repoRoot);
314
332
  const allFindings = evidence?.snapshot.findings ?? [];
@@ -449,6 +467,14 @@ export const runLifecycleWatch = async (
449
467
  return {
450
468
  command: 'pumuki watch',
451
469
  repoRoot,
470
+ version: {
471
+ effective: versionMetadata.resolvedVersion,
472
+ runtime: versionMetadata.runtimeVersion,
473
+ consumerInstalled: versionMetadata.consumerInstalledVersion,
474
+ source: versionMetadata.source,
475
+ driftFromRuntime,
476
+ driftWarning,
477
+ },
452
478
  stage,
453
479
  scope,
454
480
  intervalMs,
@@ -1,4 +1,5 @@
1
1
  import { evaluateAiGate, type AiGateStage } from '../gate/evaluateAiGate';
2
+ import { readSddLearningContext, type SddLearningContext } from '../sdd/learningInsights';
2
3
 
3
4
  const AUTO_FIX_BY_CODE: Readonly<Record<string, string>> = {
4
5
  EVIDENCE_MISSING: 'Ejecuta una auditoría para generar .ai_evidence.json.',
@@ -28,6 +29,7 @@ export type EnterpriseAiGateCheckResult = {
28
29
  violations: ReturnType<typeof evaluateAiGate>['violations'];
29
30
  warnings: ReadonlyArray<string>;
30
31
  auto_fixes: ReadonlyArray<string>;
32
+ learning_context: SddLearningContext | null;
31
33
  evidence: ReturnType<typeof evaluateAiGate>['evidence'];
32
34
  mcp_receipt: ReturnType<typeof evaluateAiGate>['mcp_receipt'];
33
35
  skills_contract: ReturnType<typeof evaluateAiGate>['skills_contract'];
@@ -99,7 +101,10 @@ const buildWarnings = (evaluation: ReturnType<typeof evaluateAiGate>): ReadonlyA
99
101
  return warnings;
100
102
  };
101
103
 
102
- const buildAutoFixes = (evaluation: ReturnType<typeof evaluateAiGate>): ReadonlyArray<string> => {
104
+ const buildAutoFixes = (
105
+ evaluation: ReturnType<typeof evaluateAiGate>,
106
+ learningContext: SddLearningContext | null
107
+ ): ReadonlyArray<string> => {
103
108
  const fixes: string[] = [];
104
109
  const emittedCodes = new Set<string>();
105
110
  for (const violation of evaluation.violations) {
@@ -113,6 +118,11 @@ const buildAutoFixes = (evaluation: ReturnType<typeof evaluateAiGate>): Readonly
113
118
  fixes.push(fix);
114
119
  emittedCodes.add(violation.code);
115
120
  }
121
+ for (const recommendation of learningContext?.recommended_actions ?? []) {
122
+ if (!fixes.includes(recommendation)) {
123
+ fixes.push(recommendation);
124
+ }
125
+ }
116
126
  return fixes;
117
127
  };
118
128
 
@@ -143,8 +153,11 @@ export const runEnterpriseAiGateCheck = (params: {
143
153
  });
144
154
  const branch = evaluation.repo_state.git.branch;
145
155
  const timestamp = evaluation.evidence.source.generated_at;
156
+ const learningContext = readSddLearningContext({
157
+ repoRoot: params.repoRoot,
158
+ });
146
159
  const warnings = buildWarnings(evaluation);
147
- const autoFixes = buildAutoFixes(evaluation);
160
+ const autoFixes = buildAutoFixes(evaluation, learningContext);
148
161
  const message = buildMessage(evaluation);
149
162
 
150
163
  return {
@@ -163,6 +176,7 @@ export const runEnterpriseAiGateCheck = (params: {
163
176
  violations: evaluation.violations,
164
177
  warnings,
165
178
  auto_fixes: autoFixes,
179
+ learning_context: learningContext,
166
180
  evidence: evaluation.evidence,
167
181
  mcp_receipt: evaluation.mcp_receipt,
168
182
  skills_contract: evaluation.skills_contract,
@@ -1,5 +1,6 @@
1
1
  import { evaluateAiGate, type AiGateStage, type AiGateViolation } from '../gate/evaluateAiGate';
2
2
  import { collectWorktreeAtomicSlices } from '../git/worktreeAtomicSlices';
3
+ import { readSddLearningContext, type SddLearningContext } from '../sdd/learningInsights';
3
4
 
4
5
  type AutoExecuteAction = 'proceed' | 'ask';
5
6
  type AutoExecutePhase = 'GREEN' | 'RED';
@@ -154,6 +155,7 @@ export type EnterpriseAutoExecuteAiStartResult = {
154
155
  confidence_pct: number;
155
156
  reason_code: string;
156
157
  next_action: AutoExecuteNextAction;
158
+ learning_context: SddLearningContext | null;
157
159
  gate: {
158
160
  stage: ReturnType<typeof evaluateAiGate>['stage'];
159
161
  status: ReturnType<typeof evaluateAiGate>['status'];
@@ -174,6 +176,9 @@ export const runEnterpriseAutoExecuteAiStart = (params: {
174
176
  stage,
175
177
  requireMcpReceipt: params.requireMcpReceipt ?? false,
176
178
  });
179
+ const learningContext = readSddLearningContext({
180
+ repoRoot: params.repoRoot,
181
+ });
177
182
  const firstViolation = evaluation.violations[0];
178
183
  const reasonCode = firstViolation?.code ?? 'READY';
179
184
  const action: AutoExecuteAction = evaluation.allowed ? 'proceed' : 'ask';
@@ -186,12 +191,16 @@ export const runEnterpriseAutoExecuteAiStart = (params: {
186
191
  }
187
192
  : nextActionFromViolation(firstViolation, params.repoRoot);
188
193
 
189
- const message = toHumanMessage({
194
+ let message = toHumanMessage({
190
195
  action,
191
196
  confidencePct,
192
197
  reasonCode,
193
198
  });
194
- const instruction = nextAction.message;
199
+ let instruction = nextAction.message;
200
+ if (learningContext?.recommended_actions[0]) {
201
+ message = `${message} Learning: ${learningContext.recommended_actions[0]}`;
202
+ instruction = `${instruction} Learning: ${learningContext.recommended_actions[0]}`;
203
+ }
195
204
  const force = action === 'ask' && confidencePct < 50;
196
205
 
197
206
  return {
@@ -210,6 +219,7 @@ export const runEnterpriseAutoExecuteAiStart = (params: {
210
219
  confidence_pct: confidencePct,
211
220
  reason_code: reasonCode,
212
221
  next_action: nextAction,
222
+ learning_context: learningContext,
213
223
  gate: {
214
224
  stage: evaluation.stage,
215
225
  status: evaluation.status,
@@ -1,5 +1,6 @@
1
1
  import { evaluateAiGate, type AiGateStage, type AiGateViolation } from '../gate/evaluateAiGate';
2
2
  import { collectWorktreeAtomicSlices } from '../git/worktreeAtomicSlices';
3
+ import { readSddLearningContext, type SddLearningContext } from '../sdd/learningInsights';
3
4
 
4
5
  const ACTIONABLE_HINTS_BY_CODE: Readonly<Record<string, string>> = {
5
6
  EVIDENCE_MISSING: 'Ejecuta una auditoría (1/2/3/4) para regenerar .ai_evidence.json.',
@@ -44,6 +45,7 @@ const buildPreFlightHints = (params: {
44
45
  status: ReturnType<typeof evaluateAiGate>['status'];
45
46
  violations: ReadonlyArray<AiGateViolation>;
46
47
  upstream: string | null;
48
+ learningContext: SddLearningContext | null;
47
49
  }): ReadonlyArray<string> => {
48
50
  const hints: string[] = [];
49
51
  const emittedCodes = new Set<string>();
@@ -89,6 +91,14 @@ const buildPreFlightHints = (params: {
89
91
  hints.push('Corrige la causa bloqueante y vuelve a ejecutar el pre-flight.');
90
92
  }
91
93
  }
94
+ if (params.learningContext) {
95
+ hints.push(
96
+ `LEARNING_CONTEXT: change=${params.learningContext.change} file=${params.learningContext.path}`
97
+ );
98
+ if (params.learningContext.recommended_actions[0]) {
99
+ hints.push(`LEARNING_NEXT_ACTION: ${params.learningContext.recommended_actions[0]}`);
100
+ }
101
+ }
92
102
  return hints;
93
103
  };
94
104
 
@@ -111,6 +121,7 @@ export type EnterprisePreFlightCheckResult = {
111
121
  skills_contract: ReturnType<typeof evaluateAiGate>['skills_contract'];
112
122
  repo_state: ReturnType<typeof evaluateAiGate>['repo_state'];
113
123
  hints: ReadonlyArray<string>;
124
+ learning_context: SddLearningContext | null;
114
125
  ast_analysis: null;
115
126
  tdd_status: null;
116
127
  };
@@ -126,6 +137,9 @@ export const runEnterprisePreFlightCheck = (params: {
126
137
  stage: params.stage,
127
138
  requireMcpReceipt: params.requireMcpReceipt ?? false,
128
139
  });
140
+ const learningContext = readSddLearningContext({
141
+ repoRoot: params.repoRoot,
142
+ });
129
143
 
130
144
  const hints = buildPreFlightHints({
131
145
  repoRoot: params.repoRoot,
@@ -133,6 +147,7 @@ export const runEnterprisePreFlightCheck = (params: {
133
147
  status: evaluation.status,
134
148
  violations: evaluation.violations,
135
149
  upstream: evaluation.repo_state.git.upstream,
150
+ learningContext,
136
151
  });
137
152
  const phase: 'GREEN' | 'RED' = evaluation.allowed ? 'GREEN' : 'RED';
138
153
  const message = evaluation.allowed
@@ -161,6 +176,7 @@ export const runEnterprisePreFlightCheck = (params: {
161
176
  skills_contract: evaluation.skills_contract,
162
177
  repo_state: evaluation.repo_state,
163
178
  hints,
179
+ learning_context: learningContext,
164
180
  ast_analysis: null,
165
181
  tdd_status: null,
166
182
  },
@@ -0,0 +1,91 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { readSddSession } from './sessionStore';
4
+
5
+ type LearningArtifact = {
6
+ generated_at?: unknown;
7
+ failed_patterns?: unknown;
8
+ successful_patterns?: unknown;
9
+ rule_updates?: unknown;
10
+ gate_anomalies?: unknown;
11
+ };
12
+
13
+ export type SddLearningContext = {
14
+ change: string;
15
+ path: string;
16
+ generated_at: string | null;
17
+ failed_patterns: string[];
18
+ successful_patterns: string[];
19
+ rule_updates: string[];
20
+ gate_anomalies: string[];
21
+ recommended_actions: string[];
22
+ };
23
+
24
+ const toStringArray = (value: unknown): string[] => {
25
+ if (!Array.isArray(value)) {
26
+ return [];
27
+ }
28
+ return value.filter((item): item is string => typeof item === 'string');
29
+ };
30
+
31
+ const toRecommendedActions = (ruleUpdates: string[]): string[] => {
32
+ const actions: string[] = [];
33
+ for (const rule of ruleUpdates) {
34
+ if (rule === 'evidence.bootstrap.required' || rule === 'evidence.rebuild.required') {
35
+ actions.push(
36
+ 'Regenera evidencia y vuelve a validar PRE_WRITE para estabilizar el gate.'
37
+ );
38
+ continue;
39
+ }
40
+ if (rule.startsWith('ai-gate.violation.')) {
41
+ actions.push('Corrige la violación de gate indicada y reejecuta validate/hook.');
42
+ continue;
43
+ }
44
+ if (rule.startsWith('sdd.')) {
45
+ actions.push('Completa el contrato SDD del cambio activo antes de cerrar stage.');
46
+ continue;
47
+ }
48
+ }
49
+ return [...new Set(actions)];
50
+ };
51
+
52
+ export const readSddLearningContext = (params: {
53
+ repoRoot: string;
54
+ change?: string | null;
55
+ }): SddLearningContext | null => {
56
+ const repoRoot = resolve(params.repoRoot);
57
+ const explicitChange = params.change?.trim().toLowerCase() ?? null;
58
+ const session = readSddSession(repoRoot);
59
+ const change = explicitChange ?? session.changeId ?? null;
60
+ if (!change) {
61
+ return null;
62
+ }
63
+
64
+ const relativePath = `openspec/changes/${change}/learning.json`;
65
+ const absolutePath = resolve(repoRoot, relativePath);
66
+ if (!existsSync(absolutePath)) {
67
+ return null;
68
+ }
69
+
70
+ let parsed: LearningArtifact;
71
+ try {
72
+ parsed = JSON.parse(readFileSync(absolutePath, 'utf8')) as LearningArtifact;
73
+ } catch {
74
+ return null;
75
+ }
76
+
77
+ const ruleUpdates = toStringArray(parsed.rule_updates);
78
+ return {
79
+ change,
80
+ path: relativePath,
81
+ generated_at:
82
+ typeof parsed.generated_at === 'string' && parsed.generated_at.trim().length > 0
83
+ ? parsed.generated_at
84
+ : null,
85
+ failed_patterns: toStringArray(parsed.failed_patterns),
86
+ successful_patterns: toStringArray(parsed.successful_patterns),
87
+ rule_updates: ruleUpdates,
88
+ gate_anomalies: toStringArray(parsed.gate_anomalies),
89
+ recommended_actions: toRecommendedActions(ruleUpdates),
90
+ };
91
+ };