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
@@ -0,0 +1,185 @@
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
+ exports.IntegrationLockBlockedError = exports.decideJoinReconciliation = void 0;
7
+ exports.validateJoinReadiness = validateJoinReadiness;
8
+ exports.planJoinOrder = planJoinOrder;
9
+ exports.acquireIntegrationLock = acquireIntegrationLock;
10
+ exports.releaseIntegrationLock = releaseIntegrationLock;
11
+ exports.stopControllerGenerationConfirmed = stopControllerGenerationConfirmed;
12
+ // R5.2/R5.8/R5.9/R6.3/R6.4/R6.5/C7 (Task 10): quiescencia del plan y
13
+ // precondiciones de join. Dos familias de funciones acá:
14
+ // - `validateJoinReadiness`/`planJoinOrder`: PURAS, sin I/O — misma
15
+ // autoridad única que `protocol.ts`, pero para el momento previo al
16
+ // primer merge (Task 11 las consume; acá solo se construyen y prueban).
17
+ // - `acquireIntegrationLock`/`releaseIntegrationLock`/
18
+ // `stopControllerGenerationConfirmed`: efectos reales, modelados 1:1
19
+ // sobre `watch/lock.ts` (`acquireLock`/`releaseLock`/`LockBlockedError`)
20
+ // y sobre el patrón de terminación con identidad confirmada que
21
+ // `supervisor.ts` ya usa en su camino COMPLETE — nunca un kill crudo.
22
+ const fs_1 = __importDefault(require("fs"));
23
+ const path_1 = __importDefault(require("path"));
24
+ const crypto_1 = __importDefault(require("crypto"));
25
+ const store_1 = require("../journal/store");
26
+ const paths_1 = require("../journal/paths");
27
+ const process_1 = require("../journal/process");
28
+ const atomic_file_1 = require("../atomic-file");
29
+ const types_1 = require("../journal/types");
30
+ // R6.8/R6.9/C7 (Task 11): `decideJoinReconciliation` YA vive en `protocol.ts`
31
+ // desde Task 1 (`reconcileProtocol` la llama para su handler de
32
+ // `join-observation`) — regla de autoridad única: esta task NO crea una
33
+ // segunda copia acá. `join-reconcile.test.ts` prueba la matriz completa
34
+ // IMPORTANDO este re-export, así que cualquier fix que la matriz descubra
35
+ // tiene que vivir en `protocol.ts` (y volver a correr la exploración de
36
+ // Task 1 en el mismo commit) para que el test del gate siga significando algo.
37
+ var protocol_1 = require("./protocol");
38
+ Object.defineProperty(exports, "decideJoinReconciliation", { enumerable: true, get: function () { return protocol_1.decideJoinReconciliation; } });
39
+ /** Autoridad única de precondiciones de join: TODO hecho indemostrable o
40
+ * adverso bloquea — nunca se descarta un dirty path del mensaje (R6.5), y
41
+ * el HEAD congelado se compara byte a byte contra el HEAD real observado
42
+ * (protege contra el track mutando su propio worktree después de FROZEN). */
43
+ function validateJoinReadiness(x) {
44
+ const reasons = [];
45
+ if (x.actualHeadSha !== x.frozenHeadSha)
46
+ reasons.push(`HEAD cambió: esperado ${x.frozenHeadSha}, actual ${x.actualHeadSha}`);
47
+ if (x.dirtyPaths.length > 0)
48
+ reasons.push(`worktree sucio: ${[...x.dirtyPaths].sort().join(', ')}`);
49
+ if (!x.gatePass)
50
+ reasons.push('gate local rojo');
51
+ if (x.liveJobs > 0)
52
+ reasons.push(`${x.liveJobs} jobs vivos`);
53
+ if (x.supervisorAlive)
54
+ reasons.push('supervisor de track vivo');
55
+ if (x.lockExists)
56
+ reasons.push('lock de track retenido');
57
+ return reasons.length === 0 ? { ok: true } : { ok: false, reasons };
58
+ }
59
+ /** Ownership REAL (post-hoc, desde commits ya congelados — ver
60
+ * `assessActualOwnership` en `ownership.ts`), no la declaración de T4: un
61
+ * solo track con `outsideOwnership` no vacío serializa el ORDEN de los
62
+ * joins restantes (R5.8/R5.9) — nunca revierte merges ya hechos. Un
63
+ * `globalClasses` no vacío además invalida el paralelismo de la cohorte
64
+ * entera (C5): `parallelInvalidatedBy` nombra `<trackId>:<clase>` para cada
65
+ * hallazgo, para que el consumidor (T10 Step 6 en `tracks.ts`) lo persista
66
+ * como `cohortParallelInvalidatedBy` y ningún `awm watch` futuro lo pierda. */
67
+ function planJoinOrder(trackIds, assessments) {
68
+ const violations = {};
69
+ const parallelInvalidatedBy = [];
70
+ let serial = false;
71
+ for (const trackId of trackIds) {
72
+ const a = assessments[trackId];
73
+ if (a === undefined)
74
+ continue;
75
+ if (a.outsideOwnership.length > 0) {
76
+ violations[trackId] = [...a.outsideOwnership];
77
+ serial = true;
78
+ }
79
+ if (a.globalClasses.length > 0) {
80
+ serial = true;
81
+ for (const cls of a.globalClasses)
82
+ parallelInvalidatedBy.push(`${trackId}:${cls}`);
83
+ }
84
+ }
85
+ return { mode: serial ? 'serial-joins' : 'parallel-joins', order: [...trackIds], violations, parallelInvalidatedBy };
86
+ }
87
+ // --- Lock de integración (R5.8/R5.9/C7) -------------------------------------
88
+ /** Identidad indemostrable en el lock: BLOQUEAR con error distinto — mismo
89
+ * criterio que `LockBlockedError` de `watch/lock.ts`, nunca se reclama lo
90
+ * que no se puede probar muerto. */
91
+ class IntegrationLockBlockedError extends Error {
92
+ constructor(message) { super(message); this.name = 'IntegrationLockBlockedError'; }
93
+ }
94
+ exports.IntegrationLockBlockedError = IntegrationLockBlockedError;
95
+ function isWellFormedIntegrationLockBody(x) {
96
+ return (0, types_1.isWellFormedProcessRef)(x)
97
+ && typeof x.planJournalId === 'string'
98
+ && typeof x.expectedPlanHeadSha === 'string';
99
+ }
100
+ /** Modelado 1:1 sobre `acquireLock` (`watch/lock.ts`): creación EXCLUSIVA
101
+ * real (`wx`), 0600, fsync de archivo + directorio, identidad completa
102
+ * (`ProcessRef`) más el contexto de integración (`planJournalId` +
103
+ * `expectedPlanHeadSha`) para que cualquier lector externo pueda auditar
104
+ * QUÉ mutación se autorizó, no solo QUIÉN la retiene. */
105
+ function acquireIntegrationLock(planRoot, ctx) {
106
+ const lp = (0, paths_1.integrationLockPath)(planRoot);
107
+ fs_1.default.mkdirSync(path_1.default.dirname(lp), { recursive: true, mode: 0o700 });
108
+ const self = (0, process_1.captureSelfRef)(crypto_1.default.randomBytes(8).toString('hex'));
109
+ const body = { ...self, ...ctx };
110
+ const serialized = JSON.stringify(body, null, 2) + '\n';
111
+ for (let attempt = 0; attempt < 2; attempt++) {
112
+ try {
113
+ const fd = fs_1.default.openSync(lp, 'wx', 0o600);
114
+ try {
115
+ fs_1.default.writeFileSync(fd, serialized);
116
+ fs_1.default.fsyncSync(fd);
117
+ }
118
+ finally {
119
+ fs_1.default.closeSync(fd);
120
+ }
121
+ (0, atomic_file_1.fsyncDirSync)(path_1.default.dirname(lp));
122
+ return { ref: self, path: lp };
123
+ }
124
+ catch (error) {
125
+ if (error.code !== 'EEXIST')
126
+ throw error;
127
+ }
128
+ let prior;
129
+ try {
130
+ prior = JSON.parse(fs_1.default.readFileSync(lp, 'utf8'));
131
+ }
132
+ catch {
133
+ throw new IntegrationLockBlockedError(`integration.lock ilegible en ${lp}: identidad indemostrable — BLOQUEADO (no se reclama)`);
134
+ }
135
+ if (!isWellFormedIntegrationLockBody(prior)) {
136
+ throw new IntegrationLockBlockedError(`integration.lock con shape inválido en ${lp}: identidad indemostrable — BLOQUEADO (no se reclama)`);
137
+ }
138
+ if ((0, process_1.refIsAlive)(prior)) {
139
+ throw new Error(`integración activa (pid ${prior.pid}) sobre este plan`);
140
+ }
141
+ process.stderr.write('awm watch: integration.lock previo con identidad muerta probada — reclamando\n');
142
+ fs_1.default.rmSync(lp, { force: true });
143
+ }
144
+ throw new Error('no se pudo adquirir integration.lock tras reintento único (carrera persistente)');
145
+ }
146
+ function releaseIntegrationLock(handle) {
147
+ try {
148
+ const onDisk = JSON.parse(fs_1.default.readFileSync(handle.path, 'utf8'));
149
+ if (onDisk.spawnNonce === handle.ref.spawnNonce)
150
+ fs_1.default.rmSync(handle.path);
151
+ }
152
+ catch { /* ya ausente o ilegible: no tocar lo que no es nuestro */ }
153
+ }
154
+ // --- Pausa de la generación del plan (R5.8/C7) ------------------------------
155
+ /** "Ningún controller administrado sigue corriendo durante el lease" (Step 5
156
+ * del plan R5-T10): a diferencia de `resolveGeneration` (generations.ts,
157
+ * gobierna reemplazo POR STALL, con backoff/adapter), esto es una pausa
158
+ * ADMINISTRATIVA incondicional antes de integrar — termina, con identidad
159
+ * confirmada, cualquier generación del PLAN todavía activa. Sin generación
160
+ * activa, es un no-op (ya está pausado). Cualquier fallo en confirmar la
161
+ * terminación lanza — jamás se adquiere el lock de integración con un
162
+ * controller potencialmente vivo. */
163
+ async function stopControllerGenerationConfirmed(planRoot, branch, grace) {
164
+ const r = (0, store_1.readJournal)(planRoot, branch);
165
+ if (r.corrupt || r.state === null)
166
+ throw new Error('journal corrupto: no se puede pausar la generación del plan (R1.6)');
167
+ const s = r.state;
168
+ // Misma definición que `activeGeneration` (`watch/generations.ts`),
169
+ // reimplementada inline: `core/tracks` no depende de `commands/watch`
170
+ // (dirección de capas), y esto es una sola línea sin lógica propia que
171
+ // proteger de una duplicación real.
172
+ const gen = s.generations.find((g) => g.state === 'active' || g.state === 'controller-suspected-stall');
173
+ if (gen === undefined)
174
+ return;
175
+ for (const ref of [gen.processRef, gen.wrapperRef]) {
176
+ if (ref === undefined || (0, process_1.groupIsGone)(ref.processGroup))
177
+ continue;
178
+ const confirmed = await (0, process_1.terminateGroupConfirmed)(ref, grace);
179
+ if (!confirmed)
180
+ throw new Error(`no se pudo confirmar la terminación de la generación ${gen.n} del plan antes de integrar (C7)`);
181
+ }
182
+ gen.state = 'terminated';
183
+ (0, store_1.writeJournal)(planRoot, branch, s);
184
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'plan-generation-stopped', n: gen.n });
185
+ }
@@ -0,0 +1,100 @@
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
+ exports.ownershipPrefix = ownershipPrefix;
7
+ exports.canonicalResource = canonicalResource;
8
+ exports.assessDeclaredIndependence = assessDeclaredIndependence;
9
+ exports.assessActualOwnership = assessActualOwnership;
10
+ const path_1 = __importDefault(require("path"));
11
+ const GLOBAL = [
12
+ { kind: 'lockfile', re: /(^|\/)(?:package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|poetry\.lock)$/i },
13
+ { kind: 'manifest', re: /(^|\/)(?:package\.json|Cargo\.toml|pyproject\.toml)$/i },
14
+ { kind: 'migration', re: /(^|\/)(?:migrations?|schema)\//i },
15
+ { kind: 'snapshot', re: /(?:^|\/)(__snapshots__\/|[^/]+\.snap$)/i },
16
+ { kind: 'generated', re: /(^|\/)(?:dist|generated|coverage)\//i },
17
+ ];
18
+ const canon = (p) => path_1.default.posix.normalize(p.replaceAll('\\', '/')).replace(/^\.\//, '');
19
+ const key = (p) => canon(p).toLocaleLowerCase('en-US');
20
+ const GLOB_CHARS = /[*?[\]{}]/;
21
+ /**
22
+ * Formas soportadas: `dir/sub/file.ts`, `dir/`, `dir/*`, `dir/**`. Cualquier otro glob
23
+ * (intermedio, `?`, clases, llaves) se rechaza: un patrón que no sabemos expandir no puede
24
+ * usarse para AFIRMAR que dos tracks no se pisan.
25
+ */
26
+ function ownershipPrefix(owner) {
27
+ const trimmed = canon(owner).replace(/\/(?:\*\*|\*)$/, '/');
28
+ if (GLOB_CHARS.test(trimmed))
29
+ throw new Error(`ownership no soporta este glob: ${owner}`);
30
+ return key(trimmed);
31
+ }
32
+ const covers = (owner, actual) => {
33
+ const o = ownershipPrefix(owner);
34
+ const a = key(actual);
35
+ return o.endsWith('/') ? a === o.slice(0, -1) || a.startsWith(o) : a === o;
36
+ };
37
+ /** Un patrón inexpandible cuenta como intersección: nunca habilita paralelismo por ignorancia. */
38
+ const intersects = (left, right) => {
39
+ try {
40
+ return covers(left, right) || covers(right, left);
41
+ }
42
+ catch {
43
+ return true;
44
+ }
45
+ };
46
+ /** Simétrico del anterior: un patrón inexpandible nunca prueba propiedad. */
47
+ const proves = (owner, actual) => {
48
+ try {
49
+ return covers(owner, actual);
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ };
55
+ function canonicalResource(raw) {
56
+ const match = raw.match(/^([a-z][a-z0-9-]*):(.+)$/i);
57
+ if (match === null || match[2].trim().length === 0)
58
+ throw new Error(`recurso debe usar <clase>:<valor>: ${raw}`);
59
+ // Case-fold el valor igual que `key()` hace con paths: sin esto, `db:Dev`
60
+ // y `db:dev` producen strings distintos y el Set.has() de
61
+ // assessDeclaredIndependence no detecta la colision (fail-open).
62
+ return `${match[1].toLowerCase()}:${match[2].trim().toLocaleLowerCase('en-US')}`;
63
+ }
64
+ function assessDeclaredIndependence(tracks) {
65
+ const reasons = new Set();
66
+ for (const t of tracks) {
67
+ for (const owner of t.ownership) {
68
+ try {
69
+ ownershipPrefix(owner);
70
+ }
71
+ catch {
72
+ reasons.add(`unsupported-glob:${canon(owner)}`);
73
+ }
74
+ const global = GLOBAL.find((g) => g.re.test(canon(owner)));
75
+ if (global !== undefined)
76
+ reasons.add(`global:${global.kind}:${canon(owner)}`);
77
+ }
78
+ }
79
+ for (let i = 0; i < tracks.length; i++)
80
+ for (let j = i + 1; j < tracks.length; j++) {
81
+ for (const left of tracks[i].ownership)
82
+ for (const right of tracks[j].ownership) {
83
+ if (intersects(left, right))
84
+ reasons.add(`path:${canon(right)}`);
85
+ }
86
+ const rightResources = new Set(tracks[j].sharedResources.map(canonicalResource));
87
+ for (const resource of tracks[i].sharedResources.map(canonicalResource)) {
88
+ if (rightResources.has(resource))
89
+ reasons.add(`resource:${resource}`);
90
+ }
91
+ }
92
+ return { parallel: reasons.size === 0, reasons: [...reasons].sort() };
93
+ }
94
+ function assessActualOwnership(track, changes) {
95
+ const actual = changes.flatMap((c) => c.oldPath === undefined ? [c.path] : [c.oldPath, c.path]);
96
+ return {
97
+ outsideOwnership: actual.filter((p) => !track.ownership.some((o) => proves(o, p))).sort(),
98
+ globalClasses: actual.flatMap((p) => GLOBAL.filter((g) => g.re.test(canon(p))).map((g) => `${g.kind}:${canon(p)}`)).sort(),
99
+ };
100
+ }
@@ -0,0 +1,108 @@
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
+ exports.parseTrackPlan = parseTrackPlan;
7
+ const path_1 = __importDefault(require("path"));
8
+ const taskHeading = /^### Task ([^:]+):/;
9
+ const trackLine = /^\*\*Track:\*\*\s*(.*)$/;
10
+ const fileLine = /^- (?:Create|Modify|Test|Delete):\s+`([^`]+)`/;
11
+ const EXACT_MATCH_ERROR = 'membresía y filas de ## Tracks requieren coincidencia exacta';
12
+ function jsonStrings(label, raw) {
13
+ if (raw === undefined)
14
+ throw new Error(`${label} es obligatorio para paralelismo`);
15
+ let value;
16
+ try {
17
+ value = JSON.parse(raw);
18
+ }
19
+ catch {
20
+ throw new Error(`${label} debe ser JSON string[]`);
21
+ }
22
+ if (!Array.isArray(value) || value.length === 0 || value.some((x) => typeof x !== 'string' || x.length === 0)) {
23
+ throw new Error(`${label} debe ser JSON string[] no vacío`);
24
+ }
25
+ return value;
26
+ }
27
+ function canonicalFile(raw) {
28
+ const withoutLines = raw.replace(/:\d+(?:-\d+)?$/, '');
29
+ const posix = withoutLines.replaceAll('\\', '/');
30
+ const normalized = path_1.default.posix.normalize(posix);
31
+ if (path_1.default.posix.isAbsolute(normalized) || normalized === '..' || normalized.startsWith('../')) {
32
+ throw new Error(`Files path fuera del repo: ${raw}`);
33
+ }
34
+ return normalized.replace(/^\.\//, '');
35
+ }
36
+ function parseTrackPlan(source, checkRef) {
37
+ const lines = source.replace(/\r\n/g, '\n').split('\n');
38
+ const hasMembership = lines.some((line) => trackLine.test(line));
39
+ const tracksHeading = lines.findIndex((line) => line.trim() === '## Tracks');
40
+ if (!hasMembership && tracksHeading < 0)
41
+ return { mode: 'serial', reason: 'no-tracks' };
42
+ if (!hasMembership || tracksHeading < 0)
43
+ throw new Error('Track membership y ## Tracks deben coexistir');
44
+ const argvRaw = lines.find((l) => l.startsWith('**Integration argv:**'))?.slice('**Integration argv:**'.length).trim();
45
+ const pathsRaw = lines.find((l) => l.startsWith('**Integration paths:**'))?.slice('**Integration paths:**'.length).trim();
46
+ const integration = { argv: jsonStrings('Integration argv', argvRaw), paths: jsonStrings('Integration paths', pathsRaw) };
47
+ const declared = new Map();
48
+ for (const line of lines.slice(tracksHeading + 1)) {
49
+ if (line.startsWith('## '))
50
+ break;
51
+ const cells = line.split('|').slice(1, -1).map((x) => x.trim());
52
+ if (cells.length !== 3 || cells[0] === 'Track' || /^[-:]+$/.test(cells[0]))
53
+ continue;
54
+ const [id, dependsRaw, resourcesRaw] = cells;
55
+ if (!id || id === '.' || id === '..' || id.startsWith('-') || id.includes('/') || id.includes('\\') || !checkRef(id)) {
56
+ throw new Error(`track id inválido: ${JSON.stringify(id)}`);
57
+ }
58
+ if (declared.has(id))
59
+ throw new Error(`track duplicado: ${id}`);
60
+ const resources = resourcesRaw === '[]' ? [] : resourcesRaw.length === 0 ? null : resourcesRaw.split(',').map((x) => x.trim());
61
+ const dependsOn = dependsRaw === 'none' ? [] : dependsRaw.split(',').map((x) => x.trim());
62
+ declared.set(id, { trackId: id, taskIds: [], ownership: [], dependsOn, sharedResources: resources ?? [] });
63
+ if (resources === null)
64
+ return { mode: 'serial', reason: `shared-resources-missing:${id}` };
65
+ }
66
+ let taskId = null;
67
+ let member = null;
68
+ let pendingFiles = [];
69
+ for (const line of lines) {
70
+ const heading = line.match(taskHeading);
71
+ if (heading !== null) {
72
+ if (taskId !== null && pendingFiles.length > 0 && member === null)
73
+ throw new Error(`task ${taskId} tiene Files pero no Track`);
74
+ taskId = heading[1].trim();
75
+ member = null;
76
+ pendingFiles = [];
77
+ continue;
78
+ }
79
+ const membership = line.match(trackLine);
80
+ if (membership !== null) {
81
+ if (taskId === null || member !== null)
82
+ throw new Error('cada task admite exactamente un Track');
83
+ member = membership[1].trim();
84
+ const track = declared.get(member);
85
+ if (track === undefined)
86
+ throw new Error(EXACT_MATCH_ERROR);
87
+ track.taskIds.push(taskId);
88
+ track.ownership.push(...pendingFiles);
89
+ }
90
+ const file = line.match(fileLine);
91
+ if (file !== null) {
92
+ const canonical = canonicalFile(file[1]);
93
+ if (member === null)
94
+ pendingFiles.push(canonical);
95
+ else
96
+ declared.get(member).ownership.push(canonical);
97
+ }
98
+ }
99
+ // La última task del documento no tiene un `### Task` siguiente que dispare el chequeo del loop.
100
+ if (taskId !== null && pendingFiles.length > 0 && member === null)
101
+ throw new Error(`task ${taskId} tiene Files pero no Track`);
102
+ const members = new Set([...declared.values()].flatMap((t) => t.taskIds.length > 0 ? [t.trackId] : []));
103
+ if (members.size !== declared.size)
104
+ throw new Error(EXACT_MATCH_ERROR);
105
+ if ([...declared.values()].some((t) => t.dependsOn.length > 0))
106
+ return { mode: 'serial', reason: 'track-dependency' };
107
+ return { mode: 'parallel-candidate', tracks: Object.fromEntries(declared), integration };
108
+ }