agentic-workflow-manager 6.2.0 → 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 (75) 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/diagnostics/provider-checks.js +12 -9
  16. package/dist/src/core/journal/adapter.js +46 -6
  17. package/dist/src/core/journal/paths.js +9 -0
  18. package/dist/src/core/journal/process.js +101 -1
  19. package/dist/src/core/journal/requests.js +5 -1
  20. package/dist/src/core/journal/store.js +34 -2
  21. package/dist/src/core/journal/types.js +93 -4
  22. package/dist/src/core/paths.js +51 -0
  23. package/dist/src/core/profile.js +15 -1
  24. package/dist/src/core/tracks/concurrency.js +85 -0
  25. package/dist/src/core/tracks/context.js +89 -0
  26. package/dist/src/core/tracks/descriptor.js +40 -0
  27. package/dist/src/core/tracks/git.js +318 -0
  28. package/dist/src/core/tracks/join.js +185 -0
  29. package/dist/src/core/tracks/ownership.js +100 -0
  30. package/dist/src/core/tracks/plan-parser.js +108 -0
  31. package/dist/src/core/tracks/protocol.js +466 -0
  32. package/dist/src/core/tracks/teardown.js +34 -0
  33. package/dist/src/core/tracks/types.js +14 -0
  34. package/dist/src/core/update-check.js +5 -1
  35. package/dist/src/index.js +2 -0
  36. package/dist/tests/commands/job/gate-reconcile.test.js +267 -0
  37. package/dist/tests/commands/track/fixtures.js +13 -0
  38. package/dist/tests/commands/track/status.test.js +157 -0
  39. package/dist/tests/commands/track/supervisor-wrapper-cli.test.js +129 -0
  40. package/dist/tests/commands/track/supervisor-wrapper.test.js +131 -0
  41. package/dist/tests/commands/track/verbs.test.js +326 -0
  42. package/dist/tests/commands/watch/apply.test.js +116 -4
  43. package/dist/tests/commands/watch/runner.test.js +22 -0
  44. package/dist/tests/commands/watch/supervisor-loop.test.js +150 -0
  45. package/dist/tests/commands/watch/track-bootstrap-crash.test.js +342 -0
  46. package/dist/tests/commands/watch/track-bootstrap.test.js +350 -0
  47. package/dist/tests/commands/watch/track-finalize.test.js +643 -0
  48. package/dist/tests/commands/watch/track-freeze.test.js +591 -0
  49. package/dist/tests/commands/watch/track-join-crash.test.js +439 -0
  50. package/dist/tests/commands/watch/track-runtime-git.test.js +119 -0
  51. package/dist/tests/commands/watch/track-teardown-crash.test.js +426 -0
  52. package/dist/tests/core/diagnostics/provider-tier.test.js +3 -3
  53. package/dist/tests/core/journal/adapter-override.test.js +51 -0
  54. package/dist/tests/core/journal/process.test.js +37 -0
  55. package/dist/tests/core/journal/requests.test.js +21 -0
  56. package/dist/tests/core/journal/store.test.js +28 -0
  57. package/dist/tests/core/journal/types.test.js +89 -0
  58. package/dist/tests/core/profile.test.js +9 -2
  59. package/dist/tests/core/same-existing-path.test.js +48 -0
  60. package/dist/tests/core/tracks/concurrency.test.js +134 -0
  61. package/dist/tests/core/tracks/context.test.js +177 -0
  62. package/dist/tests/core/tracks/descriptor.test.js +76 -0
  63. package/dist/tests/core/tracks/git.test.js +132 -0
  64. package/dist/tests/core/tracks/join-reconcile.test.js +53 -0
  65. package/dist/tests/core/tracks/join.test.js +197 -0
  66. package/dist/tests/core/tracks/ownership.test.js +96 -0
  67. package/dist/tests/core/tracks/plan-parser.test.js +94 -0
  68. package/dist/tests/core/tracks/protocol.test.js +416 -0
  69. package/dist/tests/core/tracks/teardown.test.js +62 -0
  70. package/dist/tests/core/update-check.test.js +34 -3
  71. package/dist/tests/helpers/git-fixture.js +35 -0
  72. package/dist/tests/integration/parallel-tracks.e2e.test.js +343 -0
  73. package/dist/tests/integration/r5-provider-evidence.test.js +67 -0
  74. package/dist/tests/structural/path-identity-not-string-compare.test.js +51 -0
  75. package/package.json +1 -1
