agentic-workflow-manager 6.2.1 → 6.3.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 (69) hide show
  1. package/README.md +17 -0
  2. package/dist/src/commands/job/gate.js +37 -13
  3. package/dist/src/commands/job/index.js +48 -8
  4. package/dist/src/commands/job/request.js +44 -6
  5. package/dist/src/commands/track/emit.js +26 -0
  6. package/dist/src/commands/track/index.js +194 -0
  7. package/dist/src/commands/track/status.js +63 -0
  8. package/dist/src/commands/track/supervisor-wrapper.js +89 -0
  9. package/dist/src/commands/watch/apply.js +201 -13
  10. package/dist/src/commands/watch/index.js +14 -0
  11. package/dist/src/commands/watch/runner.js +20 -1
  12. package/dist/src/commands/watch/supervisor.js +251 -27
  13. package/dist/src/commands/watch/teardown-driver.js +189 -0
  14. package/dist/src/commands/watch/tracks.js +1100 -0
  15. package/dist/src/core/journal/adapter.js +46 -6
  16. package/dist/src/core/journal/paths.js +9 -0
  17. package/dist/src/core/journal/process.js +101 -1
  18. package/dist/src/core/journal/requests.js +5 -1
  19. package/dist/src/core/journal/store.js +34 -2
  20. package/dist/src/core/journal/types.js +93 -4
  21. package/dist/src/core/paths.js +51 -0
  22. package/dist/src/core/tracks/concurrency.js +85 -0
  23. package/dist/src/core/tracks/context.js +89 -0
  24. package/dist/src/core/tracks/descriptor.js +40 -0
  25. package/dist/src/core/tracks/git.js +318 -0
  26. package/dist/src/core/tracks/join.js +185 -0
  27. package/dist/src/core/tracks/ownership.js +100 -0
  28. package/dist/src/core/tracks/plan-parser.js +108 -0
  29. package/dist/src/core/tracks/protocol.js +466 -0
  30. package/dist/src/core/tracks/teardown.js +34 -0
  31. package/dist/src/core/tracks/types.js +14 -0
  32. package/dist/src/index.js +2 -0
  33. package/dist/tests/commands/job/gate-reconcile.test.js +267 -0
  34. package/dist/tests/commands/track/fixtures.js +13 -0
  35. package/dist/tests/commands/track/status.test.js +157 -0
  36. package/dist/tests/commands/track/supervisor-wrapper-cli.test.js +129 -0
  37. package/dist/tests/commands/track/supervisor-wrapper.test.js +131 -0
  38. package/dist/tests/commands/track/verbs.test.js +326 -0
  39. package/dist/tests/commands/watch/apply.test.js +116 -4
  40. package/dist/tests/commands/watch/runner.test.js +22 -0
  41. package/dist/tests/commands/watch/supervisor-loop.test.js +150 -0
  42. package/dist/tests/commands/watch/track-bootstrap-crash.test.js +342 -0
  43. package/dist/tests/commands/watch/track-bootstrap.test.js +350 -0
  44. package/dist/tests/commands/watch/track-finalize.test.js +643 -0
  45. package/dist/tests/commands/watch/track-freeze.test.js +591 -0
  46. package/dist/tests/commands/watch/track-join-crash.test.js +439 -0
  47. package/dist/tests/commands/watch/track-runtime-git.test.js +119 -0
  48. package/dist/tests/commands/watch/track-teardown-crash.test.js +426 -0
  49. package/dist/tests/core/journal/adapter-override.test.js +51 -0
  50. package/dist/tests/core/journal/process.test.js +37 -0
  51. package/dist/tests/core/journal/requests.test.js +21 -0
  52. package/dist/tests/core/journal/store.test.js +28 -0
  53. package/dist/tests/core/journal/types.test.js +89 -0
  54. package/dist/tests/core/same-existing-path.test.js +48 -0
  55. package/dist/tests/core/tracks/concurrency.test.js +134 -0
  56. package/dist/tests/core/tracks/context.test.js +177 -0
  57. package/dist/tests/core/tracks/descriptor.test.js +76 -0
  58. package/dist/tests/core/tracks/git.test.js +132 -0
  59. package/dist/tests/core/tracks/join-reconcile.test.js +53 -0
  60. package/dist/tests/core/tracks/join.test.js +197 -0
  61. package/dist/tests/core/tracks/ownership.test.js +96 -0
  62. package/dist/tests/core/tracks/plan-parser.test.js +94 -0
  63. package/dist/tests/core/tracks/protocol.test.js +416 -0
  64. package/dist/tests/core/tracks/teardown.test.js +62 -0
  65. package/dist/tests/helpers/git-fixture.js +35 -0
  66. package/dist/tests/integration/parallel-tracks.e2e.test.js +343 -0
  67. package/dist/tests/integration/r5-provider-evidence.test.js +67 -0
  68. package/dist/tests/structural/path-identity-not-string-compare.test.js +51 -0
  69. package/package.json +1 -1
@@ -6,10 +6,16 @@ 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 gate_1 = require("../../../src/commands/job/gate");
10
12
  const reconcile_1 = require("../../../src/commands/job/reconcile");
11
13
  const reap_1 = require("../../../src/commands/job/reap");
14
+ const job_1 = require("../../../src/commands/job");
15
+ const watch_1 = require("../../../src/commands/watch");
12
16
  const types_1 = require("../../../src/core/journal/types");
