agentic-workflow-manager 9.7.1 → 9.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +5 -1
  2. package/dist/src/commands/job/exec-wrapper.js +53 -3
  3. package/dist/src/commands/plan/index.js +124 -6
  4. package/dist/src/commands/sensors/bootstrap.js +36 -5
  5. package/dist/src/commands/sensors/compatibility/contract.js +19 -4
  6. package/dist/src/commands/sensors/compatibility/discovery.js +23 -3
  7. package/dist/src/commands/sensors/compatibility/live.js +18 -1
  8. package/dist/src/commands/sensors/compatibility/probe.js +8 -2
  9. package/dist/src/commands/sensors/compatibility/resolve.js +5 -2
  10. package/dist/src/commands/sensors/coverage/evidence.js +19 -10
  11. package/dist/src/commands/sensors/coverage/index.js +11 -2
  12. package/dist/src/commands/sensors/coverage/resolve.js +10 -0
  13. package/dist/src/commands/sensors/exec.js +16 -1
  14. package/dist/src/commands/sensors/migrate.js +62 -2
  15. package/dist/src/commands/sensors/prepare.js +2 -0
  16. package/dist/src/commands/sensors/result.js +2 -0
  17. package/dist/src/commands/sensors/run.js +64 -1
  18. package/dist/src/commands/sensors/status.js +6 -4
  19. package/dist/src/commands/watch/archive-unused.js +138 -0
  20. package/dist/src/commands/watch/generations.js +32 -9
  21. package/dist/src/commands/watch/index.js +108 -2
  22. package/dist/src/commands/watch/init.js +149 -18
  23. package/dist/src/commands/watch/supervisor.js +167 -13
  24. package/dist/src/commands/watch/teardown-driver.js +13 -13
  25. package/dist/src/commands/watch/track-state.js +59 -0
  26. package/dist/src/commands/watch/tracks.js +38 -102
  27. package/dist/src/core/admission/index.js +181 -0
  28. package/dist/src/core/admission/registry-contracts.js +290 -0
  29. package/dist/src/core/journal/fingerprint.js +35 -1
  30. package/dist/src/core/journal/paths.js +11 -0
  31. package/dist/src/core/journal/store.js +146 -4
  32. package/dist/src/core/journal/types.js +35 -1
  33. package/dist/src/core/migration/historical-provenance.js +18 -0
  34. package/dist/src/core/migration/index.js +322 -0
  35. package/dist/src/core/plan/identity.js +75 -0
  36. package/dist/src/core/plan/validate.js +178 -64
  37. package/dist/tests/commands/job/exec-wrapper.test.js +16 -0
  38. package/dist/tests/commands/job/gate-reconcile.test.js +24 -10
  39. package/dist/tests/commands/pin.test.js +23 -23
  40. package/dist/tests/commands/plan/index.test.js +630 -14
  41. package/dist/tests/commands/sensors/bootstrap.test.js +57 -4
  42. package/dist/tests/commands/sensors/compatibility/contract.test.js +21 -0
  43. package/dist/tests/commands/sensors/compatibility/discovery.test.js +19 -0
  44. package/dist/tests/commands/sensors/compatibility/probe.test.js +14 -1
  45. package/dist/tests/commands/sensors/compatibility/resolve.test.js +17 -0
  46. package/dist/tests/commands/sensors/coverage/evidence.test.js +51 -27
  47. package/dist/tests/commands/sensors/coverage/resolve.test.js +51 -0
  48. package/dist/tests/commands/sensors/exec.test.js +112 -0
  49. package/dist/tests/commands/sensors/run-changed.test.js +11 -0
  50. package/dist/tests/commands/sensors/run.test.js +15 -2
  51. package/dist/tests/commands/sensors/status.test.js +3 -3
  52. package/dist/tests/commands/watch/e2e-crash.test.js +126 -11
  53. package/dist/tests/commands/watch/generations.test.js +113 -4
  54. package/dist/tests/commands/watch/plan-lifecycle.test.js +263 -0
  55. package/dist/tests/commands/watch/supervisor-loop.test.js +247 -49
  56. package/dist/tests/commands/watch/track-finalize.test.js +20 -3
  57. package/dist/tests/commands/watch/track-freeze.test.js +43 -30
  58. package/dist/tests/commands/watch/track-state-dependency.test.js +15 -0
  59. package/dist/tests/commands/watch/watch-init.test.js +244 -0
  60. package/dist/tests/core/admission/index.test.js +87 -0
  61. package/dist/tests/core/journal/fingerprint.test.js +22 -0
  62. package/dist/tests/core/journal/store.test.js +53 -0
  63. package/dist/tests/core/journal/types.test.js +7 -0
  64. package/dist/tests/core/migration/facts.test.js +289 -0
  65. package/dist/tests/core/migration/historical-provenance.test.js +25 -0
  66. package/dist/tests/core/plan/validate.test.js +320 -6
  67. package/dist/tests/integration/pack-e2e.test.js +4 -1
  68. package/dist/tests/integration/preflight-json-pipe.e2e.test.js +7 -3
  69. package/dist/tests/integration/sensor-bootstrap.e2e.test.js +37 -0
  70. package/dist/tests/integration/sensor-structured-run.e2e.test.js +12 -10
  71. package/dist/tests/providers/injection-config.test.js +14 -6
  72. package/dist/tests/structural/jest-environment-is-isolated.test.js +29 -24
  73. package/dist/tests/structural/native-release-artifacts.test.js +16 -0
  74. package/package.json +2 -2
  75. package/prebuilds/darwin-arm64/secure_fs.node +0 -0
  76. package/prebuilds/darwin-x64/secure_fs.node +0 -0
  77. package/prebuilds/win32-arm64/secure_fs.node +0 -0
  78. package/prebuilds/win32-x64/secure_fs.node +0 -0
@@ -25,11 +25,28 @@ const tracks_1 = require("../../../src/commands/watch/tracks");
25
25
  const supervisor_1 = require("../../../src/commands/watch/supervisor");
26
26
  const exec_wrapper_1 = require("../../../src/commands/job/exec-wrapper");
27
27
  const init_1 = require("../../../src/commands/watch/init");
28
+ const validate_1 = require("../../../src/core/plan/validate");
28
29
  const request_1 = require("../../../src/commands/job/request");
29
30
  const requests_1 = require("../../../src/core/journal/requests");
30
31
  const fingerprint_1 = require("../../../src/core/journal/fingerprint");
31
32
  const store_1 = require("../../../src/core/journal/store");
32
33
  const paths_1 = require("../../../src/core/journal/paths");