@@ -124,20 +124,24 @@ function skillsGlobalCheck(dir, owners, integrity, renderer, artifacts) {
124
124
  // generados NO: quedan con el contenido de la version anterior hasta que alguien
125
125
  // corra `awm sync`, y hasta ahora nada lo decia.
126
126
  const stale = staleRendered(dir, artifacts);
127
+ const names = stale.map((r) => path_1.default.basename(r.targetPath));
127
128
  return {
128
129
  id: 'skills.global',
129
130
  state: stale.length > 0 ? 'stale' : 'supported',
130
131
  target: dir,
131
132
  owners: owners.length > 1 ? owners : undefined,
132
133
  detail: stale.length > 0
133
- ? `${stale.length} rendered file(s) no longer match the installed registry (${stale.slice(0, 3).join(', ')})`
134
+ ? `${stale.length} rendered file(s) no longer match the installed registry (${names.slice(0, 3).join(', ')})`
135
+ : undefined,
136
+ // El remedio depende del ALCANCE, y se midio cual funciona en cada uno:
137
+ // `awm update` reconcilia los artefactos de maquina, `awm sync` los que el
138
+ // profile del proyecto declara. Ofrecer el equivocado seria mandar al usuario
139
+ // a un comando que corre limpio sin cambiar nada — el defecto que ya tuvo
140
+ // `open-hooks-trust` (D-010), y que la primera version de ESTE chequeo
141
+ // repitio: ofrecia `reinstall-bundle` porque dos mediciones mias estaban mal.
142
+ remediationCode: stale.length > 0
143
+ ? (stale.some((r) => r.scope === 'local') ? 'awm-sync' : 'awm-update')
134
144
  : undefined,
135
- // `reinstall-bundle`, y NO `awm-sync` ni `awm-init`: se midio cual de los
136
- // cuatro comandos refresca de verdad un renderizado viejo, y solo `awm add`
137
- // lo hace. `init` y `sync` son idempotentes por presencia —ven el archivo y
138
- // no lo tocan— y `update` refresca el registry, no lo derivado de el. Ofrecer
139
- // un remedio que corre limpio sin cambiar nada es peor que no ofrecer ninguno.
140
- remediationCode: stale.length > 0 ? 'reinstall-bundle' : undefined,
141
145
  };
142
146
  }
143
147
  const shared = owners.length > 1;
@@ -213,8 +217,7 @@ function staleRendered(dir, records) {
213
217
  // corresponden. Aca "no puedo comparar" no se convierte en "esta viejo".
214
218
  return false;
215
219
  }
216
- })
217
- .map((r) => path_1.default.basename(r.targetPath));
220
+ });
218
221
  }