17
+ const store_1 = require("../../../src/core/journal/store");
18
+ const descriptor_1 = require("../../../src/core/tracks/descriptor");
13
19
  function job(partial) {
14
20
  return {
15
21
  id: 'j1', fingerprint: 'fp', commandDigest: 'cd', argv: ['npm', 'test'], cwd: '.',
@@ -168,6 +174,137 @@ describe('gate', () => {
168
174
  expect(g2.pass).toBe(false);
169
175
  expect(g2.reasons.some((r) => r.category === 'adverse-verdict')).toBe(true);
170
176
  });
177
+ test('computeGate produce el mismo conjunto de categorias que antes de la extraccion de evaluateEvidence (regresion R1)', () => {
178
+ const s = passingState();
179
+ s.cycle.status = 'BLOCKED';
180
+ s.cycle.blockedReason = 'custodia';
181
+ s.cycleVerificationPlan = [];
182
+ s.tasks[0].status = 'pending';
183
+ s.tasks[0].reviewObligations = [];
184
+ s.jobs['live'] = job({ id: 'live', executionState: 'running' });
185
+ const g = (0, gate_1.computeGate)(s, false, fpCurrent);
186
+ const categories = [...new Set(g.reasons.map((r) => r.category))].sort();
187
+ expect(categories).toEqual(['cycle-blocked', 'empty-cycle-plan', 'live-job', 'missing-verifier', 'open-obligation', 'pending-task'].sort());
188
+ });
189
+ });
190
+ /** Task 12 (R7/C4) — brecha de scope cerrada: el file-list del plan
191
+ * requeria tocar `gate.ts` para el nuevo `VerificationKind` 'track-integration',
192
+ * pero `evaluateEvidence` (arriba, linea ~78-111) ya es generico sobre TODO
193
+ * kind salvo 'review' — cae al mismo lookup `state.jobs[item.satisfiedBy]` +
194
+ * chequeo de verdict/vigencia de fingerprint sin importar el kind. Como
195
+ * `apply.ts` (`linkSatisfies`, llamado una vez por id en el bucle de
196
+ * job-request) enlaza VARIOS items `track-integration:*` al MISMO jobId
197
+ * cuando un solo job canonico de integracion satisface a toda la cohorte
198
+ * (C4 — un unico job, nunca uno por track), el bucle generico por-item ya
199
+ * evalua ese escenario correctamente: cada item hace su propio lookup
200
+ * independiente del MISMO job, así que ambos certifican juntos cuando pasa
201
+ * con fingerprint vigente, y ambos caen a 'stale-fingerprint' juntos cuando
202
+ * ese UNICO job deja de estar vigente. Esta suite documenta esa conclusion
203
+ * con evidencia real — CERO cambios de logica en gate.ts eran necesarios. */
204
+ describe('track-integration: un unico job satisface a MULTIPLES items de cohorte (R7/C4, Task 12 — scope gap)', () => {
205
+ function stateWithSharedIntegrationJob() {
206
+ const s = (0, types_1.emptyState)('r');
207
+ s.requiredVerifiers = ['test', 'sensors'];
208
+ s.cycleVerificationPlan = [
209
+ { id: 'qa', kind: 'qa', satisfiedBy: 'job-qa' },
210
+ { id: 'interlock', kind: 'interlock', satisfiedBy: 'job-interlock' },
211
+ { id: 'test', kind: 'test', satisfiedBy: 'job-test' },
212
+ { id: 'sensors', kind: 'sensors', satisfiedBy: 'job-sensors' },
213
+ // Dos tracks ('a', 'b'), UN solo job canonico de integracion
214
+ // (`job-integration`) satisface a AMBOS items — exactamente lo
215
+ // que `runRequestFinalIntegration` (watch/tracks.ts) produce vía
216
+ // `requestJob({ satisfies: ids, ... })` con `ids` = el conjunto
217
+ // COMPLETO y ordenado de `track-integration:*` de la cohorte.
218
+ { id: 'track-integration:a', kind: 'track-integration', satisfiedBy: 'job-integration' },
219
+ { id: 'track-integration:b', kind: 'track-integration', satisfiedBy: 'job-integration' },
220
+ ];
221
+ for (const id of ['job-qa', 'job-interlock', 'job-test', 'job-sensors', 'job-integration']) {
222
+ s.jobs[id] = job({ id, fingerprint: 'fp', executionState: 'exited', verdict: 'pass' });
223
+ }
224
+ return s;
225
+ }
226
+ test('un job pasando con fingerprint vigente certifica AMBOS items de track-integration a la vez, sin cambios en gate.ts', () => {
227
+ const s = stateWithSharedIntegrationJob();
228
+ const g = (0, gate_1.computeGate)(s, false, fpCurrent);
229
+ expect(g.pass).toBe(true);
230
+ expect(g.reasons).toEqual([]);
231
+ });
232
+ test('cuando ESE UNICO job pierde vigencia, AMBOS items de track-integration caen a stale-fingerprint juntos (nunca uno sin el otro)', () => {
233
+ const s = stateWithSharedIntegrationJob();
234
+ const g = (0, gate_1.computeGate)(s, false, fpStale);
235
+ expect(g.pass).toBe(false);
236
+ const staleTrackIntegration = g.reasons.filter((r) => r.category === 'stale-fingerprint' && /track-integration:/.test(r.detail));
237
+ expect(staleTrackIntegration).toHaveLength(2);
238
+ expect(staleTrackIntegration.some((r) => /track-integration:a/.test(r.detail))).toBe(true);
239
+ expect(staleTrackIntegration.some((r) => /track-integration:b/.test(r.detail))).toBe(true);
240
+ });
241
+ test('si el job compartido falla (verdict no-pass), AMBOS items de track-integration bloquean por adverse-verdict', () => {
242
+ const s = stateWithSharedIntegrationJob();
243
+ s.jobs['job-integration'].verdict = 'fail';
244
+ const g = (0, gate_1.computeGate)(s, false, fpCurrent);
245
+ expect(g.pass).toBe(false);
246
+ const adverse = g.reasons.filter((r) => r.category === 'adverse-verdict' && /track-integration:/.test(r.detail));
247
+ expect(adverse).toHaveLength(2);
248
+ });
249
+ });
250
+ /** Estado de journal de un TRACK individual (C6, R3.5): trackContext con la
251
+ * tarea asignada ya `done`, cycleVerificationPlan VACIO (un track no exige
252
+ * QA/interlock de ambito de plan), y evidencia local completa (item de
253
+ * test/sensors satisfecho, obligaciones spec/quality con verdict pass) —
254
+ * todo con el MISMO fingerprint que el gate va a recomputar ('same'). */
255
+ function stateWithCompletedTrackTask() {
256
+ const s = (0, types_1.emptyState)('track/cli');
257
+ s.trackContext = { trackId: 'cli', taskIds: ['T1'], planDigest: 'x', baseSha: 'y', planJournalId: 'j-1' };
258
+ s.requiredVerifiers = ['test', 'sensors'];
259
+ s.tasks.push({
260
+ id: 'T1', title: 't', status: 'done', attempts: 1,
261
+ verificationPlan: [
262
+ { id: 'v1', kind: 'test', satisfiedBy: 'j1' },
263
+ { id: 'v-sensors', kind: 'sensors', satisfiedBy: 'j2' },
264
+ ],
265
+ reviewObligations: [
266
+ { id: 'o-spec', taskId: 'T1', kind: 'spec', verdictId: 'verd-spec' },
267
+ { id: 'o-quality', taskId: 'T1', kind: 'quality', verdictId: 'verd-quality' },
268
+ ],
269
+ });
270
+ s.jobs['j1'] = job({ id: 'j1', fingerprint: 'same', executionState: 'exited', verdict: 'pass' });
271
+ s.jobs['j2'] = job({ id: 'j2', fingerprint: 'same', executionState: 'exited', verdict: 'pass' });
272
+ s.verdicts.push({ id: 'verd-spec', obligationId: 'o-spec', result: 'pass', detail: 'ok', receivedAt: 'now', fingerprint: 'same', argv: ['review', 'o-spec'], paths: [], cwd: '.' }, { id: 'verd-quality', obligationId: 'o-quality', result: 'pass', detail: 'ok', receivedAt: 'now', fingerprint: 'same', argv: ['review', 'o-quality'], paths: [], cwd: '.' });
273
+ return s;
274
+ }
275
+ describe('computeTrackGate — gate local de un track (C6, R3.5)', () => {
276
+ test('computeTrackGate no exige qa/interlock global (R3.5, C6)', () => {
277
+ const s = stateWithCompletedTrackTask();
278
+ expect((0, gate_1.computeTrackGate)(s, false, () => 'same')).toEqual({ pass: true, reasons: [] });
279
+ expect((0, gate_1.computeGate)(s, false, () => 'same').pass).toBe(false);
280
+ });
281
+ test('corrupcion o journal ausente bloquea con categoria propia (corrupt-state)', () => {
282
+ const g = (0, gate_1.computeTrackGate)(null, true, () => 'same');
283
+ expect(g.pass).toBe(false);
284
+ expect(g.reasons).toEqual([{ category: 'corrupt-state', detail: expect.any(String) }]);
285
+ });
286
+ test('journal sin trackContext no es un journal de track: bloquea con wrong-context', () => {
287
+ const s = stateWithCompletedTrackTask();
288
+ delete s.trackContext;
289
+ const g = (0, gate_1.computeTrackGate)(s, false, () => 'same');
290
+ expect(g.pass).toBe(false);
291
+ expect(g.reasons).toEqual([{ category: 'wrong-context', detail: expect.any(String) }]);
292
+ });
293
+ test('una tarea ajena colada en el journal del track se rechaza, jamas certifica por ella (R2.3)', () => {
294
+ const s = stateWithCompletedTrackTask();
295
+ s.tasks.push({ id: 'ajena', title: 'x', status: 'done', attempts: 1, verificationPlan: [], reviewObligations: [] });
296
+ const g = (0, gate_1.computeTrackGate)(s, false, () => 'same');
297
+ expect(g.pass).toBe(false);
298
+ expect(g.reasons).toEqual([{ category: 'foreign-task', detail: expect.stringContaining('ajena') }]);
299
+ });
300
+ test('sigue exigiendo verificadores mecanicos y fingerprint vigente localmente (no es un gate vacio)', () => {
301
+ const s = stateWithCompletedTrackTask();
302
+ s.jobs['j1'].fingerprint = 'cambiado';
303
+ expect((0, gate_1.computeTrackGate)(s, false, () => 'same').reasons.some((r) => r.category === 'stale-fingerprint')).toBe(true);
304
+ const s2 = stateWithCompletedTrackTask();
305
+ s2.requiredVerifiers = ['test', 'sensors', 'lint'];
306
+ expect((0, gate_1.computeTrackGate)(s2, false, () => 'same').reasons.some((r) => r.category === 'missing-verifier' && /lint/.test(r.detail))).toBe(true);
307
+ });
171
308
  });