34
+ const admitted = async () => ({
35
+ state: 'admitted', planState: 'valid', executionMode: 'desatendido',
36
+ journal: 'current', currentness: 'current', sensors: 'pass', diagnostics: [],
37
+ });
38
+ function initCompactWatch(repoRoot, branch) {
39
+ const planPath = path_1.default.join(repoRoot, 'plans', 'fixture.md');
40
+ fs_1.default.mkdirSync(path_1.default.dirname(planPath), { recursive: true });
41
+ fs_1.default.writeFileSync(planPath, fs_1.default.readFileSync(path_1.default.join(__dirname, '../../core/plan/fixtures/compact-slices-v1/valid.md'), 'utf8'));
42
+ fs_1.default.writeFileSync(path_1.default.join(repoRoot, 'source.md'), '## Canonical source\nfixture source\n');
43
+ git(repoRoot, 'add', 'plans/fixture.md', 'source.md');
44
+ git(repoRoot, 'commit', '-qm', 'compact plan fixture');
45
+ const report = (0, validate_1.validatePlanFile)('plans/fixture.md', repoRoot);
46
+ if (report.state !== 'valid')
47
+ throw new Error(`compact fixture must validate: ${JSON.stringify(report)}`);
48
+ (0, init_1.initWatch)(repoRoot, branch, { path: 'plans/fixture.md', report });
49
+ }
33
50
  function git(cwd, ...args) {
34
51
  (0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', ...args], { cwd, stdio: 'pipe' });
35
52
  }
@@ -283,7 +300,7 @@ describe('Supervisor.tick() — freeze de un track individual (R5.2/R6.3)', () =
283
300
  payload: { verdictId, obligationId, result: 'pass', detail: 'ok', fingerprint: fp, argv, paths: [], cwd: '.' } });
284
301
  }
