import { createHash } from 'node:crypto'; import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { setTimeout as sleepTimer } from 'node:timers/promises'; import { isSeverityAtLeast, type Severity } from '../../core/rules/Severity'; import type { GateScope } from '../git/runPlatformGateFacts'; import { runPlatformGate } from '../git/runPlatformGate'; import { resolveFactsForGateScope } from '../git/runPlatformGateFacts'; import { resolvePolicyForStage, type ResolvedStagePolicy } from '../gate/stagePolicies'; import { readEvidence } from '../evidence/readEvidence'; import type { AiEvidenceV2_1, SnapshotFinding } from '../evidence/schema'; import { GitService, type IGitService } from '../git/GitService'; import type { Fact } from '../../core/facts/Fact'; import { evaluateGitAtomicity, type GitAtomicityEvaluation, type GitAtomicityViolation, } from '../git/gitAtomicity'; import { resolveCiBaseRef, resolvePrePushBootstrapBaseRef, resolveUpstreamRef, } from '../git/resolveGitRefs'; import { ensureRuntimeArtifactsIgnored } from './artifacts'; import { emitAuditSummaryNotificationFromEvidence, emitGateBlockedNotification, } from '../notifications/emitAuditSummaryNotification'; import { runPolicyReconcile } from './policyReconcile'; import { buildLifecycleAlignmentCommand, resolvePumukiVersionMetadata, type PumukiVersionMetadata, } from './packageInfo'; import { resolveGitAtomicityEnforcement, type GitAtomicityEnforcementResolution, } from '../policy/gitAtomicityEnforcement'; export type LifecycleWatchStage = 'PRE_COMMIT' | 'PRE_PUSH' | 'CI'; export type LifecycleWatchScope = 'workingTree' | 'staged' | 'repoAndStaged' | 'repo'; export type LifecycleWatchSeverityThreshold = 'critical' | 'high' | 'medium' | 'low'; export type LifecycleWatchTickResult = { tick: number; changed: boolean; evaluated: boolean; stage: LifecycleWatchStage; scope: LifecycleWatchScope; gateExitCode: number | null; gateOutcome: 'BLOCK' | 'WARN' | 'ALLOW' | 'NO_EVIDENCE'; threshold: LifecycleWatchSeverityThreshold; thresholdSeverity: Severity; totalFindings: number; findingsAtOrAboveThreshold: number; topCodes: ReadonlyArray; changedFiles: ReadonlyArray; evaluatedFiles: ReadonlyArray; notification: | 'sent' | 'disabled' | 'below-threshold' | 'suppressed-cooldown' | 'suppressed-duplicate' | 'not-delivered' | 'not-evaluated'; notificationDeliveryReason?: string; }; export type LifecycleWatchResult = { command: 'pumuki watch'; repoRoot: string; version: { effective: string; runtime: string; consumerInstalled: string | null; source: PumukiVersionMetadata['source']; driftFromRuntime: boolean; driftWarning: string | null; alignmentCommand: string | null; }; stage: LifecycleWatchStage; scope: LifecycleWatchScope; intervalMs: number; notifyCooldownMs: number; severityThreshold: LifecycleWatchSeverityThreshold; notifyEnabled: boolean; ticks: number; evaluations: number; notificationsSent: number; notificationsSuppressed: number; lastTick: LifecycleWatchTickResult; }; type LifecycleWatchDependencies = { resolveRepoRoot: () => string; readChangeToken: (repoRoot: string) => string; resolvePolicyForStage: (stage: LifecycleWatchStage) => ResolvedStagePolicy; resolveUpstreamRef: typeof resolveUpstreamRef; resolvePrePushBootstrapBaseRef: typeof resolvePrePushBootstrapBaseRef; resolveCiBaseRef: typeof resolveCiBaseRef; evaluateGitAtomicity: (params: { repoRoot: string; stage: LifecycleWatchStage; fromRef?: string; toRef?: string; }) => GitAtomicityEvaluation; runPlatformGate: typeof runPlatformGate; resolveFactsForGateScope: typeof resolveFactsForGateScope; readEvidence: (repoRoot: string) => AiEvidenceV2_1 | undefined; ensureRuntimeArtifactsIgnored: (repoRoot: string) => void; emitAuditSummaryNotificationFromEvidence: typeof emitAuditSummaryNotificationFromEvidence; emitGateBlockedNotification: typeof emitGateBlockedNotification; runPolicyReconcile: typeof runPolicyReconcile; resolvePumukiVersionMetadata: (params?: { repoRoot?: string }) => PumukiVersionMetadata; resolveGitAtomicityEnforcement: () => GitAtomicityEnforcementResolution; nowMs: () => number; sleep: (ms: number) => Promise; }; const defaultGitService = new GitService(); class RepoScopedGitService extends GitService implements IGitService { constructor(private readonly repoRoot: string) { super(); } override runGit(args: ReadonlyArray, cwd?: string): string { return super.runGit(args, cwd ?? this.repoRoot); } override resolveRepoRoot(): string { return this.repoRoot; } } const defaultDependencies: LifecycleWatchDependencies = { resolveRepoRoot: () => defaultGitService.resolveRepoRoot(), readChangeToken: (repoRoot) => defaultGitService.runGit(['status', '--porcelain=v1', '--untracked-files=all'], repoRoot), resolvePolicyForStage: (stage) => resolvePolicyForStage(stage), resolveUpstreamRef, resolvePrePushBootstrapBaseRef, resolveCiBaseRef, evaluateGitAtomicity: (params) => evaluateGitAtomicity({ repoRoot: params.repoRoot, stage: params.stage, fromRef: params.fromRef, toRef: params.toRef, }), runPlatformGate, resolveFactsForGateScope, readEvidence, ensureRuntimeArtifactsIgnored: (repoRoot) => { try { ensureRuntimeArtifactsIgnored(repoRoot); } catch (error) { void error; } }, emitAuditSummaryNotificationFromEvidence, emitGateBlockedNotification, runPolicyReconcile, resolvePumukiVersionMetadata, resolveGitAtomicityEnforcement, nowMs: () => Date.now(), sleep: async (ms) => { await sleepTimer(ms); }, }; const BLOCKED_REMEDIATION_BY_CODE: Readonly> = { EVIDENCE_MISSING: 'Genera evidencia y vuelve a ejecutar el gate.', EVIDENCE_INVALID: 'Regenera la evidencia antes de reintentar.', EVIDENCE_CHAIN_INVALID: 'Regenera evidencia para reparar la cadena criptográfica.', EVIDENCE_STALE: 'Refresca la evidencia y vuelve a intentarlo.', EVIDENCE_BRANCH_MISMATCH: 'Regenera evidencia en esta rama y reintenta.', EVIDENCE_REPO_ROOT_MISMATCH: 'Regenera evidencia desde este repositorio.', PRE_PUSH_UPSTREAM_MISSING: 'Ejecuta: git push --set-upstream origin .', SDD_SESSION_MISSING: 'Abre sesión SDD y vuelve a intentar.', SDD_SESSION_INVALID: 'Refresca la sesión SDD y vuelve a intentar.', OPENSPEC_MISSING: 'Instala OpenSpec y reintenta la validación.', MCP_ENTERPRISE_RECEIPT_MISSING: 'Genera el receipt enterprise de MCP antes de continuar.', MANIFEST_MUTATION_DETECTED: 'Validación no debe mutar package.json/lockfiles. Revisa wiring y realiza upgrades solo con comando explícito.', }; const WATCH_POLICY_RECONCILE_CODES = new Set([ 'SKILLS_PLATFORM_COVERAGE_INCOMPLETE_HIGH', 'SKILLS_SCOPE_COMPLIANCE_INCOMPLETE_HIGH', 'EVIDENCE_PLATFORM_SKILLS_SCOPE_INCOMPLETE', 'EVIDENCE_PLATFORM_SKILLS_BUNDLES_MISSING', 'EVIDENCE_CROSS_PLATFORM_CRITICAL_ENFORCEMENT_INCOMPLETE', ]); const THRESHOLD_TO_SEVERITY: Record = { critical: 'CRITICAL', high: 'ERROR', medium: 'WARN', low: 'INFO', }; const WATCH_MANIFEST_GUARD_FILES = [ 'package.json', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', ] as const; type WatchManifestGuardEntry = { relativePath: (typeof WATCH_MANIFEST_GUARD_FILES)[number]; absolutePath: string; existed: boolean; contents: string; }; const captureWatchManifestGuardSnapshot = ( repoRoot: string ): Array => WATCH_MANIFEST_GUARD_FILES.map((relativePath) => { const absolutePath = join(repoRoot, relativePath); const existed = existsSync(absolutePath); return { relativePath, absolutePath, existed, contents: existed ? readFileSync(absolutePath, 'utf8') : '', }; }); const restoreWatchManifestGuardSnapshot = ( snapshot: ReadonlyArray ): Array => { const mutated: Array = []; for (const entry of snapshot) { const existsNow = existsSync(entry.absolutePath); if (entry.existed) { if (!existsNow) { writeFileSync(entry.absolutePath, entry.contents, 'utf8'); mutated.push(entry.relativePath); continue; } const current = readFileSync(entry.absolutePath, 'utf8'); if (current !== entry.contents) { writeFileSync(entry.absolutePath, entry.contents, 'utf8'); mutated.push(entry.relativePath); } continue; } if (existsNow) { unlinkSync(entry.absolutePath); mutated.push(entry.relativePath); } } return Array.from(new Set(mutated)); }; const toGateScope = (scope: LifecycleWatchScope): GateScope => { if (scope === 'staged') { return { kind: 'staged' }; } if (scope === 'repoAndStaged') { return { kind: 'repoAndStaged' }; } if (scope === 'repo') { return { kind: 'repo' }; } return { kind: 'workingTree' }; }; const toNotificationSignature = (params: { stage: LifecycleWatchStage; gateOutcome: LifecycleWatchTickResult['gateOutcome']; topCodes: ReadonlyArray; matchedFindings: number; }): string => createHash('sha1') .update( `${params.stage}|${params.gateOutcome}|${params.topCodes.join(',')}|${params.matchedFindings}`, 'utf8' ) .digest('hex'); const toTopCodes = (findings: ReadonlyArray): ReadonlyArray => { const seen = new Set(); const codes: string[] = []; for (const finding of findings) { if (seen.has(finding.code)) { continue; } seen.add(finding.code); codes.push(finding.code); if (codes.length >= 3) { break; } } return codes; }; const toSortedUniquePaths = (paths: ReadonlyArray): ReadonlyArray => [...new Set(paths.map((path) => path.trim()).filter((path) => path.length > 0))].sort(); const collectChangedFilesFromFacts = (facts: ReadonlyArray): ReadonlyArray => toSortedUniquePaths( facts.filter((fact) => fact.kind === 'FileChange').map((fact) => fact.path) ); const collectEvaluatedFilesFromFacts = (facts: ReadonlyArray): ReadonlyArray => { const fromContent = toSortedUniquePaths( facts.filter((fact) => fact.kind === 'FileContent').map((fact) => fact.path) ); if (fromContent.length > 0) { return fromContent; } return collectChangedFilesFromFacts(facts); }; const resolveWatchAtomicityRange = (params: { stage: LifecycleWatchStage; scope: LifecycleWatchScope; dependencies: LifecycleWatchDependencies; }): { fromRef?: string; toRef?: string } => { if (params.stage === 'PRE_COMMIT') { return {}; } if (params.stage === 'CI') { return { fromRef: params.dependencies.resolveCiBaseRef(), toRef: 'HEAD', }; } if (params.scope === 'workingTree') { return {}; } const upstreamRef = params.dependencies.resolveUpstreamRef(); if (typeof upstreamRef === 'string' && upstreamRef.trim().length > 0) { return { fromRef: upstreamRef, toRef: 'HEAD', }; } const bootstrapBaseRef = params.dependencies.resolvePrePushBootstrapBaseRef(); if (bootstrapBaseRef !== 'HEAD') { return { fromRef: bootstrapBaseRef, toRef: 'HEAD', }; } return {}; }; const toAtomicitySnapshotFindings = ( violations: ReadonlyArray, enforcement: GitAtomicityEnforcementResolution ): ReadonlyArray => violations.map((violation) => ({ ruleId: 'governance.git.atomicity', severity: enforcement.blocking ? 'ERROR' : 'WARN', code: violation.code, message: violation.message, file: '.pumuki/git-atomicity.json', matchedBy: 'LifecycleWatch', source: 'skills.backend.runtime-hygiene', blocking: enforcement.blocking, expected_fix: violation.remediation, })); const toFirstCause = (params: { evidence: AiEvidenceV2_1 | undefined; matchedFindings: ReadonlyArray; }): { code: string; message: string; remediation: string } => { const firstFinding = params.matchedFindings[0]; const firstViolation = params.evidence?.ai_gate.violations[0]; const code = firstFinding?.code ?? firstViolation?.code ?? 'WATCH_GATE_BLOCKED'; const message = firstFinding?.message ?? firstViolation?.message ?? `Watch gate bloqueado (${code}).`; const remediation = BLOCKED_REMEDIATION_BY_CODE[code] ?? 'Corrige el bloqueo indicado y vuelve a evaluar.'; return { code, message, remediation, }; }; export const runLifecycleWatch = async ( params?: { repoRoot?: string; stage?: LifecycleWatchStage; scope?: LifecycleWatchScope; intervalMs?: number; notifyCooldownMs?: number; severityThreshold?: LifecycleWatchSeverityThreshold; notifyEnabled?: boolean; maxIterations?: number; onTick?: (tick: LifecycleWatchTickResult) => void; }, dependencies: Partial = {} ): Promise => { const activeDependencies: LifecycleWatchDependencies = { ...defaultDependencies, ...dependencies, }; const repoRoot = params?.repoRoot ?? activeDependencies.resolveRepoRoot(); const repoGitService = new RepoScopedGitService(repoRoot); const versionMetadata = activeDependencies.resolvePumukiVersionMetadata({ repoRoot }); const driftFromRuntime = versionMetadata.resolvedVersion !== versionMetadata.runtimeVersion; const driftWarning = driftFromRuntime ? `Version drift detectado: effective=${versionMetadata.resolvedVersion} runtime=${versionMetadata.runtimeVersion}. ` + 'Actualiza el consumer para alinear el binario local con @latest y evitar diagnósticos inconsistentes.' : null; const stage = params?.stage ?? 'PRE_COMMIT'; const scope = params?.scope ?? 'workingTree'; const intervalMs = Math.max(250, Math.trunc(params?.intervalMs ?? 3000)); const notifyCooldownMs = Math.max(0, Math.trunc(params?.notifyCooldownMs ?? 30_000)); const severityThreshold = params?.severityThreshold ?? 'high'; const thresholdSeverity = THRESHOLD_TO_SEVERITY[severityThreshold]; const notifyEnabled = params?.notifyEnabled !== false; const autoPolicyReconcileEnabled = process.env.PUMUKI_WATCH_POLICY_AUTO_RECONCILE !== '0'; activeDependencies.ensureRuntimeArtifactsIgnored(repoRoot); const maxIterations = typeof params?.maxIterations === 'number' && params.maxIterations > 0 ? Math.trunc(params.maxIterations) : undefined; let ticks = 0; let evaluations = 0; let notificationsSent = 0; let notificationsSuppressed = 0; let previousChangeToken: string | undefined; let lastNotificationSignature: string | undefined; let lastNotificationAtMs = 0; let lastTick: LifecycleWatchTickResult = { tick: 0, changed: false, evaluated: false, stage, scope, gateExitCode: null, gateOutcome: 'NO_EVIDENCE', threshold: severityThreshold, thresholdSeverity, totalFindings: 0, findingsAtOrAboveThreshold: 0, topCodes: [], changedFiles: [], evaluatedFiles: [], notification: 'not-evaluated', }; while (true) { ticks += 1; const changeToken = activeDependencies.readChangeToken(repoRoot); const changed = previousChangeToken !== changeToken; const shouldEvaluate = ticks === 1 || changed; if (!shouldEvaluate) { lastTick = { ...lastTick, tick: ticks, changed, evaluated: false, changedFiles: [], evaluatedFiles: [], notification: 'not-evaluated', }; params?.onTick?.(lastTick); } else { evaluations += 1; const gateScope = toGateScope(scope); const facts = await activeDependencies.resolveFactsForGateScope({ scope: gateScope, git: repoGitService, }); const changedFiles = collectChangedFilesFromFacts(facts); const evaluatedFiles = collectEvaluatedFilesFromFacts(facts); const scopeHasFileDelta = changedFiles.length > 0 || evaluatedFiles.length > 0; const runEvaluation = async ( resolvedPolicy: ResolvedStagePolicy ): Promise<{ gateExitCode: number; evidence: AiEvidenceV2_1 | undefined; allFindings: ReadonlyArray; matchedFindings: ReadonlyArray; gateOutcome: 'BLOCK' | 'WARN' | 'ALLOW' | 'NO_EVIDENCE'; topCodes: ReadonlyArray; }> => { const atomicityRange = resolveWatchAtomicityRange({ stage, scope, dependencies: activeDependencies, }); const atomicity = activeDependencies.evaluateGitAtomicity({ repoRoot, stage, fromRef: atomicityRange.fromRef, toRef: atomicityRange.toRef, }); const atomicityEnforcement = activeDependencies.resolveGitAtomicityEnforcement(); const atomicityFindings = atomicity.enabled && !atomicity.allowed ? toAtomicitySnapshotFindings(atomicity.violations, atomicityEnforcement) : []; if (atomicityFindings.length > 0 && atomicityEnforcement.blocking) { const allFindings = atomicityFindings; const matchedFindings = allFindings.filter((finding) => isSeverityAtLeast(finding.severity, thresholdSeverity) ); return { gateExitCode: 1, evidence: undefined, allFindings, matchedFindings, gateOutcome: 'BLOCK', topCodes: toTopCodes(matchedFindings), }; } const manifestSnapshot = captureWatchManifestGuardSnapshot(repoRoot); let gateExitCode = await activeDependencies.runPlatformGate({ policy: resolvedPolicy.policy, policyTrace: resolvedPolicy.trace, scope: gateScope, silent: true, services: { git: repoGitService, }, }); const evidence = activeDependencies.readEvidence(repoRoot); const allFindingsBase = evidence?.snapshot.findings ?? []; const matchedFindingsBase = allFindingsBase.filter((finding) => isSeverityAtLeast(finding.severity, thresholdSeverity) ); const manifestMutations = restoreWatchManifestGuardSnapshot(manifestSnapshot); const manifestMutationFinding: SnapshotFinding | null = manifestMutations.length > 0 ? { ruleId: 'governance.manifest.no-silent-mutation', severity: 'ERROR', code: 'MANIFEST_MUTATION_DETECTED', message: `Unexpected manifest mutation detected during watch and reverted: ${manifestMutations.join(', ')}.`, file: manifestMutations[0] ?? 'package.json', matchedBy: 'LifecycleWatch', source: 'skills.backend.runtime-hygiene', } : null; const allFindingsWithAtomicity = [...allFindingsBase, ...atomicityFindings]; const matchedFindingsWithAtomicity = allFindingsWithAtomicity.filter((finding) => isSeverityAtLeast(finding.severity, thresholdSeverity) ); const allFindings = manifestMutationFinding ? [...allFindingsWithAtomicity, manifestMutationFinding] : allFindingsWithAtomicity; const matchedFindings = manifestMutationFinding ? [...matchedFindingsWithAtomicity, manifestMutationFinding] : matchedFindingsWithAtomicity; const rawGateOutcome = evidence?.snapshot.outcome ?? (gateExitCode !== 0 ? 'BLOCK' : 'NO_EVIDENCE'); const normalizedGateOutcome = rawGateOutcome === 'PASS' ? 'ALLOW' : rawGateOutcome; const gateOutcome = manifestMutationFinding ? 'BLOCK' : normalizedGateOutcome === 'ALLOW' && atomicityFindings.length > 0 ? 'WARN' : normalizedGateOutcome; if (manifestMutationFinding) { gateExitCode = 1; process.stderr.write( `[pumuki][watch-manifest-guard] unexpected manifest mutation detected and reverted: ${manifestMutations.join(', ')}\n` ); } const topCodes = toTopCodes(matchedFindings); return { gateExitCode, evidence, allFindings, matchedFindings, gateOutcome, topCodes, }; }; let evaluation = await runEvaluation(activeDependencies.resolvePolicyForStage(stage)); if (autoPolicyReconcileEnabled && evaluation.gateExitCode !== 0) { const toEvidenceViolationSeverity = (violation: { severity?: string | null; level?: string | null; }): Severity => { const candidate = typeof violation.severity === 'string' ? violation.severity : typeof violation.level === 'string' ? violation.level : null; if ( candidate === 'INFO' || candidate === 'WARN' || candidate === 'ERROR' || candidate === 'CRITICAL' ) { return candidate; } return 'INFO'; }; const findingCodes = new Set([ ...evaluation.allFindings .filter((finding) => isSeverityAtLeast(finding.severity, 'ERROR')) .map((finding) => finding.code), ...((evaluation.evidence?.ai_gate.violations ?? []) .filter((violation) => isSeverityAtLeast(toEvidenceViolationSeverity(violation), 'ERROR')) .map((violation) => violation.code)), ]); const shouldAttemptAutoReconcile = [...findingCodes].some((code) => WATCH_POLICY_RECONCILE_CODES.has(code) ); if (shouldAttemptAutoReconcile) { const reconcile = activeDependencies.runPolicyReconcile({ repoRoot, strict: true, apply: true, }); if (reconcile.autofix.status === 'APPLIED') { evaluation = await runEvaluation(activeDependencies.resolvePolicyForStage(stage)); } } } const { gateExitCode, evidence, allFindings, matchedFindings, gateOutcome, topCodes, } = evaluation; const signature = toNotificationSignature({ stage, gateOutcome, topCodes, matchedFindings: matchedFindings.length, }); let notification: LifecycleWatchTickResult['notification'] = 'below-threshold'; let notificationDeliveryReason: string | undefined; if (!notifyEnabled) { notification = 'disabled'; } else if (matchedFindings.length === 0) { notification = 'below-threshold'; } else { const nowMs = activeDependencies.nowMs(); const elapsed = nowMs - lastNotificationAtMs; if ( notifyCooldownMs > 0 && typeof lastNotificationSignature === 'string' && elapsed < notifyCooldownMs ) { notification = signature === lastNotificationSignature ? 'suppressed-duplicate' : 'suppressed-cooldown'; notificationsSuppressed += 1; } else { let notificationResult; if (gateExitCode !== 0 || evidence?.ai_gate.status === 'BLOCKED') { const cause = toFirstCause({ evidence, matchedFindings, }); notificationResult = activeDependencies.emitGateBlockedNotification({ repoRoot, stage, totalViolations: matchedFindings.length, causeCode: cause.code, causeMessage: cause.message, remediation: cause.remediation, blockingCauses: matchedFindings.map((finding) => ({ code: finding.code ?? finding.ruleId, message: finding.message, ruleId: finding.ruleId, file: finding.file, remediation: finding.expected_fix, })), }); } else { notificationResult = activeDependencies.emitAuditSummaryNotificationFromEvidence({ repoRoot, stage, }); } if (notificationResult.delivered) { notification = 'sent'; notificationsSent += 1; } else { notification = 'not-delivered'; notificationDeliveryReason = notificationResult.reason; notificationsSuppressed += 1; } lastNotificationAtMs = nowMs; lastNotificationSignature = signature; } } lastTick = { tick: ticks, changed: scopeHasFileDelta, evaluated: true, stage, scope, gateExitCode, gateOutcome, threshold: severityThreshold, thresholdSeverity, totalFindings: allFindings.length, findingsAtOrAboveThreshold: matchedFindings.length, topCodes, changedFiles, evaluatedFiles, notification, ...(notificationDeliveryReason ? { notificationDeliveryReason } : {}), }; params?.onTick?.(lastTick); } previousChangeToken = changeToken; if (maxIterations && ticks >= maxIterations) { return { command: 'pumuki watch', repoRoot, version: { effective: versionMetadata.resolvedVersion, runtime: versionMetadata.runtimeVersion, consumerInstalled: versionMetadata.consumerInstalledVersion, source: versionMetadata.source, driftFromRuntime, driftWarning, alignmentCommand: driftFromRuntime ? buildLifecycleAlignmentCommand(versionMetadata.runtimeVersion) : null, }, stage, scope, intervalMs, notifyCooldownMs, severityThreshold, notifyEnabled, ticks, evaluations, notificationsSent, notificationsSuppressed, lastTick, }; } await activeDependencies.sleep(intervalMs); } };