pumuki 6.3.69 → 6.3.71
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.
- package/README.md +1 -1
- package/docs/operations/RELEASE_NOTES.md +14 -0
- package/docs/product/CONFIGURATION.md +16 -0
- package/docs/product/USAGE.md +12 -1
- package/integrations/evidence/buildEvidence.ts +15 -0
- package/integrations/evidence/generateEvidence.test.ts +24 -1
- package/integrations/evidence/generateEvidence.ts +18 -1
- package/integrations/evidence/operationalHints.ts +110 -0
- package/integrations/evidence/schema.ts +16 -0
- package/integrations/evidence/writeEvidence.ts +4 -0
- package/integrations/gate/remediationCatalog.ts +40 -0
- package/integrations/git/filterFactsByPathPrefixes.ts +61 -0
- package/integrations/git/runPlatformGate.ts +12 -4
- package/integrations/git/runPlatformGateEvidence.ts +45 -0
- package/integrations/git/stageRunners.ts +82 -28
- package/integrations/lifecycle/cli.ts +32 -3
- package/integrations/lifecycle/doctor.ts +112 -0
- package/integrations/mcp/aiGateCheck.ts +2 -11
- package/package.json +1 -1
- package/scripts/framework-menu-system-notifications-macos-swift-source.ts +6 -1
- package/scripts/framework-menu-system-notifications-macos.ts +28 -4
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ npx --yes pumuki status
|
|
|
37
37
|
npx --yes pumuki doctor --json
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
Desde **6.3.63**, `npm install` en la raíz de un repo **Git** dispara un `postinstall` que ejecuta **`pumuki install` solo** (hooks `pre-commit` / `pre-push`, lifecycle, evidencia cuando aplica). **Pumuki no depende de ningún IDE** para el baseline: no toca `.cursor/` ni otros ficheros de editor por defecto. Desde **6.3.68**, cada hook gestionado ejecuta **`pumuki-pre-write` antes** de `pumuki-pre-commit` / `pumuki-pre-push` (stage **PRE_WRITE** vía Git). Saltar solo PRE_WRITE en hooks: `PUMUKI_SKIP_CHAINED_PRE_WRITE=1`. Desde **6.3.69**, esos mismos hooks aplican también **git-flow en ramas protegidas** (`GITFLOW_PROTECTED_BRANCH`) e **higiene de worktree** (`PUMUKI_PREWRITE_WORKTREE_*` / códigos `EVIDENCE_PREWRITE_WORKTREE_*`) cuando la evidencia es válida; el **modal macOS** de bloqueo (Desactivar / Silenciar 30 min / Mantener activas) queda **activo por defecto** si las notificaciones están habilitadas (`"blockedDialogEnabled": false` o `PUMUKI_MACOS_BLOCKED_DIALOG=0` para apagarlo). Tras install, si no existía, aparece **`.pumuki/adapter.json`** con los comandos de hooks y **MCP stdio** para referencia o para clientes que los registren manualmente. Para generar también config de IDE: **`pumuki install --with-mcp --agent=cursor`** (u otro) o **`pumuki bootstrap --enterprise --agent=…`**. Desactivar el postinstall: `PUMUKI_SKIP_POSTINSTALL=1`. En CI suele saltarse solo (`CI=true`). En **6.3.64+**, las notificaciones del sistema en plataformas sin banner nativo se reflejan en **stderr** por defecto (`PUMUKI_DISABLE_STDERR_NOTIFICATIONS=1` para silenciarlas). En **6.3.69+**, un `gate.blocked` en macOS también deja una copia en **stderr** por defecto (`PUMUKI_DISABLE_GATE_BLOCKED_STDERR_MIRROR=1` para desactivar solo eso).
|
|
40
|
+
Desde **6.3.63**, `npm install` en la raíz de un repo **Git** dispara un `postinstall` que ejecuta **`pumuki install` solo** (hooks `pre-commit` / `pre-push`, lifecycle, evidencia cuando aplica). **Pumuki no depende de ningún IDE** para el baseline: no toca `.cursor/` ni otros ficheros de editor por defecto. Desde **6.3.68**, cada hook gestionado ejecuta **`pumuki-pre-write` antes** de `pumuki-pre-commit` / `pumuki-pre-push` (stage **PRE_WRITE** vía Git). Saltar solo PRE_WRITE en hooks: `PUMUKI_SKIP_CHAINED_PRE_WRITE=1`. Desde **6.3.69**, esos mismos hooks aplican también **git-flow en ramas protegidas** (`GITFLOW_PROTECTED_BRANCH`) e **higiene de worktree** (`PUMUKI_PREWRITE_WORKTREE_*` / códigos `EVIDENCE_PREWRITE_WORKTREE_*`) cuando la evidencia es válida; el **modal macOS** de bloqueo (Desactivar / Silenciar 30 min / Mantener activas) queda **activo por defecto** si las notificaciones están habilitadas (`"blockedDialogEnabled": false` o `PUMUKI_MACOS_BLOCKED_DIALOG=0` para apagarlo). Tras install, si no existía, aparece **`.pumuki/adapter.json`** con los comandos de hooks y **MCP stdio** para referencia o para clientes que los registren manualmente. Para generar también config de IDE: **`pumuki install --with-mcp --agent=cursor`** (u otro) o **`pumuki bootstrap --enterprise --agent=…`**. Desactivar el postinstall: `PUMUKI_SKIP_POSTINSTALL=1`. En CI suele saltarse solo (`CI=true`). En **6.3.64+**, las notificaciones del sistema en plataformas sin banner nativo se reflejan en **stderr** por defecto (`PUMUKI_DISABLE_STDERR_NOTIFICATIONS=1` para silenciarlas). En **6.3.69+**, un `gate.blocked` en macOS también deja una copia en **stderr** por defecto (`PUMUKI_DISABLE_GATE_BLOCKED_STDERR_MIRROR=1` para desactivar solo eso). Desde **6.3.70**, si **`.ai_evidence.json` está versionado** y **PRE_PUSH** no bloquea, ese archivo **no se reescribe** en el push (compatibilidad con **pre-commit** como hook de **pre-push**); para forzar escritura: `PUMUKI_PRE_PUSH_ALWAYS_WRITE_TRACKED_EVIDENCE=1`. Con modal de bloqueo activo, el panel interactivo prioriza foco/clics y se evita el banner duplicado.
|
|
41
41
|
|
|
42
42
|
Fallback (equivalent in pasos separados):
|
|
43
43
|
|
|
@@ -6,6 +6,20 @@ This file keeps only the operational highlights and rollout notes that matter wh
|
|
|
6
6
|
|
|
7
7
|
## 2026-04 (CLI stability and macOS notifications)
|
|
8
8
|
|
|
9
|
+
### 2026-04-06 (v6.3.71)
|
|
10
|
+
|
|
11
|
+
- **Evidencia v2.1**: bloque `operational_hints` (`requires_second_pass`, resumen operativo, desglose por severidad de reglas). Alineado con PRE_COMMIT solo-docs + evidencia trackeada (INC-069) cuando no se re-stagea el JSON automáticamente.
|
|
12
|
+
- **Monorepo**: `PUMUKI_GATE_SCOPE_PATH_PREFIXES` acota el primer alcance de hechos por prefijos de ruta.
|
|
13
|
+
- **Paridad CI/local**: `pumuki doctor --parity` y fichero opcional `.pumuki/ci-parity-expected.json` (fallo con exit 1 si hay drift respecto al esperado).
|
|
14
|
+
- **MCP y hooks**: mismas pistas de remediación por código de violación vía catálogo compartido.
|
|
15
|
+
- **Rollout**: `pumuki@6.3.71`; repin en consumidores cuando se publique npm; validar hooks y `doctor --parity` si fijáis expectativas de CI.
|
|
16
|
+
|
|
17
|
+
### 2026-04-06 (v6.3.70)
|
|
18
|
+
|
|
19
|
+
- **Consumidores con pre-commit en pre-push**: con `.ai_evidence.json` **versionado**, `PRE_PUSH` en **ALLOW/WARN** omite persistir en disco para no ensuciar el árbol tras el gate; variable `PUMUKI_PRE_PUSH_ALWAYS_WRITE_TRACKED_EVIDENCE=1` si necesitas el snapshot `PRE_PUSH` en fichero trackeado (puede exigir flujo de commit explícito).
|
|
20
|
+
- **macOS bloqueo**: un solo canal interactivo cuando el modal está activo (sin banner `osascript` paralelo); panel Swift más fiable para foco y clics en botones.
|
|
21
|
+
- **Rollout**: `pumuki@6.3.70`, repin en monorepos (p. ej. RuralGO); `npm test` / `git push` con hooks encadenados como validación.
|
|
22
|
+
|
|
9
23
|
### 2026-04-05 (v6.3.69)
|
|
10
24
|
|
|
11
25
|
- **Hooks = política de repo**: `PRE_COMMIT` / `PRE_PUSH` / `CI` / `PRE_WRITE` incorporan **`GITFLOW_PROTECTED_BRANCH`** y **higiene de worktree** (`EVIDENCE_PREWRITE_WORKTREE_*`, env `PUMUKI_PREWRITE_WORKTREE_*`) vía fusión con `evaluateAiGate` en `runPlatformGate`.
|
|
@@ -317,6 +317,22 @@ Environment variables:
|
|
|
317
317
|
- `PUMUKI_PREWRITE_WORKTREE_WARN_THRESHOLD` (default: `12`)
|
|
318
318
|
- `PUMUKI_PREWRITE_WORKTREE_BLOCK_THRESHOLD` (default: `24`)
|
|
319
319
|
|
|
320
|
+
## Alcance del gate por prefijos (monorepos)
|
|
321
|
+
|
|
322
|
+
- `PUMUKI_GATE_SCOPE_PATH_PREFIXES`: prefijos de ruta separados por **coma** o **punto y coma** (p. ej. `apps/backend,apps/web-app`). Se normalizan barras invertidas a `/`. El **primer** conjunto de hechos del alcance del stage (p. ej. staged o rango de commits) se filtra a rutas bajo esos prefijos. Hechos sin ruta de archivo reconocible (p. ej. algunas dependencias) se conservan para no romper reglas transversales.
|
|
323
|
+
|
|
324
|
+
## Paridad local vs CI (`pumuki doctor`)
|
|
325
|
+
|
|
326
|
+
- Fichero opcional **`.pumuki/ci-parity-expected.json`** (commit en el repo consumer): JSON mínimo con los campos que quieras fijar, p. ej. `pumuki_package_version`, `pre_commit_policy_hash`, `pre_commit_policy_bundle`. Con `pumuki doctor --parity` (y opcionalmente `--json`), Pumuki calcula el perfil actual y, si existe el fichero esperado, rellena `parity_comparison`; cualquier desajuste hace **exit code 1**.
|
|
327
|
+
|
|
328
|
+
## Evidencia en PRE_COMMIT con índice solo documentación
|
|
329
|
+
|
|
330
|
+
- `PUMUKI_PRE_COMMIT_ALWAYS_RESTAGE_TRACKED_EVIDENCE` (`1|true|yes`): fuerza el `git add` automático de `.ai_evidence.json` después de un `PRE_COMMIT` exitoso aunque el índice solo contenga rutas `*.md` / `*.mdx` (además de la propia evidencia). Por defecto, en ese alcance Pumuki **no** ensarta la evidencia en el commit: el snapshot se refresca en disco y puedes decidir si lo incluyes (`git add -- .ai_evidence.json`). Cubre el caso de commits puramente documentales sin mezclar un diff grande de evidencia (p. ej. informes upstream tipo PUMUKI-INC-069).
|
|
331
|
+
|
|
332
|
+
## Evidencia en PRE_PUSH con `.ai_evidence.json` trackeado
|
|
333
|
+
|
|
334
|
+
- `PUMUKI_PRE_PUSH_ALWAYS_WRITE_TRACKED_EVIDENCE` (`1|true|yes`): fuerza la escritura del snapshot en `PRE_PUSH` aunque `.ai_evidence.json` esté versionado. Por defecto (sin esta variable), si el fichero está en el índice de git y el outcome no es `BLOCK`, Pumuki **no** muta el archivo para no romper hooks encadenados (p. ej. `pre-commit` ejecutado desde `pre-push`).
|
|
335
|
+
|
|
320
336
|
Codes emitted:
|
|
321
337
|
|
|
322
338
|
- `EVIDENCE_PREWRITE_WORKTREE_WARN` (warning, still `ALLOWED`)
|
package/docs/product/USAGE.md
CHANGED
|
@@ -287,6 +287,8 @@ npx --yes pumuki install --with-mcp --agent=codex
|
|
|
287
287
|
npx --yes pumuki doctor
|
|
288
288
|
# include deterministic adapter/mcp wiring health checks
|
|
289
289
|
npx --yes pumuki doctor --deep --json
|
|
290
|
+
# parity profile vs .pumuki/ci-parity-expected.json (CI/local alignment)
|
|
291
|
+
npx --yes pumuki doctor --parity --json
|
|
290
292
|
|
|
291
293
|
# show lifecycle status
|
|
292
294
|
npx --yes pumuki status
|
|
@@ -671,7 +673,9 @@ npm run toolkit:clean-artifacts -- --dry-run
|
|
|
671
673
|
|
|
672
674
|
- Reads staged changes with `git diff --cached --name-status`.
|
|
673
675
|
- Builds facts from staged content.
|
|
676
|
+
- Opcional: `PUMUKI_GATE_SCOPE_PATH_PREFIXES` acota el primer alcance de hechos a prefijos del monorepo (ver `docs/product/CONFIGURATION.md`).
|
|
674
677
|
- Requires valid SDD/OpenSpec status (session + active change + validation).
|
|
678
|
+
- Si `.ai_evidence.json` está **versionado** y el hook refresca el snapshot en disco tras un gate **no** bloqueante, por defecto Pumuki vuelve a hacer `git add` de ese fichero **salvo** que lo único en el índice (ignorando `.ai_evidence.json` / `.AI_EVIDENCE.json`) sean rutas de documentación (`*.md`, `*.mdx`). En ese caso la evidencia se actualiza en disco pero **no** se ensarta en el commit: puedes añadirla manualmente (`git add -- .ai_evidence.json`) o activar el comportamiento anterior con `PUMUKI_PRE_COMMIT_ALWAYS_RESTAGE_TRACKED_EVIDENCE=1` (ver `docs/product/CONFIGURATION.md`). En stderr (si no va en modo silencioso) verás un aviso `[pumuki][evidence-sync]`.
|
|
675
679
|
|
|
676
680
|
### PRE_PUSH
|
|
677
681
|
|
|
@@ -679,6 +683,7 @@ npm run toolkit:clean-artifacts -- --dry-run
|
|
|
679
683
|
- Fails safe (`exit 1`) with guidance when no upstream is configured.
|
|
680
684
|
- Evaluates `upstream..HEAD` commit range.
|
|
681
685
|
- Requires valid SDD/OpenSpec status (session + active change + validation).
|
|
686
|
+
- Si `.ai_evidence.json` está **versionado** en git y el resultado del gate **no** es `BLOCK` (`PASS`/`WARN`), Pumuki **no reescribe** ese archivo en disco. Así se evita que integraciones tipo `pre-commit` (p. ej. como hook de `pre-push`) fallen con “files were modified by this hook” tras un `decision=ALLOW`. El snapshot en el último commit sigue siendo el generado en `PRE_COMMIT` hasta el siguiente commit. Para forzar la escritura del snapshot `PRE_PUSH` en un fichero trackeado, usa `PUMUKI_PRE_PUSH_ALWAYS_WRITE_TRACKED_EVIDENCE=1` (puede exigir un flujo de commit/evidencia explícito).
|
|
682
687
|
|
|
683
688
|
### CI
|
|
684
689
|
|
|
@@ -706,13 +711,19 @@ Resolver source: `integrations/git/resolveGitRefs.ts`.
|
|
|
706
711
|
|
|
707
712
|
## Evidence output
|
|
708
713
|
|
|
709
|
-
|
|
714
|
+
Cada ejecución del gate escribe evidencia determinista en:
|
|
710
715
|
|
|
711
716
|
- `.ai_evidence.json`
|
|
712
717
|
|
|
718
|
+
Excepciones:
|
|
719
|
+
|
|
720
|
+
- En `PRE_PUSH`, si el fichero está trackeado y el outcome no es `BLOCK`, la escritura al path anterior se omite (ver sección PRE_PUSH arriba). La telemetría interna del gate sigue generándose; solo se evita mutar el árbol de trabajo.
|
|
721
|
+
- En `PRE_COMMIT`, si el fichero está trackeado y el índice es solo documentación (`*.md` / `*.mdx`), el fichero puede actualizarse en disco sin auto-`git add` (ver sección PRE_COMMIT arriba).
|
|
722
|
+
|
|
713
723
|
Schema and behavior:
|
|
714
724
|
|
|
715
725
|
- `version: "2.1"` is the source of truth
|
|
726
|
+
- `operational_hints` (opcional pero recomendado en runs recientes): `requires_second_pass` (boolean), `second_pass_reason` (string o null), `human_summary_lines` (1–4 líneas legibles), `rule_execution_breakdown` (conteos evaluated / blocking / warn / info / skipped out-of-scope). Útil para tooling y para entender el resultado sin abrir todo el snapshot.
|
|
716
727
|
- `snapshot` + `ledger`
|
|
717
728
|
- `platforms` and `rulesets` tracking
|
|
718
729
|
- `snapshot.sdd_metrics` tracks stage-level SDD enforcement metadata
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
ConsolidationSuppressedFinding,
|
|
8
8
|
CompatibilityViolation,
|
|
9
9
|
EvidenceLines,
|
|
10
|
+
EvidenceOperationalHints,
|
|
10
11
|
HumanIntentState,
|
|
11
12
|
LedgerEntry,
|
|
12
13
|
PlatformState,
|
|
@@ -23,6 +24,7 @@ import { buildSnapshotPlatformSummaries } from './platformSummary';
|
|
|
23
24
|
import { resolveHumanIntent } from './humanIntent';
|
|
24
25
|
import { normalizeSnapshotEvaluationMetrics } from './evaluationMetrics';
|
|
25
26
|
import { normalizeSnapshotRulesCoverage } from './rulesCoverage';
|
|
27
|
+
import { buildEvidenceOperationalHints } from './operationalHints';
|
|
26
28
|
|
|
27
29
|
type BuildFindingInput = Finding & {
|
|
28
30
|
file?: string;
|
|
@@ -50,6 +52,9 @@ export type BuildEvidenceParams = {
|
|
|
50
52
|
};
|
|
51
53
|
sddMetrics?: SddMetrics;
|
|
52
54
|
repoState?: RepoState;
|
|
55
|
+
operationalHintsExtra?: Partial<
|
|
56
|
+
Pick<EvidenceOperationalHints, 'requires_second_pass' | 'second_pass_reason'>
|
|
57
|
+
>;
|
|
53
58
|
};
|
|
54
59
|
|
|
55
60
|
const normalizeLines = (lines?: EvidenceLines): EvidenceLines | undefined => {
|
|
@@ -766,9 +771,19 @@ export function buildEvidence(params: BuildEvidenceParams): AiEvidenceV2_1 {
|
|
|
766
771
|
previousEvidence: params.previousEvidence,
|
|
767
772
|
});
|
|
768
773
|
|
|
774
|
+
const operational_hints = buildEvidenceOperationalHints({
|
|
775
|
+
stage: params.stage,
|
|
776
|
+
outcome,
|
|
777
|
+
findings: normalizedFindings,
|
|
778
|
+
rulesCoverage: normalizedRulesCoverage,
|
|
779
|
+
evaluationMetrics: normalizedEvaluationMetrics,
|
|
780
|
+
extra: params.operationalHintsExtra,
|
|
781
|
+
});
|
|
782
|
+
|
|
769
783
|
return {
|
|
770
784
|
version: '2.1',
|
|
771
785
|
timestamp: now,
|
|
786
|
+
operational_hints,
|
|
772
787
|
snapshot: {
|
|
773
788
|
stage: params.stage,
|
|
774
789
|
audit_mode: params.auditMode ?? 'gate',
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import test from 'node:test';
|
|
6
6
|
import { withTempDir } from '../__tests__/helpers/tempDir';
|
|
@@ -61,6 +61,29 @@ test('generateEvidence compone build + write y persiste .ai_evidence.json', asyn
|
|
|
61
61
|
});
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
test('generateEvidence omite escritura a disco cuando skipDiskWrite y repoRoot son válidos', async () => {
|
|
65
|
+
await withTempDir('pumuki-generate-evidence-skip-', async (tempRoot) => {
|
|
66
|
+
initGitRepo(tempRoot);
|
|
67
|
+
const evidencePath = join(tempRoot, '.ai_evidence.json');
|
|
68
|
+
writeFileSync(evidencePath, '{"version":"2.1","pinned":true}\n', 'utf8');
|
|
69
|
+
|
|
70
|
+
const result = generateEvidence({
|
|
71
|
+
stage: 'PRE_PUSH',
|
|
72
|
+
gateOutcome: 'PASS',
|
|
73
|
+
findings: [],
|
|
74
|
+
detectedPlatforms: {},
|
|
75
|
+
loadedRulesets: [],
|
|
76
|
+
repoRoot: tempRoot,
|
|
77
|
+
skipDiskWrite: true,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
assert.equal(result.evidence.snapshot.stage, 'PRE_PUSH');
|
|
81
|
+
assert.equal(result.write.ok, true);
|
|
82
|
+
assert.equal(result.write.skipped, true);
|
|
83
|
+
assert.equal(readFileSync(evidencePath, 'utf8'), '{"version":"2.1","pinned":true}\n');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
64
87
|
test('generateEvidence mantiene evidencia en memoria aunque write falle', async () => {
|
|
65
88
|
await withTempDir('pumuki-generate-evidence-write-error-', async (tempRoot) => {
|
|
66
89
|
initGitRepo(tempRoot);
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
1
2
|
import { buildEvidence, type BuildEvidenceParams } from './buildEvidence';
|
|
2
3
|
import type { AiEvidenceV2_1 } from './schema';
|
|
3
4
|
import { writeEvidence, type WriteEvidenceResult } from './writeEvidence';
|
|
4
5
|
|
|
6
|
+
const EVIDENCE_FILE_BASENAME = '.ai_evidence.json';
|
|
7
|
+
|
|
5
8
|
export type GenerateEvidenceResult = {
|
|
6
9
|
evidence: AiEvidenceV2_1;
|
|
7
10
|
write: WriteEvidenceResult;
|
|
@@ -9,11 +12,25 @@ export type GenerateEvidenceResult = {
|
|
|
9
12
|
|
|
10
13
|
export type GenerateEvidenceParams = BuildEvidenceParams & {
|
|
11
14
|
repoRoot?: string;
|
|
15
|
+
skipDiskWrite?: boolean;
|
|
12
16
|
};
|
|
13
17
|
|
|
14
18
|
export const generateEvidence = (params: GenerateEvidenceParams): GenerateEvidenceResult => {
|
|
15
|
-
const { repoRoot, ...buildParams } = params;
|
|
19
|
+
const { repoRoot, skipDiskWrite, ...buildParams } = params;
|
|
16
20
|
const evidence = buildEvidence(buildParams);
|
|
21
|
+
if (skipDiskWrite === true) {
|
|
22
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
|
|
23
|
+
throw new Error('generateEvidence: repoRoot is required when skipDiskWrite is true');
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
evidence,
|
|
27
|
+
write: {
|
|
28
|
+
ok: true,
|
|
29
|
+
path: join(repoRoot, EVIDENCE_FILE_BASENAME),
|
|
30
|
+
skipped: true,
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
17
34
|
const write = writeEvidence(evidence, { repoRoot });
|
|
18
35
|
return { evidence, write };
|
|
19
36
|
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { GateOutcome } from '../../core/gate/GateOutcome';
|
|
2
|
+
import type { GateStage } from '../../core/gate/GateStage';
|
|
3
|
+
import { resolveRemediationHintOrDefault } from '../gate/remediationCatalog';
|
|
4
|
+
import type {
|
|
5
|
+
EvidenceOperationalHints,
|
|
6
|
+
EvidenceRuleExecutionBreakdown,
|
|
7
|
+
SnapshotEvaluationMetrics,
|
|
8
|
+
SnapshotFinding,
|
|
9
|
+
SnapshotRulesCoverage,
|
|
10
|
+
} from './schema';
|
|
11
|
+
|
|
12
|
+
const truncate = (value: string, max: number): string => {
|
|
13
|
+
const t = value.trim();
|
|
14
|
+
if (t.length <= max) {
|
|
15
|
+
return t;
|
|
16
|
+
}
|
|
17
|
+
return `${t.slice(0, max - 1)}…`;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const buildRuleExecutionBreakdown = (params: {
|
|
21
|
+
findings: ReadonlyArray<SnapshotFinding>;
|
|
22
|
+
rulesCoverage?: SnapshotRulesCoverage;
|
|
23
|
+
evaluationMetrics?: SnapshotEvaluationMetrics;
|
|
24
|
+
}): EvidenceRuleExecutionBreakdown => {
|
|
25
|
+
const matched_blocking_count = params.findings.filter(
|
|
26
|
+
(f) =>
|
|
27
|
+
f.severity === 'ERROR' ||
|
|
28
|
+
f.severity === 'CRITICAL' ||
|
|
29
|
+
f.blocking === true
|
|
30
|
+
).length;
|
|
31
|
+
const matched_warn_count = params.findings.filter((f) => f.severity === 'WARN').length;
|
|
32
|
+
const matched_info_count = params.findings.filter((f) => f.severity === 'INFO').length;
|
|
33
|
+
const evaluated_count =
|
|
34
|
+
params.rulesCoverage?.counts?.evaluated ?? params.evaluationMetrics?.evaluated_rule_ids.length ?? 0;
|
|
35
|
+
const active = params.rulesCoverage?.counts?.active ?? 0;
|
|
36
|
+
const evaluatedFromCoverage = params.rulesCoverage?.counts?.evaluated;
|
|
37
|
+
const skipped_out_of_scope_count =
|
|
38
|
+
typeof params.rulesCoverage?.counts?.unevaluated === 'number'
|
|
39
|
+
? params.rulesCoverage.counts.unevaluated
|
|
40
|
+
: active > 0 && typeof evaluatedFromCoverage === 'number'
|
|
41
|
+
? Math.max(0, active - evaluatedFromCoverage)
|
|
42
|
+
: 0;
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
evaluated_count: Math.max(0, evaluated_count),
|
|
46
|
+
matched_blocking_count,
|
|
47
|
+
matched_warn_count,
|
|
48
|
+
matched_info_count,
|
|
49
|
+
skipped_out_of_scope_count: Math.max(0, skipped_out_of_scope_count),
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const buildHumanSummaryLines = (params: {
|
|
54
|
+
stage: GateStage;
|
|
55
|
+
outcome: GateOutcome;
|
|
56
|
+
findings: ReadonlyArray<SnapshotFinding>;
|
|
57
|
+
}): string[] => {
|
|
58
|
+
const lines: string[] = [`${params.stage}: outcome=${params.outcome}.`];
|
|
59
|
+
if (params.outcome === 'BLOCK') {
|
|
60
|
+
const blocking =
|
|
61
|
+
params.findings.find((f) => f.severity === 'CRITICAL' || f.severity === 'ERROR') ??
|
|
62
|
+
params.findings.find((f) => f.blocking === true) ??
|
|
63
|
+
params.findings[0];
|
|
64
|
+
if (blocking) {
|
|
65
|
+
lines.push(`${blocking.code}: ${truncate(blocking.message, 140)}`);
|
|
66
|
+
lines.push(resolveRemediationHintOrDefault(blocking.code));
|
|
67
|
+
}
|
|
68
|
+
return lines.slice(0, 3);
|
|
69
|
+
}
|
|
70
|
+
const warn = params.findings.find((f) => f.severity === 'WARN');
|
|
71
|
+
if (warn) {
|
|
72
|
+
lines.push(`WARN ${warn.code}: ${truncate(warn.message, 120)}`);
|
|
73
|
+
}
|
|
74
|
+
return lines.slice(0, 3);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const buildEvidenceOperationalHints = (params: {
|
|
78
|
+
stage: GateStage;
|
|
79
|
+
outcome: GateOutcome;
|
|
80
|
+
findings: ReadonlyArray<SnapshotFinding>;
|
|
81
|
+
rulesCoverage?: SnapshotRulesCoverage;
|
|
82
|
+
evaluationMetrics?: SnapshotEvaluationMetrics;
|
|
83
|
+
extra?: Partial<Pick<EvidenceOperationalHints, 'requires_second_pass' | 'second_pass_reason'>>;
|
|
84
|
+
}): EvidenceOperationalHints => {
|
|
85
|
+
const breakdown = buildRuleExecutionBreakdown({
|
|
86
|
+
findings: params.findings,
|
|
87
|
+
rulesCoverage: params.rulesCoverage,
|
|
88
|
+
evaluationMetrics: params.evaluationMetrics,
|
|
89
|
+
});
|
|
90
|
+
let human_summary_lines = buildHumanSummaryLines({
|
|
91
|
+
stage: params.stage,
|
|
92
|
+
outcome: params.outcome,
|
|
93
|
+
findings: params.findings,
|
|
94
|
+
});
|
|
95
|
+
if (params.extra?.requires_second_pass === true) {
|
|
96
|
+
human_summary_lines = [
|
|
97
|
+
...human_summary_lines,
|
|
98
|
+
'La evidencia trackeada se actualizó en disco pero no entró en el índice; si debe ir en este commit: git add -- .ai_evidence.json',
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
requires_second_pass: params.extra?.requires_second_pass ?? false,
|
|
103
|
+
second_pass_reason:
|
|
104
|
+
params.extra?.requires_second_pass === true
|
|
105
|
+
? (params.extra.second_pass_reason ?? null)
|
|
106
|
+
: null,
|
|
107
|
+
human_summary_lines: human_summary_lines.slice(0, 4),
|
|
108
|
+
rule_execution_breakdown: breakdown,
|
|
109
|
+
};
|
|
110
|
+
};
|
|
@@ -206,10 +206,26 @@ export type EvidenceChain = {
|
|
|
206
206
|
sequence: number;
|
|
207
207
|
};
|
|
208
208
|
|
|
209
|
+
export type EvidenceRuleExecutionBreakdown = {
|
|
210
|
+
evaluated_count: number;
|
|
211
|
+
matched_blocking_count: number;
|
|
212
|
+
matched_warn_count: number;
|
|
213
|
+
matched_info_count: number;
|
|
214
|
+
skipped_out_of_scope_count: number;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
export type EvidenceOperationalHints = {
|
|
218
|
+
requires_second_pass: boolean;
|
|
219
|
+
second_pass_reason: string | null;
|
|
220
|
+
human_summary_lines: string[];
|
|
221
|
+
rule_execution_breakdown?: EvidenceRuleExecutionBreakdown;
|
|
222
|
+
};
|
|
223
|
+
|
|
209
224
|
export type AiEvidenceV2_1 = {
|
|
210
225
|
version: '2.1';
|
|
211
226
|
timestamp: string;
|
|
212
227
|
evidence_chain?: EvidenceChain;
|
|
228
|
+
operational_hints?: EvidenceOperationalHints;
|
|
213
229
|
snapshot: Snapshot;
|
|
214
230
|
ledger: LedgerEntry[];
|
|
215
231
|
platforms: Record<string, PlatformState>;
|
|
@@ -22,6 +22,7 @@ export type WriteEvidenceResult = {
|
|
|
22
22
|
ok: boolean;
|
|
23
23
|
path: string;
|
|
24
24
|
error?: string;
|
|
25
|
+
skipped?: boolean;
|
|
25
26
|
};
|
|
26
27
|
|
|
27
28
|
const EVIDENCE_FILE_NAME = '.ai_evidence.json';
|
|
@@ -341,6 +342,9 @@ const toStableEvidence = (
|
|
|
341
342
|
return {
|
|
342
343
|
version: '2.1',
|
|
343
344
|
timestamp: evidence.timestamp,
|
|
345
|
+
...(typeof evidence.operational_hints !== 'undefined'
|
|
346
|
+
? { operational_hints: evidence.operational_hints }
|
|
347
|
+
: {}),
|
|
344
348
|
snapshot: {
|
|
345
349
|
stage: evidence.snapshot.stage,
|
|
346
350
|
audit_mode: normalizedAuditMode,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export const DEFAULT_GATE_REMEDIATION =
|
|
2
|
+
'Corrige la causa del bloqueo y vuelve a ejecutar el gate.';
|
|
3
|
+
|
|
4
|
+
export const REMEDIATION_HINT_BY_CODE: Readonly<Record<string, string>> = {
|
|
5
|
+
EVIDENCE_MISSING: 'Regenera .ai_evidence.json ejecutando una auditoría.',
|
|
6
|
+
EVIDENCE_INVALID: 'Corrige/regenera .ai_evidence.json y vuelve a ejecutar el gate.',
|
|
7
|
+
EVIDENCE_CHAIN_INVALID: 'Regenera evidencia para restaurar la cadena criptográfica.',
|
|
8
|
+
EVIDENCE_STAGE_SYNC_FAILED:
|
|
9
|
+
'Sincroniza la evidencia trackeada y reintenta: git add -- .ai_evidence.json && git commit --amend --no-edit',
|
|
10
|
+
EVIDENCE_STALE: 'Refresca evidencia antes de continuar.',
|
|
11
|
+
EVIDENCE_REPO_ROOT_MISMATCH: 'Regenera evidencia desde este mismo repositorio.',
|
|
12
|
+
EVIDENCE_BRANCH_MISMATCH: 'Regenera evidencia en la rama actual y reintenta.',
|
|
13
|
+
EVIDENCE_RULES_COVERAGE_MISSING: 'Ejecuta auditoría completa para recalcular rules_coverage.',
|
|
14
|
+
EVIDENCE_RULES_COVERAGE_INCOMPLETE: 'Asegura coverage_ratio=1 y unevaluated=0.',
|
|
15
|
+
ACTIVE_RULE_IDS_EMPTY_FOR_CODE_CHANGES_HIGH:
|
|
16
|
+
'Reconcilia policy/skills y reintenta PRE_COMMIT: npx --yes --package pumuki@latest pumuki policy reconcile --strict --json && npx --yes --package pumuki@latest pumuki-pre-commit',
|
|
17
|
+
EVIDENCE_ACTIVE_RULE_IDS_EMPTY_FOR_CODE_CHANGES:
|
|
18
|
+
'Reconcilia policy/skills y revalida PRE_WRITE: npx --yes --package pumuki@latest pumuki policy reconcile --strict --json && npx --yes --package pumuki@latest pumuki sdd validate --stage=PRE_WRITE --json',
|
|
19
|
+
GITFLOW_PROTECTED_BRANCH: 'Trabaja en feature/* y evita ramas protegidas.',
|
|
20
|
+
EVIDENCE_PREWRITE_WORKTREE_OVER_LIMIT:
|
|
21
|
+
'Reduce archivos staged/unstaged por debajo del umbral (o ajusta PUMUKI_PREWRITE_WORKTREE_*); divide el trabajo en commits más pequeños.',
|
|
22
|
+
EVIDENCE_PREWRITE_WORKTREE_WARN:
|
|
23
|
+
'El worktree supera el umbral de aviso; reduce alcance antes del siguiente commit/push.',
|
|
24
|
+
PRE_PUSH_UPSTREAM_MISSING: 'Ejecuta: git push --set-upstream origin <branch>',
|
|
25
|
+
PRE_PUSH_UPSTREAM_MISALIGNED:
|
|
26
|
+
'Alinea upstream con la rama actual: git branch --unset-upstream && git push --set-upstream origin <branch>',
|
|
27
|
+
MANIFEST_MUTATION_DETECTED:
|
|
28
|
+
'Los hooks/gates no deben modificar manifests. Revisa wiring y ejecuta upgrade explícito solo cuando aplique (por ejemplo: pumuki update --latest).',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const resolveRemediationHintForViolationCode = (code: string): string | undefined => {
|
|
32
|
+
const trimmed = code.trim();
|
|
33
|
+
if (trimmed.length === 0) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
return REMEDIATION_HINT_BY_CODE[trimmed];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const resolveRemediationHintOrDefault = (code: string): string =>
|
|
40
|
+
resolveRemediationHintForViolationCode(code) ?? DEFAULT_GATE_REMEDIATION;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Fact } from '../../core/facts/Fact';
|
|
2
|
+
|
|
3
|
+
const normalizePath = (value: string): string => value.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
4
|
+
|
|
5
|
+
export const resolveGateScopePathPrefixesFromEnv = (): string[] => {
|
|
6
|
+
const raw = process.env.PUMUKI_GATE_SCOPE_PATH_PREFIXES?.trim();
|
|
7
|
+
if (!raw) {
|
|
8
|
+
return [];
|
|
9
|
+
}
|
|
10
|
+
return Array.from(
|
|
11
|
+
new Set(
|
|
12
|
+
raw
|
|
13
|
+
.split(/[,;]/)
|
|
14
|
+
.map((segment) => normalizePath(segment.trim()))
|
|
15
|
+
.filter((segment) => segment.length > 0)
|
|
16
|
+
)
|
|
17
|
+
).sort((a, b) => a.localeCompare(b));
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const primaryPathForFact = (fact: Fact): string | null => {
|
|
21
|
+
if (fact.kind === 'FileContent' || fact.kind === 'FileChange') {
|
|
22
|
+
return fact.path;
|
|
23
|
+
}
|
|
24
|
+
if (fact.kind === 'Heuristic') {
|
|
25
|
+
return fact.filePath ?? null;
|
|
26
|
+
}
|
|
27
|
+
if (fact.kind === 'Dependency') {
|
|
28
|
+
return fact.from;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const pathMatchesAnyPrefix = (path: string, prefixes: ReadonlyArray<string>): boolean => {
|
|
34
|
+
const normalized = normalizePath(path);
|
|
35
|
+
for (const prefix of prefixes) {
|
|
36
|
+
if (normalized === prefix) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
const withSlash = prefix.endsWith('/') ? prefix : `${prefix}/`;
|
|
40
|
+
if (normalized.startsWith(withSlash)) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const filterFactsByPathPrefixes = (
|
|
48
|
+
facts: ReadonlyArray<Fact>,
|
|
49
|
+
prefixes: ReadonlyArray<string>
|
|
50
|
+
): Fact[] => {
|
|
51
|
+
if (prefixes.length === 0) {
|
|
52
|
+
return [...facts];
|
|
53
|
+
}
|
|
54
|
+
return facts.filter((fact) => {
|
|
55
|
+
const primary = primaryPathForFact(fact);
|
|
56
|
+
if (primary === null) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
return pathMatchesAnyPrefix(primary, prefixes);
|
|
60
|
+
});
|
|
61
|
+
};
|
|
@@ -35,6 +35,10 @@ import type { TddBddSnapshot } from '../tdd/types';
|
|
|
35
35
|
import { resolveSkillsEnforcement } from '../policy/skillsEnforcement';
|
|
36
36
|
import { applyTddBddEnforcement } from '../policy/tddBddEnforcement';
|
|
37
37
|
import { collectAiGateRepoPolicyFindings } from './aiGateRepoPolicyFindings';
|
|
38
|
+
import {
|
|
39
|
+
filterFactsByPathPrefixes,
|
|
40
|
+
resolveGateScopePathPrefixesFromEnv,
|
|
41
|
+
} from './filterFactsByPathPrefixes';
|
|
38
42
|
|
|
39
43
|
export type OperationalMemoryShadowRecommendation = {
|
|
40
44
|
recommendedOutcome: 'ALLOW' | 'WARN' | 'BLOCK';
|
|
@@ -911,10 +915,14 @@ export async function runPlatformGate(params: {
|
|
|
911
915
|
}
|
|
912
916
|
}
|
|
913
917
|
|
|
914
|
-
const
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
+
const gateScopePathPrefixes = resolveGateScopePathPrefixesFromEnv();
|
|
919
|
+
const facts = filterFactsByPathPrefixes(
|
|
920
|
+
await dependencies.resolveFactsForGateScope({
|
|
921
|
+
scope: params.scope,
|
|
922
|
+
git,
|
|
923
|
+
}),
|
|
924
|
+
gateScopePathPrefixes
|
|
925
|
+
);
|
|
918
926
|
const stagedPaths = collectStagedPaths(git, repoRoot);
|
|
919
927
|
const factsForPlatformEvaluation = shouldAugmentStagedSkillsContractFactsWithRepoFacts({
|
|
920
928
|
scope: params.scope,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Finding } from '../../core/gate/Finding';
|
|
2
2
|
import type { GateOutcome } from '../../core/gate/GateOutcome';
|
|
3
3
|
import type { GateStage } from '../../core/gate/GateStage';
|
|
4
|
+
import { GitService } from './GitService';
|
|
4
5
|
import { rulePackVersions } from '../../core/rules/presets/rulePackVersions';
|
|
5
6
|
import type { RuleSet } from '../../core/rules/RuleSet';
|
|
6
7
|
import type { SkillsRuleSetLoadResult } from '../config/skillsRuleSet';
|
|
@@ -17,14 +18,51 @@ import { normalizeSnapshotRulesCoverage } from '../evidence/rulesCoverage';
|
|
|
17
18
|
import type { TddBddSnapshot } from '../tdd/types';
|
|
18
19
|
import { emitGateTelemetryEvent } from '../telemetry/gateTelemetry';
|
|
19
20
|
|
|
21
|
+
const TRACKED_EVIDENCE_RELATIVE_PATH = '.ai_evidence.json';
|
|
22
|
+
|
|
23
|
+
const isTruthyEnvFlag = (value?: string): boolean => {
|
|
24
|
+
if (!value) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
const normalized = value.trim().toLowerCase();
|
|
28
|
+
return normalized === '1' || normalized === 'true' || normalized === 'yes';
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const defaultIsEvidencePathTracked = (repoRoot: string, relativePath: string): boolean => {
|
|
32
|
+
try {
|
|
33
|
+
new GitService().runGit(['ls-files', '--error-unmatch', '--', relativePath], repoRoot);
|
|
34
|
+
return true;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const shouldSkipPrePushTrackedEvidenceDiskWrite = (params: {
|
|
41
|
+
stage: GateStage;
|
|
42
|
+
gateOutcome: GateOutcome;
|
|
43
|
+
repoRoot: string;
|
|
44
|
+
isEvidencePathTracked: (repoRoot: string, relativePath: string) => boolean;
|
|
45
|
+
}): boolean => {
|
|
46
|
+
if (isTruthyEnvFlag(process.env.PUMUKI_PRE_PUSH_ALWAYS_WRITE_TRACKED_EVIDENCE)) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
return (
|
|
50
|
+
params.stage === 'PRE_PUSH' &&
|
|
51
|
+
params.gateOutcome !== 'BLOCK' &&
|
|
52
|
+
params.isEvidencePathTracked(params.repoRoot, TRACKED_EVIDENCE_RELATIVE_PATH)
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
|
|
20
56
|
export type PlatformGateEvidenceDependencies = {
|
|
21
57
|
generateEvidence: typeof generateEvidence;
|
|
22
58
|
emitGateTelemetryEvent: typeof emitGateTelemetryEvent;
|
|
59
|
+
isEvidencePathTracked: (repoRoot: string, relativePath: string) => boolean;
|
|
23
60
|
};
|
|
24
61
|
|
|
25
62
|
const defaultDependencies: PlatformGateEvidenceDependencies = {
|
|
26
63
|
generateEvidence,
|
|
27
64
|
emitGateTelemetryEvent,
|
|
65
|
+
isEvidencePathTracked: defaultIsEvidencePathTracked,
|
|
28
66
|
};
|
|
29
67
|
|
|
30
68
|
export const emitPlatformGateEvidence = (params: {
|
|
@@ -58,6 +96,12 @@ export const emitPlatformGateEvidence = (params: {
|
|
|
58
96
|
const evaluationMetrics = normalizeSnapshotEvaluationMetrics(params.evaluationMetrics);
|
|
59
97
|
const rulesCoverage = normalizeSnapshotRulesCoverage(params.stage, params.rulesCoverage);
|
|
60
98
|
const repoState = captureRepoState(params.repoRoot);
|
|
99
|
+
const skipDiskWrite = shouldSkipPrePushTrackedEvidenceDiskWrite({
|
|
100
|
+
stage: params.stage,
|
|
101
|
+
gateOutcome: params.gateOutcome,
|
|
102
|
+
repoRoot: params.repoRoot,
|
|
103
|
+
isEvidencePathTracked: activeDependencies.isEvidencePathTracked,
|
|
104
|
+
});
|
|
61
105
|
|
|
62
106
|
activeDependencies.generateEvidence({
|
|
63
107
|
stage: params.stage,
|
|
@@ -70,6 +114,7 @@ export const emitPlatformGateEvidence = (params: {
|
|
|
70
114
|
...(params.tddBdd ? { tddBdd: params.tddBdd } : {}),
|
|
71
115
|
...(params.memoryShadow ? { memoryShadow: params.memoryShadow } : {}),
|
|
72
116
|
repoRoot: params.repoRoot,
|
|
117
|
+
...(skipDiskWrite ? { skipDiskWrite: true } : {}),
|
|
73
118
|
previousEvidence: params.evidenceService.loadPreviousEvidence(params.repoRoot),
|
|
74
119
|
detectedPlatforms: params.evidenceService.toDetectedPlatformsRecord(params.detectedPlatforms),
|
|
75
120
|
loadedRulesets: params.evidenceService.buildRulesetState({
|
|
@@ -20,7 +20,9 @@ import {
|
|
|
20
20
|
} from '../notifications/emitAuditSummaryNotification';
|
|
21
21
|
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
22
22
|
import { join } from 'node:path';
|
|
23
|
+
import { buildEvidenceOperationalHints } from '../evidence/operationalHints';
|
|
23
24
|
import { readEvidence, readEvidenceResult } from '../evidence/readEvidence';
|
|
25
|
+
import { writeEvidence } from '../evidence/writeEvidence';
|
|
24
26
|
import type { EvidenceReadResult } from '../evidence/readEvidence';
|
|
25
27
|
import type { SnapshotFinding } from '../evidence/schema';
|
|
26
28
|
import { ensureRuntimeArtifactsIgnored } from '../lifecycle/artifacts';
|
|
@@ -31,6 +33,10 @@ import {
|
|
|
31
33
|
resolveGitAtomicityEnforcement,
|
|
32
34
|
type GitAtomicityEnforcementResolution,
|
|
33
35
|
} from '../policy/gitAtomicityEnforcement';
|
|
36
|
+
import {
|
|
37
|
+
DEFAULT_GATE_REMEDIATION as DEFAULT_BLOCKED_REMEDIATION,
|
|
38
|
+
REMEDIATION_HINT_BY_CODE as BLOCKED_REMEDIATION_BY_CODE,
|
|
39
|
+
} from '../gate/remediationCatalog';
|
|
34
40
|
|
|
35
41
|
const PRE_PUSH_UPSTREAM_REQUIRED_MESSAGE =
|
|
36
42
|
'pumuki pre-push blocked: branch has no upstream tracking reference. Configure upstream first (for example: git push --set-upstream origin <branch>) and retry.';
|
|
@@ -40,39 +46,43 @@ const PRE_PUSH_MANUAL_FALLBACK_MESSAGE =
|
|
|
40
46
|
'[pumuki][pre-push] branch has no upstream and stdin is empty; using working-tree fallback scope.';
|
|
41
47
|
const PRE_PUSH_UPSTREAM_MISALIGNED_AHEAD_THRESHOLD = 5;
|
|
42
48
|
|
|
49
|
+
const isTruthyEnvFlag = (value?: string): boolean => {
|
|
50
|
+
if (!value) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const normalized = value.trim().toLowerCase();
|
|
54
|
+
return normalized === '1' || normalized === 'true' || normalized === 'yes';
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const isDocumentationOnlyStagedPath = (relativePath: string): boolean => {
|
|
58
|
+
const normalized = relativePath.replace(/\\/g, '/').trim();
|
|
59
|
+
if (normalized.length === 0) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
return /\.(md|mdx)$/i.test(normalized);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const shouldSkipRestagingTrackedEvidenceForDocumentationOnlyScope = (params: {
|
|
66
|
+
listStagedIndexPaths: (repoRoot: string) => ReadonlyArray<string>;
|
|
67
|
+
repoRoot: string;
|
|
68
|
+
}): boolean => {
|
|
69
|
+
if (isTruthyEnvFlag(process.env.PUMUKI_PRE_COMMIT_ALWAYS_RESTAGE_TRACKED_EVIDENCE)) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
const paths = params.listStagedIndexPaths(params.repoRoot).filter(
|
|
73
|
+
(p) => p !== '.ai_evidence.json' && p !== '.AI_EVIDENCE.json'
|
|
74
|
+
);
|
|
75
|
+
if (paths.length === 0) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
return paths.every(isDocumentationOnlyStagedPath);
|
|
79
|
+
};
|
|
80
|
+
|
|
43
81
|
const PRE_COMMIT_EVIDENCE_MAX_AGE_SECONDS = 900;
|
|
44
82
|
const PRE_PUSH_EVIDENCE_MAX_AGE_SECONDS = 1800;
|
|
45
83
|
const HOOK_GATE_PROGRESS_REMINDER_MS = 2000;
|
|
46
|
-
const DEFAULT_BLOCKED_REMEDIATION = 'Corrige la causa del bloqueo y vuelve a ejecutar el gate.';
|
|
47
84
|
const EVIDENCE_FILE_PATH = '.ai_evidence.json';
|
|
48
85
|
|
|
49
|
-
const BLOCKED_REMEDIATION_BY_CODE: Readonly<Record<string, string>> = {
|
|
50
|
-
EVIDENCE_MISSING: 'Regenera .ai_evidence.json ejecutando una auditoría.',
|
|
51
|
-
EVIDENCE_INVALID: 'Corrige/regenera .ai_evidence.json y vuelve a ejecutar el gate.',
|
|
52
|
-
EVIDENCE_CHAIN_INVALID: 'Regenera evidencia para restaurar la cadena criptográfica.',
|
|
53
|
-
EVIDENCE_STAGE_SYNC_FAILED:
|
|
54
|
-
'Sincroniza la evidencia trackeada y reintenta: git add -- .ai_evidence.json && git commit --amend --no-edit',
|
|
55
|
-
EVIDENCE_STALE: 'Refresca evidencia antes de continuar.',
|
|
56
|
-
EVIDENCE_REPO_ROOT_MISMATCH: 'Regenera evidencia desde este mismo repositorio.',
|
|
57
|
-
EVIDENCE_BRANCH_MISMATCH: 'Regenera evidencia en la rama actual y reintenta.',
|
|
58
|
-
EVIDENCE_RULES_COVERAGE_MISSING: 'Ejecuta auditoría completa para recalcular rules_coverage.',
|
|
59
|
-
EVIDENCE_RULES_COVERAGE_INCOMPLETE: 'Asegura coverage_ratio=1 y unevaluated=0.',
|
|
60
|
-
ACTIVE_RULE_IDS_EMPTY_FOR_CODE_CHANGES_HIGH:
|
|
61
|
-
'Reconcilia policy/skills y reintenta PRE_COMMIT: npx --yes --package pumuki@latest pumuki policy reconcile --strict --json && npx --yes --package pumuki@latest pumuki-pre-commit',
|
|
62
|
-
EVIDENCE_ACTIVE_RULE_IDS_EMPTY_FOR_CODE_CHANGES:
|
|
63
|
-
'Reconcilia policy/skills y revalida PRE_WRITE: npx --yes --package pumuki@latest pumuki policy reconcile --strict --json && npx --yes --package pumuki@latest pumuki sdd validate --stage=PRE_WRITE --json',
|
|
64
|
-
GITFLOW_PROTECTED_BRANCH: 'Trabaja en feature/* y evita ramas protegidas.',
|
|
65
|
-
EVIDENCE_PREWRITE_WORKTREE_OVER_LIMIT:
|
|
66
|
-
'Reduce archivos staged/unstaged por debajo del umbral (o ajusta PUMUKI_PREWRITE_WORKTREE_*); divide el trabajo en commits más pequeños.',
|
|
67
|
-
EVIDENCE_PREWRITE_WORKTREE_WARN:
|
|
68
|
-
'El worktree supera el umbral de aviso; reduce alcance antes del siguiente commit/push.',
|
|
69
|
-
PRE_PUSH_UPSTREAM_MISSING: 'Ejecuta: git push --set-upstream origin <branch>',
|
|
70
|
-
PRE_PUSH_UPSTREAM_MISALIGNED:
|
|
71
|
-
'Alinea upstream con la rama actual: git branch --unset-upstream && git push --set-upstream origin <branch>',
|
|
72
|
-
MANIFEST_MUTATION_DETECTED:
|
|
73
|
-
'Los hooks/gates no deben modificar manifests. Revisa wiring y ejecuta upgrade explícito solo cuando aplique (por ejemplo: pumuki update --latest).',
|
|
74
|
-
};
|
|
75
|
-
|
|
76
86
|
const HOOK_POLICY_RECONCILE_CODES = new Set<string>([
|
|
77
87
|
'SKILLS_PLATFORM_COVERAGE_INCOMPLETE_HIGH',
|
|
78
88
|
'SKILLS_SCOPE_COMPLIANCE_INCOMPLETE_HIGH',
|
|
@@ -123,6 +133,7 @@ type StageRunnerDependencies = {
|
|
|
123
133
|
ensureRuntimeArtifactsIgnored: (repoRoot: string) => void;
|
|
124
134
|
runPolicyReconcile: typeof runPolicyReconcile;
|
|
125
135
|
isPathTracked: (repoRoot: string, relativePath: string) => boolean;
|
|
136
|
+
listStagedIndexPaths: (repoRoot: string) => ReadonlyArray<string>;
|
|
126
137
|
stagePath: (repoRoot: string, relativePath: string) => void;
|
|
127
138
|
resolveHeadOid: (repoRoot: string) => string | null;
|
|
128
139
|
resolveGitAtomicityEnforcement: () => GitAtomicityEnforcementResolution;
|
|
@@ -202,6 +213,13 @@ const defaultDependencies: StageRunnerDependencies = {
|
|
|
202
213
|
return false;
|
|
203
214
|
}
|
|
204
215
|
},
|
|
216
|
+
listStagedIndexPaths: (repoRoot) => {
|
|
217
|
+
const raw = new GitService().runGit(['diff', '--cached', '--name-only'], repoRoot);
|
|
218
|
+
return raw
|
|
219
|
+
.split('\n')
|
|
220
|
+
.map((line) => line.trim())
|
|
221
|
+
.filter((line) => line.length > 0);
|
|
222
|
+
},
|
|
205
223
|
stagePath: (repoRoot, relativePath) => {
|
|
206
224
|
new GitService().runGit(['add', '--', relativePath], repoRoot);
|
|
207
225
|
},
|
|
@@ -489,6 +507,26 @@ const runHookGateWithPolicyRetry = async (params: {
|
|
|
489
507
|
}
|
|
490
508
|
};
|
|
491
509
|
|
|
510
|
+
const patchOperationalHintsAfterDocumentationOnlyEvidenceSync = (repoRoot: string): void => {
|
|
511
|
+
const evidenceRead = readEvidenceResult(repoRoot);
|
|
512
|
+
if (evidenceRead.kind !== 'valid') {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
const evidence = evidenceRead.evidence;
|
|
516
|
+
const hints = buildEvidenceOperationalHints({
|
|
517
|
+
stage: evidence.snapshot.stage,
|
|
518
|
+
outcome: evidence.snapshot.outcome,
|
|
519
|
+
findings: evidence.snapshot.findings,
|
|
520
|
+
rulesCoverage: evidence.snapshot.rules_coverage,
|
|
521
|
+
evaluationMetrics: evidence.snapshot.evaluation_metrics,
|
|
522
|
+
extra: {
|
|
523
|
+
requires_second_pass: true,
|
|
524
|
+
second_pass_reason: 'tracked_evidence_refreshed_on_disk_not_staged_documentation_only_commit',
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
writeEvidence({ ...evidence, operational_hints: hints }, { repoRoot });
|
|
528
|
+
};
|
|
529
|
+
|
|
492
530
|
const syncTrackedEvidenceAfterSuccessfulPreCommit = (params: {
|
|
493
531
|
dependencies: StageRunnerDependencies;
|
|
494
532
|
repoRoot: string;
|
|
@@ -500,6 +538,22 @@ const syncTrackedEvidenceAfterSuccessfulPreCommit = (params: {
|
|
|
500
538
|
if (!params.dependencies.isPathTracked(params.repoRoot, EVIDENCE_FILE_PATH)) {
|
|
501
539
|
return false;
|
|
502
540
|
}
|
|
541
|
+
if (
|
|
542
|
+
shouldSkipRestagingTrackedEvidenceForDocumentationOnlyScope({
|
|
543
|
+
repoRoot: params.repoRoot,
|
|
544
|
+
listStagedIndexPaths: params.dependencies.listStagedIndexPaths,
|
|
545
|
+
})
|
|
546
|
+
) {
|
|
547
|
+
if (!params.dependencies.isQuietMode()) {
|
|
548
|
+
process.stderr.write(
|
|
549
|
+
`[pumuki][evidence-sync] tracked ${EVIDENCE_FILE_PATH} updated on disk but not auto-staged (documentation-only staged paths: *.md / *.mdx). ` +
|
|
550
|
+
`Include in this commit if needed: git add -- ${EVIDENCE_FILE_PATH}. ` +
|
|
551
|
+
`Force previous behavior: PUMUKI_PRE_COMMIT_ALWAYS_RESTAGE_TRACKED_EVIDENCE=1\n`
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
patchOperationalHintsAfterDocumentationOnlyEvidenceSync(params.repoRoot);
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
503
557
|
try {
|
|
504
558
|
params.dependencies.stagePath(params.repoRoot, EVIDENCE_FILE_PATH);
|
|
505
559
|
return false;
|
|
@@ -5,6 +5,7 @@ import { runPlatformGate } from '../git/runPlatformGate';
|
|
|
5
5
|
import { collectWorktreeAtomicSlices } from '../git/worktreeAtomicSlices';
|
|
6
6
|
import {
|
|
7
7
|
doctorHasBlockingIssues,
|
|
8
|
+
doctorHasParityMismatch,
|
|
8
9
|
runLifecycleDoctor,
|
|
9
10
|
type LifecycleDoctorReport,
|
|
10
11
|
} from './doctor';
|
|
@@ -115,6 +116,7 @@ export type ParsedArgs = {
|
|
|
115
116
|
installMcpAgent?: AdapterAgent;
|
|
116
117
|
remoteChecks?: boolean;
|
|
117
118
|
doctorDeep?: boolean;
|
|
119
|
+
doctorParity?: boolean;
|
|
118
120
|
sddCommand?: SddCommand;
|
|
119
121
|
loopCommand?: LoopCommand;
|
|
120
122
|
loopSessionId?: string;
|
|
@@ -180,7 +182,7 @@ Pumuki lifecycle commands:
|
|
|
180
182
|
pumuki uninstall [--purge-artifacts]
|
|
181
183
|
pumuki remove
|
|
182
184
|
pumuki update [--latest|--spec=<package-spec>]
|
|
183
|
-
pumuki doctor [--remote-checks] [--deep] [--json]
|
|
185
|
+
pumuki doctor [--remote-checks] [--deep] [--parity] [--json]
|
|
184
186
|
pumuki status [--json] [--remote-checks]
|
|
185
187
|
pumuki watch [--stage=PRE_COMMIT|PRE_PUSH|CI] [--scope=workingTree|staged|repoAndStaged|repo] [--severity=critical|high|medium|low] [--interval-ms=<n>] [--notify-cooldown-ms=<n>] [--no-notify] [--once|--iterations=<n>] [--json]
|
|
186
188
|
pumuki loop run --objective=<text> [--max-attempts=<n>] [--json]
|
|
@@ -567,6 +569,7 @@ export const parseLifecycleCliArgs = (argv: ReadonlyArray<string>): ParsedArgs =
|
|
|
567
569
|
let installMcpAgent: ParsedArgs['installMcpAgent'];
|
|
568
570
|
let remoteChecks = false;
|
|
569
571
|
let doctorDeep = false;
|
|
572
|
+
let doctorParity = false;
|
|
570
573
|
let watchStage: ParsedArgs['watchStage'];
|
|
571
574
|
let watchScope: ParsedArgs['watchScope'];
|
|
572
575
|
let watchIntervalMs: ParsedArgs['watchIntervalMs'];
|
|
@@ -1395,6 +1398,10 @@ export const parseLifecycleCliArgs = (argv: ReadonlyArray<string>): ParsedArgs =
|
|
|
1395
1398
|
doctorDeep = true;
|
|
1396
1399
|
continue;
|
|
1397
1400
|
}
|
|
1401
|
+
if (arg === '--parity') {
|
|
1402
|
+
doctorParity = true;
|
|
1403
|
+
continue;
|
|
1404
|
+
}
|
|
1398
1405
|
if (arg === '--purge-artifacts') {
|
|
1399
1406
|
purgeArtifacts = true;
|
|
1400
1407
|
continue;
|
|
@@ -1417,6 +1424,9 @@ export const parseLifecycleCliArgs = (argv: ReadonlyArray<string>): ParsedArgs =
|
|
|
1417
1424
|
if (doctorDeep && commandRaw !== 'doctor') {
|
|
1418
1425
|
throw new Error(`--deep is only supported with "pumuki doctor".\n\n${HELP_TEXT}`);
|
|
1419
1426
|
}
|
|
1427
|
+
if (doctorParity && commandRaw !== 'doctor') {
|
|
1428
|
+
throw new Error(`--parity is only supported with "pumuki doctor".\n\n${HELP_TEXT}`);
|
|
1429
|
+
}
|
|
1420
1430
|
if (commandRaw !== 'bootstrap' && bootstrapEnterprise) {
|
|
1421
1431
|
throw new Error(`--enterprise is only supported with "pumuki bootstrap".\n\n${HELP_TEXT}`);
|
|
1422
1432
|
}
|
|
@@ -1444,6 +1454,7 @@ export const parseLifecycleCliArgs = (argv: ReadonlyArray<string>): ParsedArgs =
|
|
|
1444
1454
|
...(installMcpAgent ? { installMcpAgent } : {}),
|
|
1445
1455
|
...(remoteChecks ? { remoteChecks: true } : {}),
|
|
1446
1456
|
...(doctorDeep ? { doctorDeep: true } : {}),
|
|
1457
|
+
...(doctorParity ? { doctorParity: true } : {}),
|
|
1447
1458
|
};
|
|
1448
1459
|
};
|
|
1449
1460
|
|
|
@@ -1516,6 +1527,22 @@ const printDoctorReport = (
|
|
|
1516
1527
|
writeInfo(`[pumuki] ${issue.severity.toUpperCase()}: ${issue.message}`);
|
|
1517
1528
|
}
|
|
1518
1529
|
|
|
1530
|
+
if (report.parity_profile) {
|
|
1531
|
+
writeInfo(
|
|
1532
|
+
`[pumuki][doctor][parity] pumuki=${report.parity_profile.pumuki_package_version} bundle=${report.parity_profile.pre_commit_policy_bundle} hash=${report.parity_profile.pre_commit_policy_hash}`
|
|
1533
|
+
);
|
|
1534
|
+
}
|
|
1535
|
+
if (report.parity_comparison) {
|
|
1536
|
+
writeInfo(
|
|
1537
|
+
`[pumuki][doctor][parity] expected_file=${report.parity_comparison.expected_path} matches=${report.parity_comparison.matches ? 'yes' : 'no'}`
|
|
1538
|
+
);
|
|
1539
|
+
for (const mismatch of report.parity_comparison.mismatches) {
|
|
1540
|
+
writeInfo(
|
|
1541
|
+
`[pumuki][doctor][parity] mismatch ${mismatch.field}: expected=${mismatch.expected} actual=${mismatch.actual}`
|
|
1542
|
+
);
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1519
1546
|
if (report.deep?.enabled) {
|
|
1520
1547
|
for (const check of report.deep.checks) {
|
|
1521
1548
|
writeInfo(
|
|
@@ -1527,7 +1554,8 @@ const printDoctorReport = (
|
|
|
1527
1554
|
}
|
|
1528
1555
|
}
|
|
1529
1556
|
|
|
1530
|
-
const hasBlocking =
|
|
1557
|
+
const hasBlocking =
|
|
1558
|
+
doctorHasBlockingIssues(report) || doctorHasParityMismatch(report);
|
|
1531
1559
|
const hasWarnings =
|
|
1532
1560
|
report.issues.length > 0 ||
|
|
1533
1561
|
report.deep?.checks.some((check) => check.status !== 'pass') === true;
|
|
@@ -2225,6 +2253,7 @@ export const runLifecycleCli = async (
|
|
|
2225
2253
|
case 'doctor': {
|
|
2226
2254
|
const report = runLifecycleDoctor({
|
|
2227
2255
|
deep: parsed.doctorDeep === true,
|
|
2256
|
+
parity: parsed.doctorParity === true,
|
|
2228
2257
|
});
|
|
2229
2258
|
const remoteCiDiagnostics = parsed.remoteChecks
|
|
2230
2259
|
? activeDependencies.collectRemoteCiDiagnostics({
|
|
@@ -2247,7 +2276,7 @@ export const runLifecycleCli = async (
|
|
|
2247
2276
|
} else {
|
|
2248
2277
|
printDoctorReport(report, remoteCiDiagnostics);
|
|
2249
2278
|
}
|
|
2250
|
-
return doctorHasBlockingIssues(report) ? 1 : 0;
|
|
2279
|
+
return doctorHasBlockingIssues(report) || doctorHasParityMismatch(report) ? 1 : 0;
|
|
2251
2280
|
}
|
|
2252
2281
|
case 'status': {
|
|
2253
2282
|
const status = readLifecycleStatus();
|
|
@@ -69,6 +69,22 @@ export type DoctorCompatibilityContract = {
|
|
|
69
69
|
};
|
|
70
70
|
};
|
|
71
71
|
|
|
72
|
+
export type DoctorParityProfile = {
|
|
73
|
+
schema_version: '1';
|
|
74
|
+
pumuki_package_version: string;
|
|
75
|
+
pre_commit_policy_bundle: string;
|
|
76
|
+
pre_commit_policy_hash: string;
|
|
77
|
+
pre_commit_policy_signature: string | null;
|
|
78
|
+
pre_commit_policy_version: string | null;
|
|
79
|
+
skills_policy_present: boolean;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type DoctorParityComparison = {
|
|
83
|
+
expected_path: string;
|
|
84
|
+
matches: boolean;
|
|
85
|
+
mismatches: ReadonlyArray<{ field: string; expected: string; actual: string }>;
|
|
86
|
+
};
|
|
87
|
+
|
|
72
88
|
export type LifecycleDoctorReport = {
|
|
73
89
|
repoRoot: string;
|
|
74
90
|
packageVersion: string;
|
|
@@ -81,6 +97,8 @@ export type LifecycleDoctorReport = {
|
|
|
81
97
|
policyValidation: LifecyclePolicyValidationSnapshot;
|
|
82
98
|
issues: ReadonlyArray<DoctorIssue>;
|
|
83
99
|
deep?: DoctorDeepReport;
|
|
100
|
+
parity_profile?: DoctorParityProfile;
|
|
101
|
+
parity_comparison?: DoctorParityComparison;
|
|
84
102
|
};
|
|
85
103
|
|
|
86
104
|
const buildDoctorIssues = (params: {
|
|
@@ -698,10 +716,87 @@ const buildDoctorDeepReport = (params: {
|
|
|
698
716
|
};
|
|
699
717
|
};
|
|
700
718
|
|
|
719
|
+
const buildDoctorParityProfile = (params: {
|
|
720
|
+
repoRoot: string;
|
|
721
|
+
packageVersion: string;
|
|
722
|
+
}): DoctorParityProfile => {
|
|
723
|
+
const policy = resolvePolicyForStage('PRE_COMMIT', params.repoRoot);
|
|
724
|
+
const skillsPolicyPath = join(params.repoRoot, 'skills.policy.json');
|
|
725
|
+
return {
|
|
726
|
+
schema_version: '1',
|
|
727
|
+
pumuki_package_version: params.packageVersion,
|
|
728
|
+
pre_commit_policy_bundle: policy.trace.bundle,
|
|
729
|
+
pre_commit_policy_hash: policy.trace.hash,
|
|
730
|
+
pre_commit_policy_signature: policy.trace.signature ?? null,
|
|
731
|
+
pre_commit_policy_version: policy.trace.version ?? null,
|
|
732
|
+
skills_policy_present: existsSync(skillsPolicyPath),
|
|
733
|
+
};
|
|
734
|
+
};
|
|
735
|
+
|
|
736
|
+
const compareDoctorParityProfile = (params: {
|
|
737
|
+
repoRoot: string;
|
|
738
|
+
actual: DoctorParityProfile;
|
|
739
|
+
}): DoctorParityComparison | undefined => {
|
|
740
|
+
const expectedPath = join(params.repoRoot, '.pumuki', 'ci-parity-expected.json');
|
|
741
|
+
if (!existsSync(expectedPath)) {
|
|
742
|
+
return undefined;
|
|
743
|
+
}
|
|
744
|
+
let raw: unknown;
|
|
745
|
+
try {
|
|
746
|
+
raw = JSON.parse(readFileSync(expectedPath, 'utf8')) as unknown;
|
|
747
|
+
} catch {
|
|
748
|
+
return {
|
|
749
|
+
expected_path: expectedPath,
|
|
750
|
+
matches: false,
|
|
751
|
+
mismatches: [
|
|
752
|
+
{
|
|
753
|
+
field: 'ci-parity-expected.json',
|
|
754
|
+
expected: 'valid-json',
|
|
755
|
+
actual: 'parse-error',
|
|
756
|
+
},
|
|
757
|
+
],
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
if (!isRecord(raw)) {
|
|
761
|
+
return {
|
|
762
|
+
expected_path: expectedPath,
|
|
763
|
+
matches: false,
|
|
764
|
+
mismatches: [{ field: 'root', expected: 'object', actual: 'non-object' }],
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
const mismatches: Array<{ field: string; expected: string; actual: string }> = [];
|
|
768
|
+
const expectField = (field: string, expected: unknown, actual: string) => {
|
|
769
|
+
if (typeof expected === 'string' && expected.trim().length > 0 && expected !== actual) {
|
|
770
|
+
mismatches.push({ field, expected, actual });
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
expectField(
|
|
774
|
+
'pumuki_package_version',
|
|
775
|
+
raw.pumuki_package_version,
|
|
776
|
+
params.actual.pumuki_package_version
|
|
777
|
+
);
|
|
778
|
+
expectField(
|
|
779
|
+
'pre_commit_policy_hash',
|
|
780
|
+
raw.pre_commit_policy_hash,
|
|
781
|
+
params.actual.pre_commit_policy_hash
|
|
782
|
+
);
|
|
783
|
+
expectField(
|
|
784
|
+
'pre_commit_policy_bundle',
|
|
785
|
+
raw.pre_commit_policy_bundle,
|
|
786
|
+
params.actual.pre_commit_policy_bundle
|
|
787
|
+
);
|
|
788
|
+
return {
|
|
789
|
+
expected_path: expectedPath,
|
|
790
|
+
matches: mismatches.length === 0,
|
|
791
|
+
mismatches,
|
|
792
|
+
};
|
|
793
|
+
};
|
|
794
|
+
|
|
701
795
|
export const runLifecycleDoctor = (params?: {
|
|
702
796
|
cwd?: string;
|
|
703
797
|
git?: ILifecycleGitService;
|
|
704
798
|
deep?: boolean;
|
|
799
|
+
parity?: boolean;
|
|
705
800
|
}): LifecycleDoctorReport => {
|
|
706
801
|
const git = params?.git ?? new LifecycleGitService();
|
|
707
802
|
const cwd = params?.cwd ?? process.cwd();
|
|
@@ -730,6 +825,18 @@ export const runLifecycleDoctor = (params?: {
|
|
|
730
825
|
lifecycleVersion: lifecycleState.version,
|
|
731
826
|
});
|
|
732
827
|
|
|
828
|
+
const parity_profile =
|
|
829
|
+
params?.parity === true
|
|
830
|
+
? buildDoctorParityProfile({
|
|
831
|
+
repoRoot,
|
|
832
|
+
packageVersion: version.effective,
|
|
833
|
+
})
|
|
834
|
+
: undefined;
|
|
835
|
+
const parity_comparison =
|
|
836
|
+
typeof parity_profile !== 'undefined'
|
|
837
|
+
? compareDoctorParityProfile({ repoRoot, actual: parity_profile })
|
|
838
|
+
: undefined;
|
|
839
|
+
|
|
733
840
|
return {
|
|
734
841
|
repoRoot,
|
|
735
842
|
packageVersion: version.effective,
|
|
@@ -742,8 +849,13 @@ export const runLifecycleDoctor = (params?: {
|
|
|
742
849
|
policyValidation: readLifecyclePolicyValidationSnapshot(repoRoot),
|
|
743
850
|
issues,
|
|
744
851
|
deep,
|
|
852
|
+
parity_profile,
|
|
853
|
+
parity_comparison,
|
|
745
854
|
};
|
|
746
855
|
};
|
|
747
856
|
|
|
748
857
|
export const doctorHasBlockingIssues = (report: LifecycleDoctorReport): boolean =>
|
|
749
858
|
report.issues.some((issue) => issue.severity === 'error') || report.deep?.blocking === true;
|
|
859
|
+
|
|
860
|
+
export const doctorHasParityMismatch = (report: LifecycleDoctorReport): boolean =>
|
|
861
|
+
typeof report.parity_comparison !== 'undefined' && report.parity_comparison.matches === false;
|
|
@@ -1,17 +1,8 @@
|
|
|
1
1
|
import { evaluateAiGate, type AiGateStage } from '../gate/evaluateAiGate';
|
|
2
|
+
import { resolveRemediationHintForViolationCode } from '../gate/remediationCatalog';
|
|
2
3
|
import { resolveLearningContextExperimentalFeature } from '../policy/experimentalFeatures';
|
|
3
4
|
import { readSddLearningContext, type SddLearningContext } from '../sdd/learningInsights';
|
|
4
5
|
|
|
5
|
-
const AUTO_FIX_BY_CODE: Readonly<Record<string, string>> = {
|
|
6
|
-
EVIDENCE_MISSING: 'Ejecuta una auditoría para generar .ai_evidence.json.',
|
|
7
|
-
EVIDENCE_INVALID: 'Regenera .ai_evidence.json y vuelve a evaluar.',
|
|
8
|
-
EVIDENCE_STALE: 'Refresca evidencia antes de continuar.',
|
|
9
|
-
EVIDENCE_BRANCH_MISMATCH: 'Regenera evidencia en la rama actual.',
|
|
10
|
-
EVIDENCE_REPO_ROOT_MISMATCH: 'Regenera evidencia desde este repositorio.',
|
|
11
|
-
PRE_PUSH_UPSTREAM_MISSING: 'Ejecuta git push --set-upstream origin <branch>.',
|
|
12
|
-
GITFLOW_PROTECTED_BRANCH: 'Crea una rama feature/* y mueve el trabajo allí.',
|
|
13
|
-
};
|
|
14
|
-
|
|
15
6
|
const PROTECTED_BRANCHES = new Set(['main', 'master', 'develop', 'dev']);
|
|
16
7
|
|
|
17
8
|
export type EnterpriseAiGateCheckResult = {
|
|
@@ -112,7 +103,7 @@ const buildAutoFixes = (
|
|
|
112
103
|
if (emittedCodes.has(violation.code)) {
|
|
113
104
|
continue;
|
|
114
105
|
}
|
|
115
|
-
const fix =
|
|
106
|
+
const fix = resolveRemediationHintForViolationCode(violation.code);
|
|
116
107
|
if (!fix) {
|
|
117
108
|
continue;
|
|
118
109
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pumuki",
|
|
3
|
-
"version": "6.3.
|
|
3
|
+
"version": "6.3.71",
|
|
4
4
|
"description": "Enterprise-grade AST Intelligence System with multi-platform support (iOS, Android, Backend, Frontend) and Feature-First + DDD + Clean Architecture enforcement. Includes dynamic violations API for intelligent querying.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export const SWIFT_BLOCKED_DIALOG_SOURCE = String.raw`import AppKit
|
|
2
2
|
import Foundation
|
|
3
3
|
|
|
4
|
+
final class KeyableFloatingPanel: NSPanel {
|
|
5
|
+
override var canBecomeKey: Bool { true }
|
|
6
|
+
}
|
|
7
|
+
|
|
4
8
|
struct DialogConfig {
|
|
5
9
|
let title: String
|
|
6
10
|
let cause: String
|
|
@@ -108,12 +112,13 @@ final class DialogController: NSObject, NSApplicationDelegate, NSWindowDelegate
|
|
|
108
112
|
y: screenFrame.minY + margin
|
|
109
113
|
)
|
|
110
114
|
|
|
111
|
-
let panel =
|
|
115
|
+
let panel = KeyableFloatingPanel(
|
|
112
116
|
contentRect: NSRect(x: origin.x, y: origin.y, width: width, height: height),
|
|
113
117
|
styleMask: [.titled, .closable, .fullSizeContentView],
|
|
114
118
|
backing: .buffered,
|
|
115
119
|
defer: false
|
|
116
120
|
)
|
|
121
|
+
panel.becomesKeyOnlyIfNeeded = false
|
|
117
122
|
panel.level = .floating
|
|
118
123
|
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
|
119
124
|
panel.titleVisibility = .hidden
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
type SystemNotificationCommandRunnerWithOutput,
|
|
6
6
|
type SystemNotificationsConfig,
|
|
7
7
|
} from './framework-menu-system-notifications-types';
|
|
8
|
-
import { resolveBlockedDialogEnabled } from './framework-menu-system-notifications-macos-dialog';
|
|
8
|
+
import { resolveBlockedDialogEnabled } from './framework-menu-system-notifications-macos-dialog-enabled';
|
|
9
9
|
import { emitMacOsBannerStage } from './framework-menu-system-notifications-macos-banner-stage';
|
|
10
10
|
import { emitMacOsBlockedDialogStage } from './framework-menu-system-notifications-macos-blocked-stage';
|
|
11
11
|
import { finalizeMacOsNotificationDelivery } from './framework-menu-system-notifications-macos-result';
|
|
@@ -13,6 +13,21 @@ import { finalizeMacOsNotificationDelivery } from './framework-menu-system-notif
|
|
|
13
13
|
export { runSystemCommand, runSystemCommandWithOutput } from './framework-menu-system-notifications-macos-runner';
|
|
14
14
|
export { resolveBlockedDialogEnabled } from './framework-menu-system-notifications-macos-dialog';
|
|
15
15
|
|
|
16
|
+
const shouldSkipMacOsBannerForInteractiveBlockedDialog = (params: {
|
|
17
|
+
event: PumukiCriticalNotificationEvent;
|
|
18
|
+
repoRoot?: string;
|
|
19
|
+
config: SystemNotificationsConfig;
|
|
20
|
+
env: NodeJS.ProcessEnv;
|
|
21
|
+
}): boolean => {
|
|
22
|
+
if (params.event.kind !== 'gate.blocked') {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
if (typeof params.repoRoot !== 'string') {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
return resolveBlockedDialogEnabled({ env: params.env, config: params.config });
|
|
29
|
+
};
|
|
30
|
+
|
|
16
31
|
export const deliverMacOsNotification = (params: {
|
|
17
32
|
event: PumukiCriticalNotificationEvent;
|
|
18
33
|
payload: Parameters<typeof emitMacOsBannerStage>[0]['payload'];
|
|
@@ -29,11 +44,20 @@ export const deliverMacOsNotification = (params: {
|
|
|
29
44
|
nowMs: number;
|
|
30
45
|
}) => void;
|
|
31
46
|
}): SystemNotificationEmitResult => {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
47
|
+
const skipBanner = shouldSkipMacOsBannerForInteractiveBlockedDialog({
|
|
48
|
+
event: params.event,
|
|
49
|
+
repoRoot: params.repoRoot,
|
|
50
|
+
config: params.config,
|
|
51
|
+
env: params.env,
|
|
35
52
|
});
|
|
36
53
|
|
|
54
|
+
const bannerResult = skipBanner
|
|
55
|
+
? { delivered: true as const, reason: 'delivered' as const }
|
|
56
|
+
: emitMacOsBannerStage({
|
|
57
|
+
payload: params.payload,
|
|
58
|
+
runCommand: params.runCommand,
|
|
59
|
+
});
|
|
60
|
+
|
|
37
61
|
emitMacOsBlockedDialogStage({
|
|
38
62
|
event: params.event,
|
|
39
63
|
repoRoot: params.repoRoot,
|