219
222
  function corruptRendered(dir, files, renderer) {
220
223
  const marker = (0, registry_1.rendererIntegrityMarker)(renderer);
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.WATCH_PROVIDERS = void 0;
3
+ exports.CONTROLLER_ARGV_ENV = exports.WATCH_PROVIDERS = void 0;
4
4
  exports.isWatchProvider = isWatchProvider;
5
5
  exports.adapterFor = adapterFor;
6
6
  const process_1 = require("./process");
@@ -27,10 +27,50 @@ exports.WATCH_PROVIDERS = ['codex', 'claude-code'];
27
27
  function isWatchProvider(value) {
28
28
  return typeof value === 'string' && exports.WATCH_PROVIDERS.includes(value);
29
29
  }
30
+ /** Nombre del env var que redirige el LANZAMIENTO del controller a un comando propio.
31
+ * Exportado para que tests y documentacion nunca reescriban el literal. */
32
+ exports.CONTROLLER_ARGV_ENV = 'AWM_CONTROLLER_ARGV';
33
+ /**
34
+ * El supervisor declara no conocer providers: conoce el contrato `ControllerAdapter`. Esa
35
+ * afirmacion era INFALSIFICABLE desde afuera mientras los unicos adapters posibles fueran
36
+ * `codex` y `claude-code` — no habia forma de plantar un controller ajeno y comprobar que
37
+ * el supervisor se comporta igual.
38
+ *
39
+ * `AWM_CONTROLLER_ARGV` cierra eso: un array JSON de strings que reemplaza el argv de
40
+ * lanzamiento, conservando TODO lo demas del adapter elegido (actividad, `safeToReplace`,
41
+ * fencing, custodia). El prompt de la generacion se agrega como ultimo argumento, igual que
42
+ * hacen los adapters nativos, para que el controller reciba su token por la misma via.
43
+ *
44
+ * Array JSON y jamas una linea de shell: es la misma doctrina que el argv de integracion de
45
+ * los tracks (C4) — una string interpretada por un shell convierte un nombre de archivo en
46
+ * un operador. Se valida en el borde: cualquier cosa que no sea un array no vacio de strings
47
+ * no vacios es un error explicito, nunca un fallback silencioso al provider nativo (un
48
+ * override mal escrito que "funciona igual" lanzaria el agente real sin que nadie lo note).
49
+ */
50
+ function controllerArgvOverride() {
51
+ const raw = process.env[exports.CONTROLLER_ARGV_ENV]?.trim();
52
+ if (raw === undefined || raw.length === 0)
53
+ return null;
54
+ let parsed;
55
+ try {
56
+ parsed = JSON.parse(raw);
57
+ }
58
+ catch {
59
+ throw new Error(`${exports.CONTROLLER_ARGV_ENV} debe ser un array JSON de strings, no una linea de shell: ${raw}`);
60
+ }
61
+ if (!Array.isArray(parsed) || parsed.length === 0 || parsed.some((x) => typeof x !== 'string' || x.length === 0)) {
62
+ throw new Error(`${exports.CONTROLLER_ARGV_ENV} debe ser un array JSON no vacio de strings no vacios: ${raw}`);
63
+ }
64
+ return parsed;
65
+ }
30
66
  function adapterFor(provider) {
31
- if (provider === 'codex')
32
- return codexAdapter;
33
- if (provider === 'claude-code')
34
- return claudeAdapter;
35
- throw new Error(`provider desconocido: ${provider} (validos: ${exports.WATCH_PROVIDERS.join(', ')})`);
67
+ const base = provider === 'codex' ? codexAdapter
68
+ : provider === 'claude-code' ? claudeAdapter
69
+ : null;
70
+ if (base === null)
71
+ throw new Error(`provider desconocido: ${provider} (validos: ${exports.WATCH_PROVIDERS.join(', ')})`);
72
+ const override = controllerArgvOverride();
73
+ if (override === null)
74
+ return base;
75
+ return { ...base, launchArgv: (resumePrompt) => [...override, resumePrompt] };
36
76
  }
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.branchSlug = branchSlug;
7
7
  exports.journalDir = journalDir;
8
8
  exports.supervisorLockPath = supervisorLockPath;
9
+ exports.integrationLockPath = integrationLockPath;
9
10
  exports.statePath = statePath;
10
11
  exports.requestsDir = requestsDir;
11
12
  exports.acksDir = acksDir;
@@ -36,6 +37,14 @@ function journalDir(repoRoot, branch) {
36
37
  function supervisorLockPath(repoRoot) {
37
38
  return path_1.default.join(fs_1.default.realpathSync(repoRoot), '.awm', 'journal', 'supervisor.lock');
38
39
  }
40
+ /** Lock de integración (R5.8/R5.9/C7, Task 10): único por PLAN físico, fuera
41
+ * de cualquier dir de rama — mismo criterio de `supervisorLockPath` (clavado
42
+ * por realpath). Se adquiere ANTES del primer join de una cohorte y retiene
43
+ * ownership mientras la generación del plan está pausada y ningún controller
44
+ * administrado corre. */
45
+ function integrationLockPath(repoRoot) {
46
+ return path_1.default.join(fs_1.default.realpathSync(repoRoot), '.awm', 'journal', 'integration.lock');
47
+ }
39
48
  function statePath(repoRoot, branch) {
40
49
  return path_1.default.join(journalDir(repoRoot, branch), 'state.json');
41
50
  }
@@ -381,6 +381,17 @@ function refIsAlive(ref) {
381
381
  if ((0, paths_1.isWindowsNative)()) {
382
382
  if (!pidExistsNative(ref.pid))
383
383
  return false;
384
+ // DELIBERADO (Ronda 3, ver el test 'refIsAlive en win32 usa process.kill(pid,0),
385
+ // NUNCA ps/pgrep'): esta rama NO consulta identidad — ni ps/pgrep ni WMI. La razon
386
+ // es un bug real de CI: `ps`/`pgrep` emulados por MSYS son ciegos a procesos nativos
387
+ // y devuelven exit 1 para pids genuinamente vivos, y confiar en ellos declaraba
388
+ // muertes falsas. `process.kill(pid, 0)` es la unica observacion no ciega.
389
+ //
390
+ // CONSECUENCIA CONOCIDA Y ACEPTADA: un pid RECICLADO por un proceso ajeno se reporta
391
+ // como "nuestro proceso sigue vivo". Eso sobre-reporta vida, que es la direccion
392
+ // SEGURA — jamas autoriza matar nada ajeno (de eso se encarga `win32LeaderReused`
393
+ // antes de cualquier senial) — a costa de que un teardown pueda quedarse esperando a
394
+ // un proceso que nunca fue nuestro. Se prefiere la espera sobre el riesgo.
384
395
  return ref.processGroup === ref.pid;
385
396
  }
386
397
  try {
@@ -530,10 +541,83 @@ async function terminateGroupConfirmed(ref, opts) {
530
541
  catch { /* idem */ }
531
542
  return waitUntilGone(opts.killGraceMs);
532
543
  }
544
+ /** true <=> el slot de PID que identifica al grupo (`ref.processGroup` — para
545
+ * todo caller real de esta funcion coincide con `ref.pid`, el lider que el
546
+ * propio caller spawneo: `captureRefFor`/`spawnStructured` siempre fijan
547
+ * `processGroup` al pgid real de ese pid recien creado, que en un detached
548
+ * spawn -setsid- es su PROPIO pid) esta hoy ocupado por un proceso VIVO cuya
549
+ * identidad (startTime + digest de `ps args`) NO coincide con la que
550
+ * capturamos para `ref`. Cubre reuso de PGID (post-review, hallazgo de
551
+ * revision de Task 13): el SO reciclo ese numero de pid hacia un proceso
552
+ * nuevo y no relacionado que se convirtio en lider de su propio grupo
553
+ * (`setpgid`/`setsid`) con el MISMO numero — sin este chequeo, la senial de
554
+ * `terminatePreviouslyOwnedGroup` de mas abajo alcanzaria a ese proceso
555
+ * ajeno. Una ausencia total o un zombie en ese slot NUNCA cuentan como
556
+ * reuso: o es nuestro propio lider aun sin reap (zombie), o el slot esta
557
+ * libre y cualquier miembro remanente del pgid es necesariamente
558
+ * descendiente nuestro (el SO no puede reutilizar el NUMERO de pid del
559
+ * lider para un proceso nuevo mientras ese pid siga ocupado, vivo o
560
+ * zombie). Fail-closed ante cualquier duda (R2.1/R4.8): un `ps` que no pudo
561
+ * correr, o cualquier campo indeterminado, se trata como reuso — nunca
562
+ * como autorizacion para senializar. */
563
+ /** Hermano win32 de `groupLeaderReused` (R4.8). Mismo contrato, distinta fuente de verdad:
564
+ * POSIX pregunta a `ps` por lstart/pgid/args; win32 pregunta a WMI por creationDate y
565
+ * commandLine, exactamente los dos campos con los que `captureRefFor` construyo el `ref`
566
+ * en esta plataforma. `true` <=> el PID esta ocupado por algo que NO es nuestro proceso.
567
+ *
568
+ * Fail-closed identico al POSIX: WMI indisponible o ilegible (`null`) NO prueba que el
569
+ * proceso sea nuestro, asi que se trata como ajeno y la senial jamas sale. Un `ref`
570
+ * degradado a `'unknown'` (WMI no respondio al capturarlo) tampoco autoriza nada — es la
571
+ * consecuencia correcta de no haber podido probar identidad al momento del spawn.
572
+ * `'absent'` es el unico caso que devuelve `false`: no hay nadie ahi a quien confundir. */
573
+ function win32LeaderReused(ref) {
574
+ const info = win32ProcessInfo(ref.processGroup);
575
+ if (info === 'absent')
576
+ return false;
577
+ if (info === null)
578
+ return true;
579
+ if (info.creationDate !== ref.startTime)
580
+ return true;
581
+ return identityDigest(info.commandLine, ref.spawnNonce, ref.argvDigest) !== ref.psArgsDigest;
582
+ }
583
+ function groupLeaderReused(ref) {
584
+ try {
585
+ const stat = psField(ref.processGroup, 'stat');
586
+ if (stat === null || stat.startsWith('Z'))
587
+ return false; // ausente o zombie: nunca un impostor vivo
588
+ const start = psField(ref.processGroup, 'lstart');
589
+ if (start === null || start !== ref.startTime)
590
+ return true;
591
+ const argsDig = psArgsDigestOf(ref.processGroup, ref.spawnNonce, ref.argvDigest);
592
+ if (argsDig === null || argsDig !== ref.psArgsDigest)
593
+ return true;
594
+ return false;
595
+ }
596
+ catch {
597
+ return true; // sin evidencia, jamas autorizar la senial (R2.1)
598
+ }
599
+ }
533
600
  /** Drena un grupo cuya propiedad fue capturada por el caller mientras el
534
601
  * lider aun estaba vivo. Se usa inmediatamente tras el exit del lider para
535
602
  * eliminar descendientes remanentes; el PGID no puede reutilizarse mientras
536
- * esos miembros sigan presentes. */
603
+ * esos miembros sigan presentes.
604
+ *
605
+ * Post-review (hallazgo de revision de Task 13): a diferencia de
606
+ * `terminateGroupConfirmed`, esta funcion se usa PRECISAMENTE cuando el lider
607
+ * ya se espera muerto — por eso no puede reusar `refIsAlive` (exige lider
608
+ * vivo) como gate de identidad. `groupLeaderReused` cubre el mismo riesgo
609
+ * (R4.8: jamas senializar sin identidad verificada) sin asumir que el lider
610
+ * sigue vivo. Antes de este fix, esta funcion confiaba en que el CALLER
611
+ * hubiera verificado identidad momentos antes: `exec-wrapper.ts` lo hace por
612
+ * construccion (llama esto en una ventana de milisegundos justo tras
613
+ * observar el exit del propio hijo), y el caller de Task 13
614
+ * (`stopOwnSupervisor` en `tracks.ts`) tambien, indirectamente — la capa de
615
+ * arriba (`gatherTeardownObservation`) ya recalcula `refIsAlive` en la MISMA
616
+ * tick antes de siquiera elegir la decision `stop-own-supervisor`. Pero esa
617
+ * garantia era implicita y externa al primitivo: un caller futuro (o un
618
+ * cambio en esa capa de arriba) podia perderla sin que este archivo lo
619
+ * notara. Con el chequeo ACA DENTRO, el primitivo deja de depender de la
620
+ * disciplina del caller — se auto-defiende, igual que `terminateGroupConfirmed`. */
537
621
  async function terminatePreviouslyOwnedGroup(ref, opts) {
538
622
  const waitUntilGone = async (maxMs) => {
539
623
  const deadline = Date.now() + maxMs;
@@ -546,13 +630,29 @@ async function terminatePreviouslyOwnedGroup(ref, opts) {
546
630
  };
547
631
  if (groupIsGone(ref.processGroup))
548
632
  return true;
633
+ // Windows PRIMERO: sale sin tocar la logica de grupos, que es semantica POSIX.
634
+ // El guard `groupLeaderReused` de abajo razona sobre PGIDs y no aplica aca — pero el
635
+ // PELIGRO que evita si aplica, y durante un tiempo win32 no tuvo ninguna proteccion
636
+ // equivalente: `groupIsGone` en esta plataforma solo pregunta si el PID existe
637
+ // (`pidExistsNative`), sin verificar identidad, asi que un PID reciclado por un proceso
638
+ // AJENO vivo se leia como "nuestro supervisor sigue vivo" y `killTreeWindows` lo mataba.
639
+ // Eso es exactamente el `kill(pid)` crudo que R4.8 prohibe. `win32LeaderReused` es el
640
+ // hermano que faltaba, con el mismo criterio fail-closed: sin prueba positiva de que el
641
+ // proceso es NUESTRO, jamas se le manda una senial.
549
642
  if ((0, paths_1.isWindowsNative)()) {
643
+ if (win32LeaderReused(ref))
644
+ return false;
550
645
  killTreeWindows(ref.pid);
551
646
  if (await waitUntilGone(opts.termGraceMs))
552
647
  return true;
553
648
  killTreeWindows(ref.pid);
554
649
  return waitUntilGone(opts.killGraceMs);
555
650
  }
651
+ // Un PGID cuyo slot de lider fue reciclado hacia un proceso vivo ajeno
652
+ // NUNCA se confunde con el nuestro (mismo criterio que `refIsAlive`
653
+ // aplica en `terminateGroupConfirmed`, adaptado a un lider ya-muerto).
654
+ if (groupLeaderReused(ref))
655
+ return false;
556
656
  try {
557
657
  process.kill(-ref.processGroup, 'SIGTERM');
558
658
  }
@@ -65,7 +65,11 @@ function emitRequest(repoRoot, branch, env) {
65
65
  (0, atomic_file_1.fsyncDirSync)(dir); // la ENTRADA renombrada tambien debe sobrevivir un crash (R1.3)
66
66
  return { requestId, idempotencyKey: env.idempotencyKey, payloadDigest: digestOf(payload), file: final };
67
67
  }
68
- const KNOWN_KINDS = ['job-request', 'register-entity', 'controller-heartbeat', 'verdict'];
68
+ const KNOWN_KINDS = [
69
+ 'job-request', 'register-entity', 'controller-heartbeat', 'verdict',
70
+ 'track-prepare-request', 'track-freeze-request', 'track-join-request',
71
+ 'track-teardown-request', 'track-finalize-request',
72
+ ];
69
73
  function isRecord(x) {
70
74
  return typeof x === 'object' && x !== null && !Array.isArray(x);
71
75
  }
@@ -8,12 +8,17 @@ exports.readJournal = readJournal;
8
8
  exports.writeJournal = writeJournal;
9
9
  exports.appendEvent = appendEvent;
10
10
  const fs_1 = __importDefault(require("fs"));
11
+ const crypto_1 = __importDefault(require("crypto"));
11
12
  const atomic_file_1 = require("../atomic-file");
12
13
  const types_1 = require("./types");
13
14
  const paths_1 = require("./paths");
14
- /** Schema 1 evoluciono de forma aditiva durante R1. Normalizamos solamente
15
+ /** Schema 1 evoluciono de forma aditiva durante R1/R5. Normalizamos solamente
15
16
  * campos que antes no existian; evidencia legacy queda deliberadamente con
16
- * fingerprint vacio para que el gate la considere stale, nunca certificada. */
17
+ * fingerprint vacio para que el gate la considere stale, nunca certificada.
18
+ * `tracks`/`trackContext` NO se normalizan por ausencia: son opcionales
19
+ * (solo el journal de un plan-con-tracks o de un track individual los
20
+ * lleva), asi que faltar es una forma legitima, no legacy — no se les
21
+ * inventa un default para un journal que jamas los tuvo (R9.2). */
17
22
  function normalizeSchemaOne(value) {
18
23
  if (typeof value !== 'object' || value === null || Array.isArray(value))
19
24
  return value;
@@ -24,12 +29,39 @@ function normalizeSchemaOne(value) {
24
29
  parsed.requestProblems = [];
25
30
  if (parsed.custodyDecisions === undefined)
26
31
  parsed.custodyDecisions = [];
32
+ // R9.3: journals legacy pre-R5 no tenian journalId. Se materializa
33
+ // DETERMINISTICAMENTE desde (branch, cycle.startedAt) para que dos
34
+ // lecturas del MISMO snapshot legacy nunca produzcan identidades
35
+ // distintas (a diferencia de emptyState, que usa randomUUID solo para
36
+ // journals genuinamente nuevos). Se persiste en la siguiente escritura
37
+ // CAS normal — la lectura por si sola sigue siendo read-only.
38
+ if (parsed.journalId === undefined) {
39
+ const branch = typeof parsed.branch === 'string' ? parsed.branch : '';
40
+ const cycle = typeof parsed.cycle === 'object' && parsed.cycle !== null
41
+ ? parsed.cycle : {};
42
+ const startedAt = typeof cycle.startedAt === 'string' ? cycle.startedAt : '';
43
+ parsed.journalId = `legacy-${crypto_1.default.createHash('sha256')
44
+ .update(`${branch}\0${startedAt}`).digest('hex').slice(0, 32)}`;
45
+ }
27
46
  if (typeof parsed.cycle === 'object' && parsed.cycle !== null && !Array.isArray(parsed.cycle)) {
28
47
  const cycle = parsed.cycle;
29
48
  if (cycle.status === 'IN_PROGRESS' && cycle.nextAction === undefined) {
30
49
  cycle.nextAction = { actionId: 'bootstrap-cycle', type: 'plan-cycle', target: 'cycle', preconditions: [], attempt: 0, state: 'pending' };
31
50
  }
32
51
  }
52
+ // R7 Task 12: `Job.satisfies` migró de `string` a `string[]` (varios items
53
+ // satisfechos por un mismo job, ej. el job canónico de integración final).
54
+ // Un journal legacy con `satisfies` string se normaliza a un array de un
55
+ // elemento — jamás se pierde el enlace ya persistido.
56
+ if (typeof parsed.jobs === 'object' && parsed.jobs !== null && !Array.isArray(parsed.jobs)) {
57
+ for (const job of Object.values(parsed.jobs)) {
58
+ if (typeof job !== 'object' || job === null || Array.isArray(job))
59
+ continue;
60
+ const j = job;
61
+ if (typeof j.satisfies === 'string')
62
+ j.satisfies = [j.satisfies];
63
+ }
64
+ }
33
65
  if (Array.isArray(parsed.verdicts)) {
34
66
  for (const item of parsed.verdicts) {
35
67
  if (typeof item !== 'object' || item === null || Array.isArray(item))
@@ -1,12 +1,17 @@
1
1
  "use strict";
2
2
  // Única fuente de tipos del journal. CONSTITUTION: estados separados, nunca
3
3
  // sobrecargados; shape validation antes de usar campos deserializados.
4
+ var __importDefault = (this && this.__importDefault) || function (mod) {
5
+ return (mod && mod.__esModule) ? mod : { "default": mod };
6
+ };
4
7
  Object.defineProperty(exports, "__esModule", { value: true });
5
8
  exports.GENERATION_STATES = exports.EXECUTION_STATES = void 0;
6
9
  exports.emptyState = emptyState;
7
10
  exports.isWellFormedState = isWellFormedState;
8
11
  exports.isWellFormedProcessRef = isWellFormedProcessRef;
9
12
  exports.isWellFormedJob = isWellFormedJob;
13
+ const crypto_1 = __importDefault(require("crypto"));
14
+ const types_1 = require("../tracks/types");
10
15
  exports.EXECUTION_STATES = [
11
16
  'received', 'spawn-intent', 'claimed', 'running',
12
17
  'exited', 'cancel-requested', 'cancelled', 'orphaned',
@@ -14,9 +19,21 @@ exports.EXECUTION_STATES = [
14
19
  exports.GENERATION_STATES = [
15
20
  'active', 'controller-suspected-stall', 'terminated', 'superseded',
16
21
  ];
22
+ /** Debe permanecer sincronizado con el union type `CohortPhase` de
23
+ * `../tracks/types` — ese módulo no exporta un const array propio (a
24
+ * diferencia de TRACK_PHASES), así que replicamos la lista SOLO para shape
25
+ * validation en el journal (nunca para lógica de protocolo, que vive en
26
+ * tracks/protocol.ts). */
27
+ const COHORT_PHASES = [
28
+ 'PREPARING', 'ACTIVE', 'JOINING', 'FINAL_QA',
29
+ 'FINAL_INTEGRATION', 'FINAL_INTERLOCK', 'COMPLETE',
30
+ 'FALLBACK_PENDING', 'SERIAL', 'BLOCKED',
31
+ ];
32
+ const _cohortPhasesComplete = true;
33
+ const VERIFICATION_KINDS = ['test', 'lint', 'sensors', 'review', 'qa', 'interlock', 'track-integration'];
17
34
  function emptyState(branch) {
18
35
  return {
19
- schema: 1, revision: 0, branch,
36
+ schema: 1, revision: 0, journalId: `j-${crypto_1.default.randomUUID()}`, branch,
20
37
  cycle: {
21
38
  status: 'IN_PROGRESS', startedAt: new Date().toISOString(),
22
39
  nextAction: { actionId: 'bootstrap-cycle', type: 'plan-cycle', target: 'cycle', preconditions: [], attempt: 0, state: 'pending' },
@@ -35,6 +52,8 @@ function isWellFormedState(x) {
35
52
  return false;
36
53
  if (typeof x.revision !== 'number')
37
54
  return false;
55
+ if (typeof x.journalId !== 'string' || x.journalId.length === 0)
56
+ return false;
38
57
  if (typeof x.branch !== 'string')
39
58
  return false;
40
59
  if (!isObj(x.cycle) || !['IN_PROGRESS', 'COMPLETE', 'BLOCKED'].includes(String(x.cycle.status))
@@ -48,7 +67,7 @@ function isWellFormedState(x) {
48
67
  return false;
49
68
  if (!Array.isArray(x.cycleVerificationPlan) || !Array.isArray(x.verdicts) || !Array.isArray(x.fixes))
50
69
  return false;
51
- if (!Array.isArray(x.requiredVerifiers) || !x.requiredVerifiers.every((kind) => ['test', 'lint', 'sensors', 'review', 'qa', 'interlock'].includes(String(kind)))
70
+ if (!Array.isArray(x.requiredVerifiers) || !x.requiredVerifiers.every((kind) => VERIFICATION_KINDS.includes(kind))
52
71
  || !Array.isArray(x.dispatches) || !x.dispatches.every(isWellFormedDispatch))
53
72
  return false;
54
73
  if (!isObj(x.jobs) || !Object.values(x.jobs).every(isWellFormedJob))
@@ -65,6 +84,33 @@ function isWellFormedState(x) {
65
84
  return false;
66
85
  if (!x.verdicts.every(isWellFormedVerdict) || !x.fixes.every(isWellFormedFix))
67
86
  return false;
87
+ if (x.tracks !== undefined && (!Array.isArray(x.tracks) || !x.tracks.every(isWellFormedTrackRef)))
88
+ return false;
89
+ if (x.trackContext !== undefined && !isWellFormedTrackContext(x.trackContext))
90
+ return false;
91
+ if (x.cohortPhase !== undefined && !COHORT_PHASES.includes(String(x.cohortPhase)))
92
+ return false;
93
+ if (x.cohortBaseSha !== undefined && typeof x.cohortBaseSha !== 'string')
94
+ return false;
95
+ if (x.cohortPlanHeadSha !== undefined && typeof x.cohortPlanHeadSha !== 'string')
96
+ return false;
97
+ if (x.cohortFallbackReason !== undefined && typeof x.cohortFallbackReason !== 'string')
98
+ return false;
99
+ if (x.trackIntegration !== undefined && !isWellFormedTrackIntegration(x.trackIntegration))
100
+ return false;
101
+ if (x.freezeRequested !== undefined && typeof x.freezeRequested !== 'boolean')
102
+ return false;
103
+ if (x.frozen !== undefined && !(isObj(x.frozen) && typeof x.frozen.headSha === 'string' && typeof x.frozen.at === 'string'))
104
+ return false;
105
+ if (x.cohortParallelInvalidatedBy !== undefined && !strings(x.cohortParallelInvalidatedBy))
106
+ return false;
107
+ if (x.globalQaHeadSha !== undefined && typeof x.globalQaHeadSha !== 'string')
108
+ return false;
109
+ if (x.finalIntegrationJobId !== undefined && typeof x.finalIntegrationJobId !== 'string')
110
+ return false;
111
+ if (x.qaFinalizeRequested !== undefined
112
+ && !(isObj(x.qaFinalizeRequested) && typeof x.qaFinalizeRequested.headSha === 'string' && typeof x.qaFinalizeRequested.at === 'string'))
113
+ return false;
68
114
  return true;
69
115
  }
70
116
  function isWellFormedNextAction(x) {
@@ -80,7 +126,7 @@ function strings(x) {
80
126
  }
81
127
  function isWellFormedVerificationItem(x) {
82
128
  return isObj(x) && typeof x.id === 'string'
83
- && ['test', 'lint', 'sensors', 'review', 'qa', 'interlock'].includes(String(x.kind))
129
+ && VERIFICATION_KINDS.includes(x.kind)
84
130
  && (x.satisfiedBy === undefined || typeof x.satisfiedBy === 'string');
85
131
  }
86
132
  function isWellFormedReviewObligation(x) {
@@ -159,7 +205,50 @@ function isWellFormedJob(x) {
159
205
  && (x.logPath === undefined || typeof x.logPath === 'string')
160
206
  && (x.result === undefined || (isObj(x.result) && typeof x.result.exitCode === 'number'
161
207
  && typeof x.result.endedAt === 'string' && typeof x.result.resultPath === 'string'))
162
- && (x.satisfies === undefined || typeof x.satisfies === 'string')
208
+ && (x.satisfies === undefined || strings(x.satisfies))
163
209
  && (x.attemptOf === undefined || typeof x.attemptOf === 'string')
164
210
  && exports.EXECUTION_STATES.includes(x.executionState);
165
211
  }
212
+ function isWellFormedTrackContext(x) {
213
+ return isObj(x) && typeof x.trackId === 'string' && x.trackId.length > 0
214
+ && strings(x.taskIds)
215
+ && typeof x.planDigest === 'string'
216
+ && typeof x.baseSha === 'string'
217
+ && typeof x.planJournalId === 'string' && x.planJournalId.length > 0;
218
+ }
219
+ function isWellFormedSupervisorIntent(x) {
220
+ return isObj(x) && typeof x.nonce === 'string' && x.nonce.length > 0
221
+ && strings(x.argv) && typeof x.claimPath === 'string';
222
+ }
223
+ function isWellFormedJoinIntent(x) {
224
+ return isObj(x) && typeof x.expectedPlanHeadSha === 'string' && typeof x.expectedTrackHeadSha === 'string'
225
+ && x.strategy === types_1.JOIN_STRATEGY_NO_FF;
226
+ }
227
+ function isWellFormedTeardownIntent(x) {
228
+ return isObj(x) && typeof x.worktreePath === 'string' && typeof x.branch === 'string'
229
+ && (x.supervisorNonce === undefined || typeof x.supervisorNonce === 'string');
230
+ }
231
+ /** Shape completa (R9.2/R9.7): fencingToken y readinessNonce nunca vacios —
232
+ * igual criterio que ProcessRef, la identidad es todo-o-nada. */
233
+ function isWellFormedTrackRef(x) {
234
+ if (!isObj(x))
235
+ return false;
236
+ return typeof x.trackId === 'string' && x.trackId.length > 0
237
+ && typeof x.worktreePath === 'string' && x.worktreePath.length > 0
238
+ && typeof x.branch === 'string' && x.branch.length > 0
239
+ && strings(x.ownership) && strings(x.sharedResources) && strings(x.dependsOn)
240
+ && typeof x.fencingToken === 'string' && x.fencingToken.length > 0
241
+ && types_1.TRACK_PHASES.includes(x.phase)
242
+ && typeof x.readinessNonce === 'string' && x.readinessNonce.length > 0
243
+ && (x.readinessAt === undefined || typeof x.readinessAt === 'string')
244
+ && (x.frozenHeadSha === undefined || typeof x.frozenHeadSha === 'string')
245
+ && (x.supervisorIntent === undefined || isWellFormedSupervisorIntent(x.supervisorIntent))
246
+ && (x.supervisorProcessRef === undefined || isWellFormedProcessRef(x.supervisorProcessRef))
247
+ && (x.joinIntent === undefined || isWellFormedJoinIntent(x.joinIntent))
248
+ && (x.teardownIntent === undefined || isWellFormedTeardownIntent(x.teardownIntent))
249
+ && (x.joinedCommitSha === undefined || typeof x.joinedCommitSha === 'string')
250
+ && (x.blockedReason === undefined || typeof x.blockedReason === 'string');
251
+ }
252
+ function isWellFormedTrackIntegration(x) {
253
+ return isObj(x) && strings(x.argv) && strings(x.paths) && typeof x.planDigest === 'string';
254
+ }
@@ -12,6 +12,7 @@ exports.isWindowsNative = isWindowsNative;
12
12
  exports.platformLabel = platformLabel;
13
13
  exports.noteWindowsCaveat = noteWindowsCaveat;
14
14
  exports.resolveOnPath = resolveOnPath;
15
+ exports.sameExistingPath = sameExistingPath;
15
16
  // cli/src/core/paths.ts
16
17
  //
17
18
  // Single source of truth for home / AWM_HOME resolution and platform detection.
@@ -158,3 +159,53 @@ function resolveOnPath(bin) {
158
159
  const entries = (process.env.PATH ?? '').split(path_1.default.delimiter).filter(Boolean);
159
160
  return entries.some((dir) => matches(path_1.default.join(dir, bin)));
160
161
  }
162
+ /**
163
+ * ¿Dos rutas nombran el MISMO directorio/archivo existente?
164
+ *
165
+ * Comparar `fs.realpathSync(a) !== fs.realpathSync(b)` como strings parece obvio y es
166
+ * incorrecto en Windows: el mismo directorio tiene más de una grafía legítima —
167
+ * `C:\Users\RUNNER~1\AppData\...` (nombre corto 8.3, lo que devuelve `os.tmpdir()`) y
168
+ * `C:/Users/runneradmin/AppData/...` (lo que devuelve `git worktree list`, con separadores
169
+ * POSIX) — y `realpathSync` NO reconcilia las dos. En CI eso hacía que
170
+ * `ownedWorktreeExists` respondiera "este worktree no es mío" sobre un worktree propio.
171
+ *
172
+ * Falla cerrado, así que no corrompía nada — pero dejaba la adopción tras crash inservible
173
+ * en Windows: el teardown no podía probar propiedad, los tracks quedaban `BLOCKED`, y la
174
+ * cohorte no llegaba nunca ni a `SERIAL` ni a `ACTIVE`. Un solo bug de comparación, quince
175
+ * tests en cascada.
176
+ *
177
+ * La identidad se toma del filesystem — `(dev, ino)` — que es agnóstica a separadores, a
178
+ * mayúsculas y a nombres cortos. Cuando el volumen no expone inode (`ino === 0`, posible en
179
+ * algunos filesystems de Windows) se cae a `realpathSync.native` — que sí resuelve 8.3 vía
180
+ * la API del SO — normalizando separadores y capitalización.
181
+ *
182
+ * Cualquier fallo de `stat` significa que al menos una no existe: `false`. La identidad
183
+ * nunca se afirma sin prueba.
184
+ */
185
+ function sameExistingPath(a, b) {
186
+ let sa;
187
+ let sb;
188
+ try {
189
+ sa = fs_1.default.statSync(a);
190
+ sb = fs_1.default.statSync(b);
191
+ }
192
+ catch {
193
+ return false;
194
+ }
195
+ // `ino === 0` en AMBOS lados no prueba identidad: probaría que el filesystem no la
196
+ // reporta. Tratarlo como match haría coincidir dos rutas cualesquiera del mismo volumen.
197
+ if (sa.ino !== 0 && sb.ino !== 0)
198
+ return sa.dev === sb.dev && sa.ino === sb.ino;
199
+ const canonical = (p) => {
200
+ try {
201
+ const real = fs_1.default.realpathSync.native(p).replaceAll('\\', '/');
202
+ return isWindowsNative() ? real.toLowerCase() : real;
203
+ }
204
+ catch {
205
+ return null;
206
+ }
207
+ };
208
+ const ca = canonical(a);
209
+ const cb = canonical(b);
210
+ return ca !== null && ca === cb;
211
+ }
@@ -154,5 +154,19 @@ function ensureSkillsGitignored(root, agents) {
154
154
  * project extensions and stay out of `.awm/profile.json`.
155
155
  */
156
156
  function shouldRecordExtension(bundleScope, effective) {
157
- return bundleScope === 'project' && effective === 'local';
157
+ // El criterio es el ALCANCE EFECTIVO, no el del bundle.
158
+ //
159
+ // Antes exigia ademas `bundleScope === 'project'`, asi que un bundle baseline
160
+ // instalado explicitamente con `--scope local` no quedaba registrado en ningun lado:
161
+ // `awm sync` reconcilia lo que declara el profile, y esos artefactos no estaban ahi.
162
+ // Resultado: nadie los refrescaba nunca. `update` cubre lo global y `sync` lo del
163
+ // profile; eso caia en el medio.
164
+ //
165
+ // Y no es un caso raro: `awm add dev --scope local` es exactamente lo que el playbook
166
+ // de aceptacion (AG-03) le pide correr a cualquiera que verifique un proveedor.
167
+ //
168
+ // Si alguien pidio artefactos de proyecto, el profile lo dice — que es lo que hace
169
+ // que un companero que clona el repo y corre `awm sync` obtenga lo mismo.
170
+ void bundleScope;
171
+ return effective === 'local';
158
172
  }