285
302
  function setUpGreenTrack() {
286
- (0, init_1.initWatch)(repo, BRANCH);
303
+ initCompactWatch(repo, BRANCH);
287
304
  // `initWatch` ESCRIBE `.gitignore` (`.awm/`) pero no lo commitea — un
288
305
  // `.gitignore` sin trackear es en sí mismo un path "untracked" para
289
306
  // `git status --porcelain` (R6.4's chequeo de limpieza no distingue
@@ -328,7 +345,7 @@ describe('Supervisor.tick() — freeze de un track individual (R5.2/R6.3)', () =
328
345
  test('freeze completo: drena jobs vivos, exige gate local + worktree limpio, termina la generación propia, persiste frozen (R5.2/R6.3)', async () => {
329
346
  setUpGreenTrack(); // sin freeze todavía: el track hace su trabajo real primero, como en el mundo real
330
347
  const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, tickMs: 10, provider: 'codex' };
331
- const sup = new supervisor_1.Supervisor(repo, BRANCH, cfg, fakeSpawner);
348
+ const sup = new supervisor_1.Supervisor(repo, BRANCH, cfg, fakeSpawner, undefined, admitted);
332
349
  // Drenar el trabajo real (2 jobs) ANTES de que el plan pida el
333
350
  // freeze — mismo orden que el mundo real (freeze llega cuando el
334
351
  // track ya terminó su verificación).
@@ -351,7 +368,7 @@ describe('Supervisor.tick() — freeze de un track individual (R5.2/R6.3)', () =
351
368
  test('job varado en spawn-intent con refs muertas justo al pedirse el freeze SI se reintenta y drena — el freeze converge en vez de quedar en `continue` para siempre (regresión R6.3: `dispatch:false` no debe suprimir retry-same-intent)', async () => {
352
369
  setUpGreenTrack();
353
370
  const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, tickMs: 10, provider: 'codex' };
354
- const sup = new supervisor_1.Supervisor(repo, BRANCH, cfg, fakeSpawner);
371
+ const sup = new supervisor_1.Supervisor(repo, BRANCH, cfg, fakeSpawner, undefined, admitted);
355
372
  await tickUntil(sup, () => {
356
373
  const s = (0, store_1.readJournal)(repo, BRANCH).state;
357
374
  return Object.values(s.jobs).length === 2 && Object.values(s.jobs).every((j) => j.executionState === 'exited' && j.verdict === 'pass');
@@ -365,10 +382,11 @@ describe('Supervisor.tick() — freeze de un track individual (R5.2/R6.3)', () =
365
382
  // `setUpGreenTrack`) — lo unico que debe bloquear la convergencia
366
383
  // del freeze es que siga contando como LIVE en `attemptFreeze`.
367
384
  const dead = { pid: 999999, startTime: 'gone', spawnNonce: 'stuck-nonce', argvDigest: 'd', processGroup: 999999, psArgsDigest: 'x' };
385
+ const fingerprint = (0, fingerprint_1.computeFingerprint)(repo, ['node', '-e', 'process.exit(0)'], [], '.');
368
386
  const s1 = (0, store_1.readJournal)(repo, BRANCH).state;
369
387
  s1.jobs['stuck-job'] = {
370
- id: 'stuck-job', fingerprint: 'fp', commandDigest: 'cd', argv: ['node', '-e', 'process.exit(0)'], cwd: '.',
371
- paths: [], expandedPaths: [], executionState: 'spawn-intent', observationState: 'progressing',
388
+ id: 'stuck-job', fingerprint: fingerprint.fingerprint, commandDigest: fingerprint.commandDigest, argv: ['node', '-e', 'process.exit(0)'], cwd: '.',
389
+ paths: [], expandedPaths: fingerprint.expandedPaths, executionState: 'spawn-intent', observationState: 'progressing',
372
390
  spawnNonce: 'stuck-nonce', processRef: dead, wrapperRef: dead,
373
391
  phaseTimestamps: { received: new Date(Date.now() - 120000).toISOString(), 'spawn-intent': new Date(Date.now() - 120000).toISOString() },
374
392
  };
@@ -389,7 +407,7 @@ describe('Supervisor.tick() — freeze de un track individual (R5.2/R6.3)', () =
389
407
  expect(final.frozen.headSha).toBe((0, child_process_1.execFileSync)('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim());
390
408
  });
391
409
  test('deja de despachar trabajo NUEVO mientras el freeze está pendiente: un job-request llega pero no se spawnea (paso 1)', async () => {
392
- (0, init_1.initWatch)(repo, BRANCH);
410
+ initCompactWatch(repo, BRANCH);
393
411
  const s = (0, store_1.readJournal)(repo, BRANCH).state;
394
412
  s.trackContext = { trackId: 'a', taskIds: [], planDigest: '', baseSha: 'seed', planJournalId: 'j-plan' };
395
413
  (0, store_1.writeJournal)(repo, BRANCH, s);
@@ -397,7 +415,7 @@ describe('Supervisor.tick() — freeze de un track individual (R5.2/R6.3)', () =
397
415
  let spawnCalls = 0;
398
416
  const countingSpawner = () => { spawnCalls++; };
399
417
  const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, tickMs: 10, provider: 'codex' };
400
- const sup = new supervisor_1.Supervisor(repo, BRANCH, cfg, countingSpawner);
418
+ const sup = new supervisor_1.Supervisor(repo, BRANCH, cfg, countingSpawner, undefined, admitted);
401
419
  await sup.tick(); // consume el freeze-request => freezeRequested:true
402
420
  (0, request_1.requestJob)(repo, BRANCH, 'g0', ['node', '-e', 'process.exit(0)'], [], '.');
403
421
  await sup.tick();
@@ -407,36 +425,31 @@ describe('Supervisor.tick() — freeze de un track individual (R5.2/R6.3)', () =
407
425
  expect(after.freezeRequested).toBe(true);
408
426
  expect(Object.values(after.jobs)[0].executionState).toBe('received'); // nunca avanzó a spawn-intent
409
427
  });
410
- test('worktree sucio bloquea la finalización del freeze hasta que se commitea (paso 4)', async () => {
428
+ test('worktree sucio observado en modo compacto entra en custodia y no congela evidencia potencialmente stale', async () => {
411
429
  setUpGreenTrack();
412
430
  const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, tickMs: 10, provider: 'codex' };
413
- const sup = new supervisor_1.Supervisor(repo, BRANCH, cfg, fakeSpawner);
431
+ const sup = new supervisor_1.Supervisor(repo, BRANCH, cfg, fakeSpawner, undefined, admitted);
414
432
  await tickUntil(sup, () => {
415
433
  const s = (0, store_1.readJournal)(repo, BRANCH).state;
416
434
  return Object.values(s.jobs).length === 2 && Object.values(s.jobs).every((j) => j.executionState === 'exited' && j.verdict === 'pass');
417
435
  });
418
436
  requestFreeze();
437
+ // Los jobs ya terminados no participan del guard de árbol de recovery.
438
+ // Este intento vivo, fuera del plan de verificación, aísla el hecho
439
+ // relevante: el cambio posterior invalida SU fingerprint y debe
440
+ // bloquear antes de intentar congelar.
441
+ const fingerprint = (0, fingerprint_1.computeFingerprint)(repo, ['node', '-e', 'process.exit(0)'], [], '.');
442
+ const state = (0, store_1.readJournal)(repo, BRANCH).state;
443
+ state.jobs['fingerprint-sentinel'] = {
444
+ id: 'fingerprint-sentinel', fingerprint: fingerprint.fingerprint, commandDigest: fingerprint.commandDigest,
445
+ argv: ['node', '-e', 'process.exit(0)'], cwd: '.', paths: [], expandedPaths: fingerprint.expandedPaths,
446
+ executionState: 'spawn-intent', observationState: 'progressing', phaseTimestamps: { received: new Date().toISOString(), 'spawn-intent': new Date().toISOString() },
447
+ };
448
+ (0, store_1.writeJournal)(repo, BRANCH, state);
419
449
  fs_1.default.writeFileSync(path_1.default.join(repo, 'dirty.txt'), 'sin commitear'); // untracked: worktree sucio
420
- let sawFrozenWhileDirty = false;
421
- for (let i = 0; i < 20; i++) {
422
- const outcome = await sup.tick();
423
- if (outcome === 'frozen')
424
- sawFrozenWhileDirty = true;
425
- await new Promise((r) => setTimeout(r, 15));
426
- }
427
- expect(sawFrozenWhileDirty).toBe(false);
450
+ expect(await sup.tick()).toBe('custody');
428
451
  expect((0, store_1.readJournal)(repo, BRANCH).state.frozen).toBeUndefined();
429
- // Se resuelve lo pendiente QUITANDO el archivo sin commitear (en vez
430
- // de commitearlo): commitear un archivo NUEVO cambia el árbol que
431
- // las evidencias ya persistidas (`v1`/`v-sensors`) fingerprintearon,
432
- // invalidándolas de verdad (`stale-fingerprint`) — comportamiento
433
- // CORRECTO del gate, pero un concern DISTINTO al que este test
434
- // ejercita (paso 4, limpieza — no re-vigencia de evidencia, ya
435
- // cubierta en `gate.test.ts`). Quitar el archivo deja el árbol
436
- // BYTE A BYTE igual al que las evidencias ya certificaron.
437
- fs_1.default.rmSync(path_1.default.join(repo, 'dirty.txt'));
438
- const outcome = await tickUntil(sup, () => (0, store_1.readJournal)(repo, BRANCH).state.frozen !== undefined);
439
- expect(outcome).toBe('frozen');
452
+ expect((0, store_1.readJournal)(repo, BRANCH).state.cycle.blockedReason).toContain('git-fingerprint-changed');
440
453
  });
441
454
  });
442
455
  // --- Parte 3: loop end-to-end de DOS Supervisors reales ---------------------
@@ -512,7 +525,7 @@ describe('loop end-to-end: plan (reconcileTracks) + track (Supervisor.tick) cier
512
525
  }
513
526
  test('reconcileTracks real (plan) + Supervisor.tick real (track), en loop contra dos worktrees reales, cierran request -> drenaje -> persist -> observación -> FROZEN', async () => {
514
527
  // --- lado TRACK: registrar el trabajo y drenarlo con un Supervisor REAL ---
515
- (0, init_1.initWatch)(trackWorktree, TRACK_BRANCH);
528
+ initCompactWatch(trackWorktree, TRACK_BRANCH);
516
529
  const ts0 = (0, store_1.readJournal)(trackWorktree, TRACK_BRANCH).state;
517
530
  ts0.trackContext = { trackId: 'a', taskIds: ['T1'], planDigest: '', baseSha, planJournalId: 'j-plan' };
518
531
  (0, store_1.writeJournal)(trackWorktree, TRACK_BRANCH, ts0);
@@ -525,7 +538,7 @@ describe('loop end-to-end: plan (reconcileTracks) + track (Supervisor.tick) cier
525
538
  (0, requests_1.emitRequest)(trackWorktree, TRACK_BRANCH, { kind: 'register-entity', generationToken: 'g0', idempotencyKey: 'e3',
526
539
  payload: { entity: 'task-status', taskId: 'T1', status: 'done' } });
527
540
  const trackCfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, tickMs: 10, provider: 'codex' };
528
- const trackSup = new supervisor_1.Supervisor(trackWorktree, TRACK_BRANCH, trackCfg, fakeSpawner);
541
+ const trackSup = new supervisor_1.Supervisor(trackWorktree, TRACK_BRANCH, trackCfg, fakeSpawner, undefined, admitted);
529
542
  for (let i = 0; i < 400; i++) {
530
543
  const s = (0, store_1.readJournal)(trackWorktree, TRACK_BRANCH).state;
531
544
  if (Object.values(s.jobs).length === 2 && Object.values(s.jobs).every((j) => j.executionState === 'exited' && j.verdict === 'pass'))
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const fs_1 = __importDefault(require("fs"));
7
+ const path_1 = __importDefault(require("path"));
8
+ const watchRoot = path_1.default.resolve(__dirname, '../../../src/commands/watch');
9
+ describe('watch state dependency boundary', () => {
10
+ test('teardown driver consumes shared state through the neutral track-state module', () => {
11
+ const source = fs_1.default.readFileSync(path_1.default.join(watchRoot, 'teardown-driver.ts'), 'utf8');
12
+ expect(source).toContain("from './track-state'");
13
+ expect(source).not.toMatch(/from '\.\/tracks';/);
14
+ });
15
+ });
@@ -6,8 +6,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const fs_1 = __importDefault(require("fs"));
7
7
  const path_1 = __importDefault(require("path"));
8
8
  const os_1 = __importDefault(require("os"));
9
+ const child_process_1 = require("child_process");
10
+ const commander_1 = require("commander");
9
11
  const init_1 = require("../../../src/commands/watch/init");
12
+ const watch_1 = require("../../../src/commands/watch");
10
13
  const store_1 = require("../../../src/core/journal/store");
14
+ const paths_1 = require("../../../src/core/journal/paths");
15
+ const validate_1 = require("../../../src/core/plan/validate");
16
+ const git_fixture_1 = require("../../helpers/git-fixture");
11
17
  describe('watch --init: plan-vs-repo mecanico', () => {
12
18
  let repo;
13
19
  beforeEach(() => { repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-init-')); });
@@ -32,6 +38,117 @@ describe('watch --init: plan-vs-repo mecanico', () => {
32
38
  test('repo sin verificadores => lista vacia (el gate degrada por empty-cycle-plan igualmente, R3.6)', () => {
33
39
  expect((0, init_1.detectRequiredVerifiers)(repo)).toEqual([]);
34
40
  });
41
+ test('falla cerradamente ante un arbol de paquetes demasiado profundo o grande', () => {
42
+ let cursor = repo;
43
+ for (let depth = 0; depth < 65; depth++) {
44
+ cursor = path_1.default.join(cursor, `d${depth}`);
45
+ fs_1.default.mkdirSync(cursor);
46
+ }
47
+ expect(() => (0, init_1.detectRequiredVerifiers)(repo)).toThrow(/límite|limit/i);
48
+ });
49
+ test('falla cerradamente ante package.json que excede el límite de lectura', () => {
50
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'package.json'), '{"scripts":{"test":"x"},"padding":"' + 'x'.repeat(1024 * 1024) + '"}');
51
+ expect(() => (0, init_1.detectRequiredVerifiers)(repo)).toThrow(/package\.json.*(?:límite|limit|oversized)/i);
52
+ });
53
+ test('falla cerradamente al superar el límite de entradas del escaneo', () => {
54
+ for (let index = 0; index <= 10000; index++)
55
+ fs_1.default.writeFileSync(path_1.default.join(repo, `entry-${index}`), '');
56
+ expect(() => (0, init_1.detectRequiredVerifiers)(repo)).toThrow(/entry limit/i);
57
+ });
58
+ test('falla cerradamente ante un package.json ilegible o malformado', () => {
59
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'package.json'), '{not json');
60
+ expect(() => (0, init_1.detectRequiredVerifiers)(repo)).toThrow(/rejected package\.json/i);
61
+ });
62
+ test.each(['growth', 'leaf-swap', 'parent-swap'])('rejects stale Dirent %s before journal or gitignore mutations', attack => {
63
+ const directory = attack === 'parent-swap' ? path_1.default.join(repo, 'child') : repo;
64
+ fs_1.default.mkdirSync(directory, { recursive: true });
65
+ const packageFile = path_1.default.join(directory, 'package.json');
66
+ fs_1.default.writeFileSync(packageFile, '{"scripts":{"test":"jest"}}');
67
+ const outside = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-init-outside-'));
68
+ fs_1.default.writeFileSync(path_1.default.join(outside, 'package.json'), '{"scripts":{"test":"outside-command"}}');
69
+ fs_1.default.writeFileSync(path_1.default.join(repo, '.gitignore'), 'existing-rule\n');
70
+ const nativeReaddir = fs_1.default.readdirSync;
71
+ const nativeStat = fs_1.default.statSync;
72
+ let attacked = false;
73
+ const stat = jest.spyOn(fs_1.default, 'statSync').mockImplementation(((...args) => {
74
+ if (String(args[0]) === packageFile && attack === 'growth')
75
+ return { size: 2 };
76
+ return Reflect.apply(nativeStat, fs_1.default, args);
77
+ }));
78
+ const readdir = jest.spyOn(fs_1.default, 'readdirSync').mockImplementation(((...args) => {
79
+ const entries = Reflect.apply(nativeReaddir, fs_1.default, args);
80
+ if (!attacked && String(args[0]) === directory) {
81
+ attacked = true;
82
+ if (attack === 'growth')
83
+ fs_1.default.writeFileSync(packageFile, JSON.stringify({ scripts: { test: 'jest' }, padding: 'x'.repeat(256 * 1024) }));
84
+ else if (attack === 'leaf-swap') {
85
+ fs_1.default.renameSync(packageFile, `${packageFile}.original`);
86
+ fs_1.default.symlinkSync(path_1.default.join(outside, 'package.json'), packageFile, 'file');
87
+ }
88
+ else {
89
+ fs_1.default.renameSync(directory, `${directory}.original`);
90
+ fs_1.default.symlinkSync(outside, directory, 'dir');
91
+ }
92
+ }
93
+ return entries;
94
+ }));
95
+ try {
96
+ expect(() => (0, init_1.initWatch)(repo, 'rama')).toThrow(/package\.json|bounded|regular|unsafe/i);
97
+ expect(attacked).toBe(true);
98
+ expect(fs_1.default.readFileSync(path_1.default.join(repo, '.gitignore'), 'utf8')).toBe('existing-rule\n');
99
+ expect((0, store_1.readJournal)(repo, 'rama').state).toBeNull();
100
+ }
101
+ finally {
102
+ stat.mockRestore();
103
+ readdir.mockRestore();
104
+ fs_1.default.rmSync(outside, { recursive: true, force: true });
105
+ }
106
+ });
107
+ test.each(['symlink', 'directory'])('rejects an explicitly declared nonregular package.json %s rather than treating it as absence', kind => {
108
+ const packageFile = path_1.default.join(repo, 'package.json');
109
+ if (kind === 'directory')
110
+ fs_1.default.mkdirSync(packageFile);
111
+ else {
112
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'outside-package'), '{}');
113
+ fs_1.default.symlinkSync(path_1.default.join(repo, 'outside-package'), packageFile, 'file');
114
+ }
115
+ fs_1.default.writeFileSync(path_1.default.join(repo, '.gitignore'), 'unchanged\n');
116
+ expect(() => (0, init_1.initWatch)(repo, 'rama')).toThrow(/package\.json|regular/i);
117
+ expect(fs_1.default.readFileSync(path_1.default.join(repo, '.gitignore'), 'utf8')).toBe('unchanged\n');
118
+ expect((0, store_1.readJournal)(repo, 'rama').state).toBeNull();
119
+ });
120
+ test.each(['symlink', 'dangling-link', 'directory', 'oversized', 'malformed'])('rejects unsafe sibling sensors.json %s before init mutations', fault => {
121
+ fs_1.default.mkdirSync(path_1.default.join(repo, '.awm'));
122
+ const sensors = path_1.default.join(repo, '.awm', 'sensors.json');
123
+ if (fault === 'directory')
124
+ fs_1.default.mkdirSync(sensors);
125
+ else if (fault === 'symlink' || fault === 'dangling-link') {
126
+ const destination = path_1.default.join(repo, 'outside-sensors');
127
+ if (fault === 'symlink')
128
+ fs_1.default.writeFileSync(destination, '{}');
129
+ fs_1.default.symlinkSync(destination, sensors, 'file');
130
+ }
131
+ else
132
+ fs_1.default.writeFileSync(sensors, fault === 'oversized' ? ' '.repeat(256 * 1024 + 1) : '{bad json');
133
+ fs_1.default.writeFileSync(path_1.default.join(repo, '.gitignore'), 'unchanged\n');
134
+ expect(() => (0, init_1.initWatch)(repo, 'rama')).toThrow(/sensors\.json/i);
135
+ expect(fs_1.default.readFileSync(path_1.default.join(repo, '.gitignore'), 'utf8')).toBe('unchanged\n');
136
+ expect((0, store_1.readJournal)(repo, 'rama').state).toBeNull();
137
+ });
138
+ test('falla cerradamente cuando no puede leer un directorio durante el escaneo', () => {
139
+ const original = fs_1.default.readdirSync;
140
+ const readdir = jest.spyOn(fs_1.default, 'readdirSync').mockImplementation(((target, options) => {
141
+ if (String(target) === repo)
142
+ throw new Error('EACCES');
143
+ return original(target, options);
144
+ }));
145
+ try {
146
+ expect(() => (0, init_1.detectRequiredVerifiers)(repo)).toThrow(/cannot read/i);
147
+ }
148
+ finally {
149
+ readdir.mockRestore();
150
+ }
151
+ });
35
152
  test('initWatch persiste requiredVerifiers y gitignorea el journal (R1.1/R1.4b)', () => {
36
153
  fs_1.default.writeFileSync(path_1.default.join(repo, 'package.json'), JSON.stringify({ scripts: { test: 'jest' } }));
37
154
  const out = (0, init_1.initWatch)(repo, 'rama');
@@ -40,4 +157,131 @@ describe('watch --init: plan-vs-repo mecanico', () => {
40
157
  expect(fs_1.default.readFileSync(path_1.default.join(repo, '.gitignore'), 'utf8')).toContain('.awm/');
41
158
  expect(() => (0, init_1.initWatch)(repo, 'rama')).not.toThrow(); // idempotente
42
159
  });
160
+ test('no escribe .gitignore cuando es un symlink', () => {
161
+ const outside = path_1.default.join(repo, 'outside-gitignore');
162
+ fs_1.default.writeFileSync(outside, 'outside\n');
163
+ fs_1.default.symlinkSync(outside, path_1.default.join(repo, '.gitignore'));
164
+ expect(() => (0, init_1.initWatch)(repo, 'rama')).toThrow(/gitignore.*symlink|symlink.*gitignore/i);
165
+ expect(fs_1.default.readFileSync(outside, 'utf8')).toBe('outside\n');
166
+ });
167
+ test('watch --init --plan crea una sola vinculacion schema-2 desatendida', () => {
168
+ const plan = {
169
+ state: 'valid', schema: 'compact-slices/v1', planDigest: 'a'.repeat(64),
170
+ executionMode: 'desatendido',
171
+ manifest: { schema: 'compact-slices/v1', planId: 'fixture', requirements: [], sources: [], commands: [], slices: [], closureCommands: [] },
172
+ };
173
+ const out = (0, init_1.initWatch)(repo, 'rama', { path: 'docs/plan.md', report: plan });
174
+ expect(out.planBinding).toEqual(expect.objectContaining({ path: 'docs/plan.md', digest: 'a'.repeat(64), schema: 'compact-slices/v1', executionMode: 'desatendido' }));
175
+ const state = (0, store_1.readJournal)(repo, 'rama').state;
176
+ expect(state.schema).toBe(2);
177
+ expect(state.planBinding).toEqual(expect.objectContaining({ path: 'docs/plan.md', digest: 'a'.repeat(64) }));
178
+ expect(() => (0, init_1.initWatch)(repo, 'rama', { path: 'docs/plan.md', report: plan })).toThrow(/sobrescribir|overwrite/i);
179
+ });
180
+ test('watch --init --plan bloquea un plan no valido antes de crear journal', () => {
181
+ const invalid = { state: 'invalid', diagnostics: [{ code: 'PLAN_SHAPE', message: 'bad' }] };
182
+ expect(() => (0, init_1.initWatch)(repo, 'rama', { path: 'docs/plan.md', report: invalid })).toThrow(/válido/i);
183
+ expect((0, store_1.readJournal)(repo, 'rama').corrupt).toBe(true);
184
+ });
185
+ test('watch --init --plan rechaza un plan interactivo antes de crear un binding desatendido', () => {
186
+ const interactive = { ...validPlan('a'), executionMode: 'interactivo' };
187
+ expect(() => (0, init_1.initWatch)(repo, 'rama', { path: 'docs/plan.md', report: interactive })).toThrow(/desatendido/i);
188
+ expect((0, store_1.readJournal)(repo, 'rama').state).toBeNull();
189
+ });
190
+ test('watch --init --plan rechaza paths con caracteres de control antes de persistir', () => {
191
+ const report = { state: 'valid', schema: 'compact-slices/v1', planDigest: 'a'.repeat(64), executionMode: 'desatendido', manifest: {} };
192
+ expect(() => (0, init_1.initWatch)(repo, 'rama', { path: 'docs/plan\u0000.md', report })).toThrow(/path inválido/);
193
+ expect((0, store_1.readJournal)(repo, 'rama').state).toBeNull();
194
+ });
195
+ test('rebind no atribuye prueba nueva a un binding legacy aunque el ciclo diga COMPLETE', () => {
196
+ const original = validPlan('a');
197
+ (0, init_1.initWatch)(repo, 'rama', { path: 'docs/plan.md', report: original });
198
+ const completed = (0, store_1.readJournal)(repo, 'rama').state;
199
+ completed.cycle.status = 'COMPLETE';
200
+ delete completed.cycle.nextAction;
201
+ (0, store_1.writeJournal)(repo, 'rama', completed);
202
+ const before = (0, store_1.readJournal)(repo, 'rama').raw;
203
+ expect(() => (0, init_1.rebindWatchPlan)(repo, 'rama', { path: 'docs/plan.md', report: validPlan('b') })).toThrow(/sin prueba/i);
204
+ expect((0, store_1.readJournal)(repo, 'rama').raw).toBe(before);
205
+ expect(fs_1.default.existsSync((0, paths_1.supervisorLockPath)(repo))).toBe(false);
206
+ });
207
+ test('rebind rechaza un ciclo en curso y conserva toda su evidencia ligada al digest anterior', () => {
208
+ (0, init_1.initWatch)(repo, 'rama', { path: 'docs/plan.md', report: validPlan('a') });
209
+ const before = (0, store_1.readJournal)(repo, 'rama').raw;
210
+ expect(() => (0, init_1.rebindWatchPlan)(repo, 'rama', { path: 'docs/plan.md', report: validPlan('b') })).toThrow(/ciclo.*curso/i);
211
+ expect((0, store_1.readJournal)(repo, 'rama').raw).toBe(before);
212
+ });
213
+ test('rebind rechaza una ruta canónica distinta y deja el journal byte-a-byte intacto', () => {
214
+ (0, init_1.initWatch)(repo, 'rama', { path: 'docs/plan.md', report: validPlan('a') });
215
+ const before = (0, store_1.readJournal)(repo, 'rama').raw;
216
+ expect(() => (0, init_1.rebindWatchPlan)(repo, 'rama', { path: 'docs/other.md', report: validPlan('b') })).toThrow(/misma ruta/i);
217
+ expect((0, store_1.readJournal)(repo, 'rama').raw).toBe(before);
218
+ });
219
+ test('rebind rechaza un binding ya vigente y no inventa historia duplicada', () => {
220
+ (0, init_1.initWatch)(repo, 'rama', { path: 'docs/plan.md', report: validPlan('a') });
221
+ const before = (0, store_1.readJournal)(repo, 'rama').raw;
222
+ expect(() => (0, init_1.rebindWatchPlan)(repo, 'rama', { path: 'docs/plan.md', report: validPlan('a') })).toThrow(/ya está vigente/i);
223
+ expect((0, store_1.readJournal)(repo, 'rama').raw).toBe(before);
224
+ });
225
+ test('rebind rechaza evidencia adversa durable y no reescribe el binding obsoleto', () => {
226
+ (0, init_1.initWatch)(repo, 'rama', { path: 'docs/plan.md', report: validPlan('a') });
227
+ const state = (0, store_1.readJournal)(repo, 'rama').state;
228
+ state.verdicts.push({ id: 'v-1', obligationId: 'o-1', result: 'fail', detail: 'adverso', receivedAt: new Date().toISOString(), fingerprint: '', argv: [], paths: [], cwd: '.' });
229
+ (0, store_1.writeJournal)(repo, 'rama', state);
230
+ expect(() => (0, init_1.rebindWatchPlan)(repo, 'rama', { path: 'docs/plan.md', report: validPlan('b') })).toThrow(/evidencia adversa/i);
231
+ expect((0, store_1.readJournal)(repo, 'rama').state.planBinding.digest).toBe('a'.repeat(64));
232
+ });
233
+ test('rebind rechaza jobs no terminales y conserva el estado recuperable', () => {
234
+ (0, init_1.initWatch)(repo, 'rama', { path: 'docs/plan.md', report: validPlan('a') });
235
+ const state = (0, store_1.readJournal)(repo, 'rama').state;
236
+ state.jobs.pending = { id: 'pending', fingerprint: '', commandDigest: '', argv: [], cwd: '.', paths: [], expandedPaths: [], executionState: 'received', observationState: 'progressing', phaseTimestamps: {} };
237
+ (0, store_1.writeJournal)(repo, 'rama', state);
238
+ const before = (0, store_1.readJournal)(repo, 'rama').raw;
239
+ expect(() => (0, init_1.rebindWatchPlan)(repo, 'rama', { path: 'docs/plan.md', report: validPlan('b') })).toThrow(/no terminales/i);
240
+ expect((0, store_1.readJournal)(repo, 'rama').raw).toBe(before);
241
+ expect(fs_1.default.existsSync((0, paths_1.supervisorLockPath)(repo))).toBe(false);
242
+ });
243
+ test('help declara rebind como ruta intencional separada de --init', () => {
244
+ const program = new commander_1.Command();
245
+ (0, watch_1.registerWatchCommand)(program);
246
+ const watch = program.commands.find(command => command.name() === 'watch');
247
+ expect(watch.helpInformation()).toContain('rebind');
248
+ expect(watch.commands.find(command => command.name() === 'rebind').description()).toMatch(/reconcilia/i);
249
+ });
250
+ test('CLI compilado acepta exactamente `watch rebind --plan` y reconcilia el digest', () => {
251
+ const cliRepo = (0, git_fixture_1.initRepo)();
252
+ try {
253
+ const planPath = path_1.default.join(cliRepo, 'docs', 'plan.md');
254
+ fs_1.default.mkdirSync(path_1.default.dirname(planPath), { recursive: true });
255
+ const plan = `**Modo de ejecución:** desatendido\n\n${fs_1.default.readFileSync(path_1.default.join(__dirname, '../../core/plan/fixtures/compact-slices-v1/valid.md'), 'utf8')}`;
256
+ fs_1.default.writeFileSync(planPath, plan);
257
+ fs_1.default.writeFileSync(path_1.default.join(cliRepo, 'source.md'), '## Canonical source\nfixture source\n');
258
+ (0, init_1.initWatch)(cliRepo, 'main', { path: 'docs/plan.md', report: validatePlan(cliRepo, 'docs/plan.md') });
259
+ fs_1.default.appendFileSync(planPath, '<!-- awm-qa-complete: 2026-09-16 -->\n');
260
+ const result = (0, child_process_1.spawnSync)(process.execPath, [path_1.default.resolve(__dirname, '../../../dist/src/index.js'), 'watch', 'rebind', '--plan', 'docs/plan.md'], {
261
+ cwd: cliRepo, encoding: 'utf8', env: { ...process.env, AWM_NO_UPDATE_CHECK: '1' },
262
+ });
263
+ expect(result.status).toBe(0);
264
+ expect(result.stderr).not.toContain('required option');
265
+ expect((0, store_1.readJournal)(cliRepo, 'main').state.planBinding.digest).toBe(validatePlan(cliRepo, 'docs/plan.md').planDigest);
266
+ expect((0, store_1.readJournal)(cliRepo, 'main').state.cycle.status).toBe('IN_PROGRESS');
267
+ }
268
+ finally {
269
+ fs_1.default.rmSync(cliRepo, { recursive: true, force: true });
270
+ }
271
+ });
43
272
  });
273
+ function validPlan(digestCharacter) {
274
+ return {
275
+ state: 'valid', schema: 'compact-slices/v1', planDigest: digestCharacter.repeat(64),
276
+ executionMode: 'desatendido',
277
+ manifest: { schema: 'compact-slices/v1', planId: 'fixture', requirements: [], sources: [], commands: [], slices: [], closureCommands: [] },
278
+ };
279
+ }
280
+ function validatePlan(repo, relativePath) {
281
+ // Keep the external process test tied to the real compact validator, not a
282
+ // hand-built report that could diverge from a lifecycle edit on disk.
283
+ const report = (0, validate_1.validatePlanFile)(relativePath, repo);
284
+ if (report.state !== 'valid')
285
+ throw new Error(`fixture plan unexpectedly invalid: ${JSON.stringify(report)}`);
286
+ return report;
287
+ }
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const admission_1 = require("../../../src/core/admission");
4
+ const types_1 = require("../../../src/core/journal/types");
5
+ const valid = {
6
+ state: 'valid', schema: 'compact-slices/v1', planDigest: 'a'.repeat(64),
7
+ manifest: {
8
+ schema: 'compact-slices/v1', planId: 'admission-fixture', requirements: ['RF-2.2'],
9
+ sources: [], commands: [], closureCommands: [],
10
+ slices: [{ id: 'S1', title: 'one', requirements: ['RF-2.2'], dependsOn: [], sectionAnchor: 's1', sources: [], redCommands: [], greenCommands: [], reviewEvidence: ['specification', 'code-quality'], risk: 'bounded', fallback: [] }],
11
+ },
12
+ };
13
+ describe('admitPlan', () => {
14
+ it('rejects a forecast whose total or role topology does not match its slices', () => {
15
+ const forecast = { kind: 'topology', slices: 2, roles: { implementer: 2, 'specification-reviewer': 2, 'code-quality-reviewer': 2, 'final-reviewer': 1, 'track-a-qa': 1, 'track-b-qa': 1, documentation: 1, retro: 1, finishing: 1 }, total: 99 };
16
+ expect(() => (0, admission_1.sanitizeAdmissionReport)({ state: 'admitted', planState: 'valid', journal: 'not-required', currentness: 'not-checked', sensors: 'not-required', diagnostics: [], forecast })).toThrow(/invalid report/);
17
+ forecast.total = 12;
18
+ forecast.roles.implementer = 1;
19
+ expect(() => (0, admission_1.sanitizeAdmissionReport)({ state: 'admitted', planState: 'valid', journal: 'not-required', currentness: 'not-checked', sensors: 'not-required', diagnostics: [], forecast })).toThrow(/invalid report/);
20
+ });
21
+ it('preserves the distinct unsupported plan state and bounded validator diagnostic', async () => {
22
+ const report = await (0, admission_1.admitPlan)({ plan: { state: 'unsupported', schema: 'compact-slices/v9', diagnostics: [{ code: 'PLAN_UNSUPPORTED_SCHEMA', message: `future\u001b${'x'.repeat(5000)}` }] }, provider: 'codex', cwd: process.cwd() });
23
+ expect(report).toMatchObject({ state: 'blocked', planState: 'unsupported', currentness: 'not-checked', sensors: 'not-required' });
24
+ expect(report.diagnostics.map(diagnostic => diagnostic.code)).toEqual(['ADMISSION_PLAN_UNSUPPORTED', 'PLAN_UNSUPPORTED_SCHEMA']);
25
+ expect(report.diagnostics[1].message).toHaveLength(4096);
26
+ expect(report.diagnostics[1].message).not.toMatch(/[\u0000-\u001f\u007f-\u009f]/);
27
+ });
28
+ it('does not block a private registry only when declared plan provenance proves it unconsumed', async () => {
29
+ const report = await (0, admission_1.admitPlan)({
30
+ plan: valid, provider: 'codex', cwd: process.cwd(), enabledAgents: ['codex'], requireCurrent: true,
31
+ currentness: { checkedAt: '2026-01-01T00:00:00.000Z', compatibility: { status: 'not-checked' }, components: [
32
+ { component: 'cli', installed: '1.0.0', latest: '1.0.0', channel: 'stable', source: 'npm', checkedAt: '2026-01-01T00:00:00.000Z', status: 'current', detail: 'ok', remedy: 'none' },
33
+ { component: 'registry:private', installed: null, latest: null, channel: 'stable', source: 'private', checkedAt: '2026-01-01T00:00:00.000Z', status: 'unverifiable', detail: 'no', remedy: 'none' },
34
+ ] },
35
+ consumedRegistryComponents: [], provenance: 'proven',
36
+ });
37
+ expect(report).toMatchObject({ state: 'admitted', currentness: 'current', sensors: 'not-required' });
38
+ });
39
+ it('blocks currentness when registry-contract provenance is not sufficient to prove irrelevance', async () => {
40
+ const report = await (0, admission_1.admitPlan)({ plan: valid, provider: 'codex', cwd: process.cwd(), enabledAgents: ['codex'], requireCurrent: true, currentness: { checkedAt: 'x', compatibility: { status: 'not-checked' }, components: [] } });
41
+ expect(report).toMatchObject({ state: 'blocked', currentness: 'unverifiable', sensors: 'not-required' });
42
+ expect(report.diagnostics[0].code).toBe('ADMISSION_CURRENTNESS_PROVENANCE_REQUIRED');
43
+ });
44
+ it('blocks when a proven consumed registry has no currentness component', async () => {
45
+ const report = await (0, admission_1.admitPlan)({ plan: valid, provider: 'codex', cwd: process.cwd(), enabledAgents: ['codex'], requireCurrent: true, provenance: 'proven', consumedRegistryComponents: ['registry:private'], currentness: { checkedAt: 'x', compatibility: { status: 'not-checked' }, components: [{ component: 'cli', installed: '1.0.0', latest: '1.0.0', channel: 'stable', source: 'npm', checkedAt: 'x', status: 'current', detail: 'ok', remedy: 'none' }] } });
46
+ expect(report).toMatchObject({ state: 'blocked', currentness: 'unverifiable' });
47
+ expect(report.diagnostics[0].code).toBe('ADMISSION_CURRENTNESS_MISSING_COMPONENT');
48
+ });
49
+ it('blocks a bogus execution mode instead of treating it as interactive', async () => {
50
+ const report = await (0, admission_1.admitPlan)({ plan: valid, provider: 'codex', cwd: process.cwd(), enabledAgents: ['codex'], executionMode: 'background' });
51
+ expect(report).toMatchObject({ state: 'blocked', executionMode: 'interactivo' });
52
+ expect(report.diagnostics[0].code).toBe('ADMISSION_EXECUTION_MODE_INVALID');
53
+ });
54
+ it('stops at an unmarked plan before checking any later boundary', async () => {
55
+ const report = await (0, admission_1.admitPlan)({ plan: { state: 'migration-required', reason: 'unmarked-plan' }, provider: 'codex', cwd: process.cwd() });
56
+ expect(report).toMatchObject({ state: 'blocked', planState: 'migration-required', journal: 'not-required' });
57
+ expect(report.diagnostics[0].code).toBe('ADMISSION_PLAN_MIGRATION_REQUIRED');
58
+ expect(report.currentness).toBe('not-checked');
59
+ expect(report.sensors).toBe('not-required');
60
+ });
61
+ it('blocks a disabled provider before remote currentness or sensors', async () => {
62
+ const report = await (0, admission_1.admitPlan)({ plan: valid, provider: 'codex', cwd: process.cwd(), enabledAgents: ['claude-code'] });
63
+ expect(report).toMatchObject({ state: 'blocked', planDigest: valid.planDigest, provider: 'codex' });
64
+ expect(report.diagnostics[0].code).toBe('ADMISSION_PROVIDER_DISABLED');
65
+ expect(report.currentness).toBe('not-checked');
66
+ });
67
+ it('does not let an unverified execution capability admit interactive work', async () => {
68
+ const report = await (0, admission_1.admitPlan)({ plan: valid, provider: 'cursor', cwd: process.cwd(), enabledAgents: ['cursor'] });
69
+ expect(report).toMatchObject({ state: 'blocked', executionMode: 'interactivo', provider: 'cursor', journal: 'not-required' });
70
+ expect(report.diagnostics[0].code).toBe('ADMISSION_CAPABILITY_UNVERIFIED');
71
+ });
72
+ it('reports the schema-2 journal prerequisite for otherwise admissible unattended work', async () => {
73
+ const unattended = { ...valid, manifest: { ...valid.manifest, executionMode: 'desatendido' } };
74
+ const report = await (0, admission_1.admitPlan)({ plan: unattended, provider: 'codex', cwd: process.cwd(), enabledAgents: ['codex'] });
75
+ expect(report).toMatchObject({ state: 'blocked', executionMode: 'desatendido', journal: 'missing' });
76
+ expect(report.diagnostics[0]).toMatchObject({ code: 'ADMISSION_JOURNAL_BINDING_REQUIRED' });
77
+ expect(report.forecast).toBeUndefined();
78
+ });
79
+ it('admits unattended work only with an exact schema-2 plan binding', async () => {
80
+ const unattended = { ...valid, manifest: { ...valid.manifest, executionMode: 'desatendido' } };
81
+ const journal = { ...(0, types_1.emptyState)('main'), schema: 2, planBinding: { path: 'docs/plan.md', digest: 'a'.repeat(64), schema: 'compact-slices/v1', executionMode: 'desatendido', boundAt: '2026-09-15T00:00:00.000Z' } };
82
+ const admitted = await (0, admission_1.admitPlan)({ plan: unattended, provider: 'codex', cwd: process.cwd(), enabledAgents: ['codex'], journalState: journal, planPath: 'docs/plan.md' });
83
+ expect(admitted).toMatchObject({ state: 'admitted', journal: 'current', executionMode: 'desatendido' });
84
+ const stale = await (0, admission_1.admitPlan)({ plan: unattended, provider: 'codex', cwd: process.cwd(), enabledAgents: ['codex'], journalState: { ...journal, planBinding: { ...journal.planBinding, digest: 'b'.repeat(64) } }, planPath: 'docs/plan.md' });
85
+ expect(stale).toMatchObject({ state: 'blocked', journal: 'stale' });
86
+ });
87
+ });
@@ -8,6 +8,7 @@ const path_1 = __importDefault(require("path"));
8
8
  const os_1 = __importDefault(require("os"));
9
9
  const child_process_1 = require("child_process");
10
10
  const fingerprint_1 = require("../../../src/core/journal/fingerprint");
11
+ const types_1 = require("../../../src/core/journal/types");
11
12
  function git(cwd, ...args) {
12
13
  (0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', ...args], { cwd });
13
14
  }
@@ -96,6 +97,27 @@ describe('computeFingerprint', () => {
96
97
  expect(() => (0, fingerprint_1.computeFingerprint)(repo, ['npm', 'test'], [], '.')).not.toThrow();
97
98
  });
98
99
  });
100
+ describe('reconcileUnattendedRecovery', () => {
101
+ test.each([
102
+ ['journal', { journal: null, journalCorrupt: true }, 'repair-journal'],
103
+ ['binding', { plan: { path: 'other.md' } }, 'rebind-plan'],
104
+ ['git', { git: 'changed' }, 'reconcile-git'],
105
+ ['verdicts', { verdicts: 'missing' }, 'repair-verdicts'],
106
+ ['tests', { tests: 'fail' }, 'run-tests'],
107
+ ['sensors', { sensors: 'missing' }, 'run-sensors'],
108
+ ['ready', {}, 'select-work'],
109
+ ])('covers recovery action %s', (_name, overrides, nextAction) => {
110
+ const journal = { ...(0, types_1.emptyState)('main'), schema: 2, planBinding: { path: 'docs/plan.md', digest: 'a'.repeat(64), schema: 'compact-slices/v1', executionMode: 'desatendido', boundAt: '2026-09-15T00:00:00.000Z' } };
111
+ const input = { journal, journalCorrupt: false, plan: journal.planBinding, git: 'current', activeJobIds: [], tests: 'pass', sensors: 'pass', verdicts: 'current', ...overrides };
112
+ expect((0, fingerprint_1.reconcileUnattendedRecovery)(input).nextAction).toBe(nextAction);
113
+ });
114
+ test('reuses active obligations before later verdict repair', () => {
115
+ const journal = { ...(0, types_1.emptyState)('main'), schema: 2, planBinding: { path: 'docs/plan.md', digest: 'a'.repeat(64), schema: 'compact-slices/v1', executionMode: 'desatendido', boundAt: '2026-09-15T00:00:00.000Z' } };
116
+ const input = { journal, journalCorrupt: false, plan: journal.planBinding, git: 'current', activeJobIds: ['job-b', 'job-a', 'job-a'], tests: 'pass', sensors: 'pass', verdicts: 'current' };
117
+ expect((0, fingerprint_1.reconcileUnattendedRecovery)(input)).toEqual({ state: 'ready', nextAction: 'reconcile-active-jobs', activeJobIds: ['job-a', 'job-b'], diagnostics: [] });
118
+ expect((0, fingerprint_1.reconcileUnattendedRecovery)({ ...input, verdicts: 'missing' })).toEqual({ state: 'ready', nextAction: 'reconcile-active-jobs', activeJobIds: ['job-a', 'job-b'], diagnostics: [] });
119
+ });
120
+ });
99
121
  /** Defense-in-depth (post-implementation-qa, follow-up a Task 20): el `git()`
100
122
  * interno de este archivo backea `computeFingerprint`, invocado en CADA tick
101
123
  * del supervisor via `FingerprintNow`/`computeGate` (job/gate.ts). Sin stdio