172
309
  describe('reconcile — matriz unica R1.8 (R3.3)', () => {
173
310
  let logs;
@@ -321,3 +458,133 @@ describe('reap — limpieza explicita con identidad validada (R2.2)', () => {
321
458
  child.kill('SIGKILL');
322
459
  });
323
460
  });
461
+ // El guard de contexto (R9.4) se prueba a nivel de comando REGISTRADO: `job
462
+ // gate` y `watch --init` deben rechazar un cwd cuyo descriptor no autentica,
463
+ // y deben proceder EXACTAMENTE igual que antes cuando no hay descriptor
464
+ // (caso comun) o cuando el descriptor si autentica.
465
+ class ExitSignal extends Error {
466
+ code;
467
+ constructor(code) {
468
+ super(`process.exit(${code})`);
469
+ this.code = code;
470
+ }
471
+ }
472
+ function gitInitAt(repo, branch) {
473
+ (0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'init', '-q', '-b', branch], { cwd: repo });
474
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'f.txt'), 'x');
475
+ (0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'add', '.'], { cwd: repo });
476
+ (0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'c'], { cwd: repo });
477
+ }
478
+ function trackRefFixture(overrides, worktreePath) {
479
+ return {
480
+ trackId: 'cli', worktreePath, branch: 'track/cli', ownership: [], sharedResources: [], dependsOn: [],
481
+ fencingToken: 'f'.repeat(32), phase: 'ACTIVE', readinessNonce: 'n'.repeat(8), ...overrides,
482
+ };
483
+ }
484
+ function descriptorFixture(overrides, planRoot) {
485
+ return { schema: 1, planRoot, planBranch: 'main', trackId: 'cli', planJournalId: 'j-1', fencingToken: 'f'.repeat(32), ...overrides };
486
+ }
487
+ async function runCommand(register, argv, cwd) {
488
+ const prog = new commander_1.Command();
489
+ prog.exitOverride();
490
+ register(prog);
491
+ const outSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
492
+ const errSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
493
+ const cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(cwd);
494
+ let exitCode = null;
495
+ const exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code) => {
496
+ exitCode = code ?? 0;
497
+ throw new ExitSignal(exitCode);
498
+ }));
499
+ try {
500
+ await prog.parseAsync(['node', 'awm', ...argv]);
501
+ }
502
+ catch (e) {
503
+ if (!(e instanceof ExitSignal))
504
+ throw e;
505
+ }
506
+ finally {
507
+ cwdSpy.mockRestore();
508
+ exitSpy.mockRestore();
509
+ }
510
+ const out = outSpy.mock.calls.map((c) => String(c[0])).join('');
511
+ const err = errSpy.mock.calls.map((c) => String(c[0])).join('');
512
+ outSpy.mockRestore();
513
+ errSpy.mockRestore();
514
+ return { out, err, exitCode };
515
+ }
516
+ const runJob = (argv, cwd) => runCommand(job_1.registerJobCommand, ['job', ...argv], cwd);
517
+ const runWatch = (argv, cwd) => runCommand(watch_1.registerWatchCommand, ['watch', ...argv], cwd);
518
+ describe('guard de contexto en awm job / awm watch (R9.4)', () => {
519
+ let planRoot;
520
+ let trackRoot;
521
+ let otherRoot;
522
+ beforeEach(() => {
523
+ planRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-guard-plan-'));
524
+ trackRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-guard-track-'));
525
+ otherRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-guard-other-'));
526
+ gitInitAt(planRoot, 'main');
527
+ gitInitAt(trackRoot, 'track/cli');
528
+ gitInitAt(otherRoot, 'track/cli');
529
+ (0, store_1.initJournal)(planRoot, 'main');
530
+ const planState = (0, store_1.readJournal)(planRoot, 'main').state;
531
+ planState.journalId = 'j-1';
532
+ planState.tracks = [trackRefFixture({ fencingToken: 'f'.repeat(32) }, trackRoot)];
533
+ (0, store_1.writeJournal)(planRoot, 'main', planState);
534
+ (0, store_1.initJournal)(trackRoot, 'track/cli');
535
+ const trackState = (0, store_1.readJournal)(trackRoot, 'track/cli').state;
536
+ trackState.trackContext = { trackId: 'cli', taskIds: [], planDigest: 'x', baseSha: 'y', planJournalId: 'j-1' };
537
+ (0, store_1.writeJournal)(trackRoot, 'track/cli', trackState);
538
+ (0, descriptor_1.writeDescriptor)(trackRoot, descriptorFixture({ planJournalId: 'j-1', fencingToken: 'f'.repeat(32) }, planRoot));
539
+ // otherRoot: mismo descriptor (mismo plan/trackId/fencing) pero NO es
540
+ // el worktree que el TrackRef del plan declara — su realpath jamas
541
+ // puede autenticar (R9.4), aunque el resto del descriptor "calce".
542
+ (0, store_1.initJournal)(otherRoot, 'track/cli');
543
+ const otherState = (0, store_1.readJournal)(otherRoot, 'track/cli').state;
544
+ otherState.trackContext = { trackId: 'cli', taskIds: [], planDigest: 'x', baseSha: 'y', planJournalId: 'j-1' };
545
+ (0, store_1.writeJournal)(otherRoot, 'track/cli', otherState);
546
+ (0, descriptor_1.writeDescriptor)(otherRoot, descriptorFixture({ planJournalId: 'j-1', fencingToken: 'f'.repeat(32) }, planRoot));
547
+ });
548
+ afterEach(() => {
549
+ for (const dir of [planRoot, trackRoot, otherRoot])
550
+ fs_1.default.rmSync(dir, { recursive: true, force: true });
551
+ });
552
+ test('`job gate` desde un cwd cuyo descriptor no autentica (realpath no coincide con el TrackRef) rechaza con "cwd no autenticado"', async () => {
553
+ const { err, exitCode } = await runJob(['gate'], otherRoot);
554
+ expect(exitCode).toBe(1);
555
+ expect(err).toContain('cwd no autenticado');
556
+ });
557
+ test('`job gate` desde el cwd autenticado (trackRoot) atraviesa el guard sin bloquear por autenticacion', async () => {
558
+ const { out, err, exitCode } = await runJob(['gate'], trackRoot);
559
+ // el guard NO bloqueo (nunca imprime "cwd no autenticado"); computeGate
560
+ // (todavia global, sin distinguir modo track — eso es de otra task)
561
+ // bloquea por otras razones, lo cual es esperado y no es objeto de este test.
562
+ expect(err).not.toContain('cwd no autenticado');
563
+ expect(exitCode).toBe(1);
564
+ expect(JSON.parse(out).pass).toBe(false);
565
+ });
566
+ test('`watch --init` desde un cwd SIN descriptor (repo comun, sin tracks) procede exactamente como antes de esta task', async () => {
567
+ const plainRepo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-guard-plain-'));
568
+ try {
569
+ gitInitAt(plainRepo, 'main');
570
+ const { out, err, exitCode } = await runWatch(['--init'], plainRepo);
571
+ expect(exitCode).toBeNull();
572
+ expect(err).toBe('');
573
+ expect(out).toContain('journal inicializado');
574
+ }
575
+ finally {
576
+ fs_1.default.rmSync(plainRepo, { recursive: true, force: true });
577
+ }
578
+ });
579
+ test('`watch --init` desde el cwd autenticado (trackRoot) procede con normalidad', async () => {
580
+ const { out, err, exitCode } = await runWatch(['--init'], trackRoot);
581
+ expect(err).not.toContain('cwd no autenticado');
582
+ expect(exitCode).toBeNull();
583
+ expect(out).toContain('journal inicializado');
584
+ });
585
+ test('`watch --init` desde un cwd cuyo descriptor no autentica rechaza con "cwd no autenticado"', async () => {
586
+ const { err, exitCode } = await runWatch(['--init'], otherRoot);
587
+ expect(exitCode).toBe(1);
588
+ expect(err).toContain('cwd no autenticado');
589
+ });
590
+ });
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.trackRefFixture = trackRefFixture;
4
+ exports.descriptorFixture = descriptorFixture;
5
+ function trackRefFixture(overrides, worktreePath) {
6
+ return {
7
+ trackId: 'cli', worktreePath, branch: 'track/cli', ownership: [], sharedResources: [], dependsOn: [],
8
+ fencingToken: 'f'.repeat(32), phase: 'ACTIVE', readinessNonce: 'n'.repeat(8), ...overrides,
9
+ };
10
+ }
11
+ function descriptorFixture(overrides, planRoot) {
12
+ return { schema: 1, planRoot, planBranch: 'main', trackId: 'cli', planJournalId: 'j-1', fencingToken: 'f'.repeat(32), ...overrides };
13
+ }
@@ -0,0 +1,157 @@
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 os_1 = __importDefault(require("os"));
9
+ const child_process_1 = require("child_process");
10
+ const status_1 = require("../../../src/commands/track/status");
11
+ const fingerprint_1 = require("../../../src/core/journal/fingerprint");
12
+ const store_1 = require("../../../src/core/journal/store");
13
+ const paths_1 = require("../../../src/core/journal/paths");
14
+ const fixtures_1 = require("./fixtures");
15
+ // R9.5/R9.6: `aggregateTrackStatus` COMPONE el gate de cada journal de track
16
+ // leyendolo al momento de necesitarlo — jamas escribe de vuelta al journal
17
+ // del plan ni persiste el resultado en el TrackRef.
18
+ function git(cwd, ...args) {
19
+ (0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', ...args], { cwd });
20
+ }
21
+ function gitInitAt(repo, branch) {
22
+ fs_1.default.mkdirSync(repo, { recursive: true });
23
+ git(repo, 'init', '-q', '-b', branch);
24
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'f.txt'), 'x');
25
+ git(repo, 'add', '.');
26
+ git(repo, 'commit', '-qm', 'c');
27
+ }
28
+ describe('aggregateTrackStatus — agregado read-only (R9.5, R9.6)', () => {
29
+ let planRoot;
30
+ let trackRoot;
31
+ const planBranch = 'main';
32
+ const trackBranch = 'track/cli';
33
+ beforeEach(() => {
34
+ planRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-status-plan-'));
35
+ trackRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-status-track-'));
36
+ gitInitAt(planRoot, planBranch);
37
+ gitInitAt(trackRoot, trackBranch);
38
+ (0, store_1.initJournal)(planRoot, planBranch);
39
+ (0, store_1.initJournal)(trackRoot, trackBranch);
40
+ });
41
+ afterEach(() => {
42
+ fs_1.default.rmSync(planRoot, { recursive: true, force: true });
43
+ fs_1.default.rmSync(trackRoot, { recursive: true, force: true });
44
+ });
45
+ /** Journal de track que satisface computeTrackGate integramente, con
46
+ * fingerprints REALES de `trackRoot` (fingerprintFor usa computeFingerprint
47
+ * de verdad, no un stub inyectado). */
48
+ function writeCompletedTrackJournal() {
49
+ const fp = (0, fingerprint_1.computeFingerprint)(trackRoot, ['npm', 'test'], [], '.');
50
+ const s = (0, store_1.readJournal)(trackRoot, trackBranch).state;
51
+ s.trackContext = { trackId: 'cli', taskIds: ['T1'], planDigest: 'x', baseSha: 'y', planJournalId: 'j-1' };
52
+ s.requiredVerifiers = ['test', 'sensors'];
53
+ s.tasks.push({
54
+ id: 'T1', title: 't', status: 'done', attempts: 1,
55
+ verificationPlan: [
56
+ { id: 'v1', kind: 'test', satisfiedBy: 'j1' },
57
+ { id: 'v-sensors', kind: 'sensors', satisfiedBy: 'j2' },
58
+ ],
59
+ reviewObligations: [
60
+ { id: 'o-spec', taskId: 'T1', kind: 'spec', verdictId: 'verd-spec' },
61
+ { id: 'o-quality', taskId: 'T1', kind: 'quality', verdictId: 'verd-quality' },
62
+ ],
63
+ });
64
+ const job = (id) => ({
65
+ id, fingerprint: fp.fingerprint, commandDigest: fp.commandDigest, argv: ['npm', 'test'], cwd: '.',
66
+ paths: [], expandedPaths: fp.expandedPaths, executionState: 'exited',
67
+ observationState: 'progressing', verdict: 'pass', phaseTimestamps: {},
68
+ });
69
+ s.jobs['j1'] = job('j1');
70
+ s.jobs['j2'] = job('j2');
71
+ s.verdicts.push({ id: 'verd-spec', obligationId: 'o-spec', result: 'pass', detail: 'ok', receivedAt: 'now', fingerprint: fp.fingerprint, argv: ['npm', 'test'], paths: [], cwd: '.' }, { id: 'verd-quality', obligationId: 'o-quality', result: 'pass', detail: 'ok', receivedAt: 'now', fingerprint: fp.fingerprint, argv: ['npm', 'test'], paths: [], cwd: '.' });
72
+ (0, store_1.writeJournal)(trackRoot, trackBranch, s);
73
+ }
74
+ test('compone journals al leer y no los espeja (R9.5, R9.6)', () => {
75
+ writeCompletedTrackJournal();
76
+ const planState = (0, store_1.readJournal)(planRoot, planBranch).state;
77
+ planState.tracks = [(0, fixtures_1.trackRefFixture)({}, trackRoot)];
78
+ (0, store_1.writeJournal)(planRoot, planBranch, planState);
79
+ const planBefore = fs_1.default.readFileSync((0, paths_1.statePath)(planRoot, planBranch), 'utf8');
80
+ const currentPlanState = (0, store_1.readJournal)(planRoot, planBranch).state;
81
+ const out = (0, status_1.aggregateTrackStatus)(planRoot, currentPlanState);
82
+ expect(out.tracks.cli.gate.pass).toBe(true);
83
+ expect(currentPlanState.tracks[0]).not.toHaveProperty('gate');
84
+ expect(fs_1.default.readFileSync((0, paths_1.statePath)(planRoot, planBranch), 'utf8')).toBe(planBefore);
85
+ });
86
+ test('journal de track corrupto/ausente nunca se descarta en silencio: gate rojo con corrupt-state', () => {
87
+ // ni siquiera se inicializa el journal del segundo track: readJournal
88
+ // sobre un directorio sin state.json => corrupt:true (R1.6).
89
+ const missingTrackRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-status-missing-'));
90
+ try {
91
+ const planState = (0, store_1.readJournal)(planRoot, planBranch).state;
92
+ planState.tracks = [(0, fixtures_1.trackRefFixture)({ trackId: 'ghost', branch: 'track/ghost' }, missingTrackRoot)];
93
+ (0, store_1.writeJournal)(planRoot, planBranch, planState);
94
+ const out = (0, status_1.aggregateTrackStatus)(planRoot, (0, store_1.readJournal)(planRoot, planBranch).state);
95
+ expect(out.tracks.ghost.gate.pass).toBe(false);
96
+ expect(out.tracks.ghost.gate.reasons).toEqual([{ category: 'corrupt-state', detail: expect.any(String) }]);
97
+ }
98
+ finally {
99
+ fs_1.default.rmSync(missingTrackRoot, { recursive: true, force: true });
100
+ }
101
+ });
102
+ test('journal de track roto (fingerprint stale) reporta gate rojo, no lo esconde', () => {
103
+ writeCompletedTrackJournal();
104
+ // el worktree del track cambia DESPUES de que el job registro su
105
+ // fingerprint: la evidencia queda historica.
106
+ fs_1.default.writeFileSync(path_1.default.join(trackRoot, 'f.txt'), 'cambiado');
107
+ const planState = (0, store_1.readJournal)(planRoot, planBranch).state;
108
+ planState.tracks = [(0, fixtures_1.trackRefFixture)({}, trackRoot)];
109
+ (0, store_1.writeJournal)(planRoot, planBranch, planState);
110
+ const out = (0, status_1.aggregateTrackStatus)(planRoot, (0, store_1.readJournal)(planRoot, planBranch).state);
111
+ expect(out.tracks.cli.gate.pass).toBe(false);
112
+ expect(out.tracks.cli.gate.reasons.some((r) => r.category === 'stale-fingerprint')).toBe(true);
113
+ });
114
+ test('sin tracks declarados, el agregado es vacio y el cohort lo dice explicitamente', () => {
115
+ const out = (0, status_1.aggregateTrackStatus)(planRoot, (0, store_1.readJournal)(planRoot, planBranch).state);
116
+ expect(out).toEqual({ cohort: 'sin tracks declarados', tracks: {} });
117
+ });
118
+ });
119
+ describe('deriveCohortPhase — string de display, no decide protocolo', () => {
120
+ function ref(trackId, phase) {
121
+ return (0, fixtures_1.trackRefFixture)({ trackId, phase }, `/tmp/${trackId}`);
122
+ }
123
+ test('sin tracks: mensaje explicito', () => {
124
+ expect((0, status_1.deriveCohortPhase)([])).toBe('sin tracks declarados');
125
+ });
126
+ test('cualquier BLOCKED domina el mensaje, nombrando los ids ordenados', () => {
127
+ const out = (0, status_1.deriveCohortPhase)([ref('b', 'ACTIVE'), ref('a', 'BLOCKED'), ref('c', 'BLOCKED')]);
128
+ expect(out).toBe('BLOCKED: a, c');
129
+ });
130
+ test('sin BLOCKED, reporta la fase MENOS avanzada como cuello de botella', () => {
131
+ // ARMED antecede a ACTIVE en TRACK_PHASES: el track menos avanzado
132
+ // marca el ritmo de la cohorte, no el mas adelantado.
133
+ const out = (0, status_1.deriveCohortPhase)([ref('a', 'ACTIVE'), ref('b', 'ARMED')]);
134
+ expect(out).toBe('ARMED (1/2)');
135
+ });
136
+ test('todos en la misma fase: cuenta el total', () => {
137
+ expect((0, status_1.deriveCohortPhase)([ref('a', 'ACTIVE'), ref('b', 'ACTIVE')])).toBe('ACTIVE (2/2)');
138
+ });
139
+ });
140
+ describe('fingerprintFor — espejo de realFingerprintNow cerrado sobre el worktree observado', () => {
141
+ let worktree;
142
+ beforeEach(() => {
143
+ worktree = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-fpfor-'));
144
+ gitInitAt(worktree, 'main');
145
+ });
146
+ afterEach(() => { fs_1.default.rmSync(worktree, { recursive: true, force: true }); });
147
+ test('coincide con computeFingerprint contra el MISMO worktree que cierra', () => {
148
+ const expected = (0, fingerprint_1.computeFingerprint)(worktree, ['npm', 'test'], [], '.').fingerprint;
149
+ expect((0, status_1.fingerprintFor)(worktree)(['npm', 'test'], [], '.')).toBe(expected);
150
+ });
151
+ test('argv invalido (vacio) nunca lanza — se degrada a null, nunca certifica', () => {
152
+ expect((0, status_1.fingerprintFor)(worktree)([], [], '.')).toBeNull();
153
+ });
154
+ test('cwd fuera del repo tambien se degrada a null en vez de propagar la excepcion', () => {
155
+ expect((0, status_1.fingerprintFor)(worktree)(['npm', 'test'], [], '../fuera')).toBeNull();
156
+ });
157
+ });
@@ -0,0 +1,129 @@
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
+ // Task 8 post-review item 3: CLI-level coverage de `awm track supervisor-wrapper`
7
+ // — el registro de flags requeridos y el guard de descriptor, invocados vía
8
+ // Commander (mismo patrón que verbs.test.ts), no llamando a
9
+ // `runSupervisorWrapper` directamente. El único escenario que llega a
10
+ // ejecutar el wrapper real deja el track BLOCKED en el journal del plan para
11
+ // que retorne sin nunca spawnear `awm watch` de verdad.
12
+ const fs_1 = __importDefault(require("fs"));
13
+ const path_1 = __importDefault(require("path"));
14
+ const os_1 = __importDefault(require("os"));
15
+ const child_process_1 = require("child_process");
16
+ const commander_1 = require("commander");
17
+ const track_1 = require("../../../src/commands/track");
18
+ const store_1 = require("../../../src/core/journal/store");
19
+ const descriptor_1 = require("../../../src/core/tracks/descriptor");
20
+ const fixtures_1 = require("./fixtures");
21
+ class ExitSignal extends Error {
22
+ code;
23
+ constructor(code) {
24
+ super(`process.exit(${code})`);
25
+ this.code = code;
26
+ }
27
+ }
28
+ function git(repo, args) {
29
+ return (0, child_process_1.execFileSync)('git', args, { cwd: repo, encoding: 'utf8' }).trim();
30
+ }
31
+ function gitInit(repo, branch) {
32
+ (0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'init', '-q', '-b', branch], { cwd: repo });
33
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'f.txt'), 'x');
34
+ (0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'add', '.'], { cwd: repo });
35
+ (0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'c'], { cwd: repo });
36
+ }
37
+ async function runCli(repo, argv) {
38
+ const prog = new commander_1.Command();
39
+ prog.exitOverride();
40
+ (0, track_1.registerTrackCommand)(prog);
41
+ const outSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
42
+ const errSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
43
+ const cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(repo);
44
+ let exitCode = 0;
45
+ const exitSpy = jest.spyOn(process, 'exit').mockImplementation(((code) => {
46
+ exitCode = code ?? 0;
47
+ throw new ExitSignal(exitCode);
48
+ }));
49
+ try {
50
+ await prog.parseAsync(['node', 'awm', ...argv]);
51
+ }
52
+ catch (e) {
53
+ if (e instanceof ExitSignal) {
54
+ // ya capturado por el spy de process.exit
55
+ }
56
+ else if (typeof e.exitCode === 'number') {
57
+ exitCode = e.exitCode;
58
+ }
59
+ else {
60
+ throw e;
61
+ }
62
+ }
63
+ finally {
64
+ cwdSpy.mockRestore();
65
+ exitSpy.mockRestore();
66
+ }
67
+ const out = outSpy.mock.calls.map((c) => String(c[0])).join('');
68
+ const err = errSpy.mock.calls.map((c) => String(c[0])).join('');
69
+ outSpy.mockRestore();
70
+ errSpy.mockRestore();
71
+ return { out, err, exitCode };
72
+ }
73
+ describe('awm track supervisor-wrapper — CLI (R4.7, R9.4, post-review item 3)', () => {
74
+ let planRoot;
75
+ let trackRoot;
76
+ beforeEach(() => {
77
+ planRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-wrapper-cli-plan-'));
78
+ trackRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-wrapper-cli-track-'));
79
+ gitInit(planRoot, 'main');
80
+ gitInit(trackRoot, 'track/cli');
81
+ (0, store_1.initJournal)(planRoot, 'main');
82
+ });
83
+ afterEach(() => {
84
+ fs_1.default.rmSync(planRoot, { recursive: true, force: true });
85
+ fs_1.default.rmSync(trackRoot, { recursive: true, force: true });
86
+ });
87
+ test.each([
88
+ ['sin --track', ['track', 'supervisor-wrapper', '--readiness', 'r', '--fence', 'f', '--nonce', 'n']],
89
+ ['sin --readiness', ['track', 'supervisor-wrapper', '--track', 'cli', '--fence', 'f', '--nonce', 'n']],
90
+ ['sin --fence', ['track', 'supervisor-wrapper', '--track', 'cli', '--readiness', 'r', '--nonce', 'n']],
91
+ ['sin --nonce', ['track', 'supervisor-wrapper', '--track', 'cli', '--readiness', 'r', '--fence', 'f']],
92
+ ])('%s: commander rechaza antes de tocar ningún descriptor/journal', async (_label, argv) => {
93
+ const out = await runCli(trackRoot, argv);
94
+ expect(out.exitCode).not.toBe(0);
95
+ });
96
+ test('sin descriptor de track en el cwd: rechaza sin autenticar (R9.4)', async () => {
97
+ const out = await runCli(trackRoot, ['track', 'supervisor-wrapper', '--track', 'cli', '--readiness', 'r', '--fence', 'f', '--nonce', 'n']);
98
+ expect(out.exitCode).toBe(1);
99
+ expect(out.err).toContain('sin descriptor de track');
100
+ });
101
+ test('descriptor presente pero trackId no coincide con --track: aborta antes de reclamar nada', async () => {
102
+ (0, descriptor_1.writeDescriptor)(trackRoot, (0, fixtures_1.descriptorFixture)({ trackId: 'cli' }, planRoot));
103
+ const out = await runCli(trackRoot, ['track', 'supervisor-wrapper', '--track', 'otro-track', '--readiness', 'r', '--fence', 'f'.repeat(32), '--nonce', 'n']);
104
+ expect(out.exitCode).toBe(1);
105
+ expect(out.err).toContain('no coincide');
106
+ expect(fs_1.default.existsSync(path_1.default.join(trackRoot, '.awm', 'supervisor.claim'))).toBe(false);
107
+ });
108
+ test('descriptor presente pero fencingToken no coincide con --fence: aborta antes de reclamar nada', async () => {
109
+ (0, descriptor_1.writeDescriptor)(trackRoot, (0, fixtures_1.descriptorFixture)({ trackId: 'cli', fencingToken: 'f'.repeat(32) }, planRoot));
110
+ const out = await runCli(trackRoot, ['track', 'supervisor-wrapper', '--track', 'cli', '--readiness', 'r', '--fence', 'g'.repeat(32), '--nonce', 'n']);
111
+ expect(out.exitCode).toBe(1);
112
+ expect(out.err).toContain('no coincide');
113
+ expect(fs_1.default.existsSync(path_1.default.join(trackRoot, '.awm', 'supervisor.claim'))).toBe(false);
114
+ });
115
+ test('descriptor autenticado: reclama, escribe sidecars, y sale 0 sin lanzar `awm watch` cuando el plan bloquea el track', async () => {
116
+ (0, descriptor_1.writeDescriptor)(trackRoot, (0, fixtures_1.descriptorFixture)({ trackId: 'cli', planBranch: 'main', fencingToken: 'f'.repeat(32) }, planRoot));
117
+ const planState = (0, store_1.readJournal)(planRoot, 'main').state;
118
+ planState.tracks = [(0, fixtures_1.trackRefFixture)({ trackId: 'cli', phase: 'BLOCKED', readinessNonce: 'nonce-cli'.padEnd(32, '0') }, trackRoot)];
119
+ (0, store_1.writeJournal)(planRoot, 'main', planState);
120
+ const out = await runCli(trackRoot, [
121
+ 'track', 'supervisor-wrapper', '--track', 'cli',
122
+ '--readiness', 'nonce-cli'.padEnd(32, '0'), '--fence', 'f'.repeat(32), '--nonce', 'real-nonce',
123
+ ]);
124
+ expect(out.exitCode).toBe(0);
125
+ expect(fs_1.default.existsSync(path_1.default.join(trackRoot, '.awm', 'supervisor.claim'))).toBe(true);
126
+ const identity = JSON.parse(fs_1.default.readFileSync(path_1.default.join(trackRoot, '.awm', 'supervisor.identity.json'), 'utf8'));
127
+ expect(identity.nonce).toBe('real-nonce'); // el eco end-to-end del nonce también se ejercita vía CLI
128
+ });
129
+ });