agentic-workflow-manager 6.2.1 → 6.4.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 (70) 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/dist/tests/structural/sensor-configs-are-present.test.js +49 -0
  70. package/package.json +1 -1
@@ -0,0 +1,89 @@
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.planDigest = planDigest;
7
+ exports.assertTrackTask = assertTrackTask;
8
+ exports.resolveAuthenticatedContext = resolveAuthenticatedContext;
9
+ exports.resolveCommandContext = resolveCommandContext;
10
+ // Resolucion de contexto autenticado (R2.6, R9.4, R9.5): un track SOLO se
11
+ // autentica leyendo el journal del PLAN que su propio descriptor declara —
12
+ // jamas enumera ni abre journals hermanos (R2.7). La lectura es siempre
13
+ // read-only al momento de necesitarse (R9.5): nada aqui espeja ni cachea
14
+ // estado ajeno.
15
+ const fs_1 = __importDefault(require("fs"));
16
+ const crypto_1 = __importDefault(require("crypto"));
17
+ const paths_1 = require("../paths");
18
+ const descriptor_1 = require("./descriptor");
19
+ const store_1 = require("../journal/store");
20
+ function planDigest(source) {
21
+ return crypto_1.default.createHash('sha256').update(source.replace(/\r\n/g, '\n')).digest('hex');
22
+ }
23
+ /** R2.3/R2.4: un track jamas ejecuta una tarea fuera de su asignacion, y un
24
+ * planDigest divergente bloquea ANTES de mirar taskIds — el plan cambio
25
+ * bajo los pies del track, la asignacion entera dejo de ser confiable. */
26
+ function assertTrackTask(ctx, taskId, currentPlanDigest) {
27
+ if (ctx.planDigest !== currentPlanDigest)
28
+ throw new Error('BLOCKED: planDigest divergente');
29
+ if (!ctx.taskIds.includes(taskId))
30
+ throw new Error(`task ${taskId} fuera de la asignación ${ctx.trackId}`);
31
+ }
32
+ /** Autentica un cwd contra el journal del plan que su descriptor declara
33
+ * (R2.6, R9.4). Recibe los JournalState YA leidos (localState del propio
34
+ * cwd, planState del journal del plan) — la funcion en si es pura, para que
35
+ * la autenticacion sea testeable sin tocar disco; el I/O real vive en
36
+ * `resolveCommandContext` mas abajo. */
37
+ function resolveAuthenticatedContext(cwd, localState, planState = localState) {
38
+ const root = fs_1.default.realpathSync(cwd);
39
+ const descriptor = (0, descriptor_1.readDescriptor)(root);
40
+ if (descriptor === null)
41
+ return { mode: 'plan', repoRoot: root, journal: localState };
42
+ if (localState.trackContext === undefined)
43
+ throw new Error('descriptor presente sin trackContext');
44
+ const planRoot = fs_1.default.realpathSync(descriptor.planRoot);
45
+ const ref = planState.tracks?.find((candidate) => candidate.trackId === descriptor.trackId);
46
+ if (planState.journalId !== descriptor.planJournalId || localState.trackContext.planJournalId !== descriptor.planJournalId) {
47
+ throw new Error('planJournalId no coincide');
48
+ }
49
+ if (ref === undefined)
50
+ throw new Error('TrackRef ausente en journal del plan');
51
+ if (ref.fencingToken !== descriptor.fencingToken)
52
+ throw new Error('fencingToken no coincide');
53
+ // Identidad de filesystem, no de string: en Windows `process.cwd()` y el path guardado
54
+ // en el journal pueden ser dos grafías de la misma carpeta (8.3 vs nombre largo).
55
+ if (!(0, paths_1.sameExistingPath)(ref.worktreePath, root) || (0, paths_1.sameExistingPath)(planRoot, root))
56
+ throw new Error('realpath de track no coincide');
57
+ if (localState.trackContext.trackId !== descriptor.trackId)
58
+ throw new Error('trackId no coincide');
59
+ return { mode: 'track', repoRoot: root, descriptor, trackContext: localState.trackContext };
60
+ }
61
+ /** Guard de entrada para los verbos `awm job`/`awm watch` (R9.4). Sin
62
+ * descriptor => modo plan de siempre, SIN leer ningun journal — el caso
63
+ * comun (todo repo sin tracks) no paga ningun costo ni riesgo nuevo; la
64
+ * logica propia de cada verbo sigue leyendo el journal como ya lo hacia.
65
+ * Con descriptor presente, lee el journal local y el del plan (SOLO ese,
66
+ * R2.7/R9.5) y delega la autenticacion real a resolveAuthenticatedContext;
67
+ * cualquier fallo se homogeniza bajo un mensaje que SIEMPRE contiene
68
+ * "cwd no autenticado", para que el CLI lo reporte con un patron unico. */
69
+ function resolveCommandContext(repoRoot, branch) {
70
+ const root = fs_1.default.realpathSync(repoRoot);
71
+ const descriptor = (0, descriptor_1.readDescriptor)(root);
72
+ if (descriptor === null)
73
+ return { mode: 'plan' };
74
+ try {
75
+ const local = (0, store_1.readJournal)(root, branch);
76
+ if (local.corrupt || local.state === null)
77
+ throw new Error('journal local corrupto o ausente');
78
+ const plan = (0, store_1.readJournal)(descriptor.planRoot, descriptor.planBranch);
79
+ if (plan.corrupt || plan.state === null)
80
+ throw new Error('journal del plan corrupto o ausente');
81
+ const authenticated = resolveAuthenticatedContext(root, local.state, plan.state);
82
+ if (authenticated.mode !== 'track')
83
+ throw new Error('descriptor presente pero no resolvio a modo track');
84
+ return { mode: 'track', context: authenticated };
85
+ }
86
+ catch (error) {
87
+ throw new Error(`cwd no autenticado: ${error.message}`);
88
+ }
89
+ }
@@ -0,0 +1,40 @@
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.descriptorPath = void 0;
7
+ exports.writeDescriptor = writeDescriptor;
8
+ exports.readDescriptor = readDescriptor;
9
+ // Descriptor local de un worktree de track (R2.5): NUNCA versionado, vive
10
+ // bajo .awm/ igual que el journal. Es la unica prueba local de pertenencia a
11
+ // un plan — el resto de la autenticacion (R2.6) se hace en context.ts contra
12
+ // el TrackRef del journal del plan, jamas confiando solo en este archivo.
13
+ const fs_1 = __importDefault(require("fs"));
14
+ const path_1 = __importDefault(require("path"));
15
+ const atomic_file_1 = require("../atomic-file");
16
+ const descriptorPath = (trackRoot) => path_1.default.join(trackRoot, '.awm', 'track.json');
17
+ exports.descriptorPath = descriptorPath;
18
+ /** 0600 + escritura durable (mismo contrato que el journal, R1.2): un
19
+ * descriptor a medio escribir jamas debe autenticar un cwd. */
20
+ function writeDescriptor(trackRoot, value) {
21
+ if (value.schema !== 1 || !path_1.default.isAbsolute(value.planRoot) || value.trackId.length === 0
22
+ || value.planJournalId.length === 0 || value.fencingToken.length < 32) {
23
+ throw new Error('descriptor de track inválido');
24
+ }
25
+ fs_1.default.mkdirSync(path_1.default.dirname((0, exports.descriptorPath)(trackRoot)), { recursive: true, mode: 0o700 });
26
+ (0, atomic_file_1.writeFileAtomicDurable)((0, exports.descriptorPath)(trackRoot), JSON.stringify({ ...value, planRoot: fs_1.default.realpathSync(value.planRoot) }, null, 2) + '\n', 0o600);
27
+ }
28
+ /** null = sin descriptor (modo plan, el caso comun). Forma invalida SIEMPRE
29
+ * lanza — un descriptor corrupto jamas autentica por omision (R1.6). */
30
+ function readDescriptor(trackRoot) {
31
+ const file = (0, exports.descriptorPath)(trackRoot);
32
+ if (!fs_1.default.existsSync(file))
33
+ return null;
34
+ const x = JSON.parse(fs_1.default.readFileSync(file, 'utf8'));
35
+ if (x.schema !== 1 || typeof x.planRoot !== 'string' || typeof x.planBranch !== 'string'
36
+ || typeof x.trackId !== 'string' || typeof x.planJournalId !== 'string'
37
+ || typeof x.fencingToken !== 'string')
38
+ throw new Error('track.json corrupto');
39
+ return x;
40
+ }
@@ -0,0 +1,318 @@
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.isAwmGitignored = isAwmGitignored;
7
+ exports.headSha = headSha;
8
+ exports.isWorktreeClean = isWorktreeClean;
9
+ exports.assertPlanHead = assertPlanHead;
10
+ exports.foreignPathExists = foreignPathExists;
11
+ exports.ownedWorktreeExists = ownedWorktreeExists;
12
+ exports.branchExists = branchExists;
13
+ exports.removeOwnedBranch = removeOwnedBranch;
14
+ exports.gitCheckTrackId = gitCheckTrackId;
15
+ exports.mergeBase = mergeBase;
16
+ exports.addOwnedWorktree = addOwnedWorktree;
17
+ exports.removeOwnedWorktree = removeOwnedWorktree;
18
+ exports.readMergeHead = readMergeHead;
19
+ exports.dirtyPaths = dirtyPaths;
20
+ exports.isAncestor = isAncestor;
21
+ exports.mergeFrozenTrack = mergeFrozenTrack;
22
+ exports.abortOwnedMerge = abortOwnedMerge;
23
+ exports.changedPaths = changedPaths;
24
+ const fs_1 = __importDefault(require("fs"));
25
+ const path_1 = __importDefault(require("path"));
26
+ const child_process_1 = require("child_process");
27
+ const process_1 = require("../journal/process");
28
+ const paths_1 = require("../paths");
29
+ const git = (repo, args) => (0, child_process_1.execFileSync)('git', args, { cwd: repo, encoding: 'utf8', stdio: process_1.EXEC_STDIO });
30
+ /** R4.1/C2: `.awm` debe estar demostrablemente ignorado o el journal del
31
+ * track podría terminar versionado. `git check-ignore` es una operación de
32
+ * working-tree/index — responde por el árbol REALMENTE checkeado en `dir`,
33
+ * nunca por un commit arbitrario. Por eso el caller (`defaultTrackRuntime`)
34
+ * DEBE pasar el path del worktree ya creado (checkeado en `baseSha`), no el
35
+ * repo del plan en su HEAD vivo: ese HEAD puede estar en un commit distinto
36
+ * de `baseSha` y dar una respuesta stale (post-review: bug crítico
37
+ * encontrado en la primera versión de esta función). `exit 0` = ignorado;
38
+ * `exit 1` = no ignorado; cualquier otro fallo tampoco prueba nada — todo lo
39
+ * que no sea éxito confirmado se trata como "no ignorado" (fail-closed). */
40
+ function isAwmGitignored(dir) {
41
+ try {
42
+ git(dir, ['check-ignore', '-q', '.awm/probe']);
43
+ return true;
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
49
+ function headSha(repo) {
50
+ return git(repo, ['rev-parse', 'HEAD']).trim();
51
+ }
52
+ /** R6.4 (Task 10): guard de LIMPIEZA del worktree/index, distinto del guard
53
+ * de OWNERSHIP (`changedPaths`/`assessActualOwnership`, que nunca consulta
54
+ * `git status` — ver el comentario grande del plan R5-T10 Step 6). `status
55
+ * --porcelain` vacío es la única prueba honesta de "nada sin commitear";
56
+ * cualquier fallo de git (repo corrupto, permisos) NO prueba limpieza —
57
+ * fail-closed, se trata como sucio. */
58
+ function isWorktreeClean(repo) {
59
+ try {
60
+ return git(repo, ['status', '--porcelain']).trim().length === 0;
61
+ }
62
+ catch {
63
+ return false;
64
+ }
65
+ }
66
+ /** R5.8/R5.9/C7 (Task 10): comparación fail-closed de HEAD. "Un humano puede
67
+ * mutar el repo, pero la mutación se detecta y bloquea" — cualquier fallo de
68
+ * `git rev-parse` (repo desaparecido, corrupto) NUNCA se interpreta como
69
+ * "no cambió"; el silencio no es prueba, así que se bloquea igual que un
70
+ * drift confirmado. */
71
+ function assertPlanHead(repo, expectedSha) {
72
+ let actual;
73
+ try {
74
+ actual = headSha(repo);
75
+ }
76
+ catch (error) {
77
+ throw new Error(`assertPlanHead: HEAD de ${repo} indemostrable — BLOQUEADO (no se asume sin cambios): ${error.message}`);
78
+ }
79
+ if (actual !== expectedSha) {
80
+ throw new Error(`assertPlanHead: HEAD del plan cambió — esperado ${expectedSha}, actual ${actual}: BLOQUEADO`);
81
+ }
82
+ }
83
+ /** R4.6: nada se considera "nuestro" antes de que lo hayamos creado — si el
84
+ * destino ya existe y no está vacío, es por definición ajeno (todavía no
85
+ * intentamos crear nada ahí). Devuelve `true` también si no se puede
86
+ * *probar* que está vacío (fail-closed: silencio nunca es prueba de nada). */
87
+ function foreignPathExists(target) {
88
+ try {
89
+ return fs_1.default.existsSync(target) && fs_1.default.readdirSync(target).length > 0;
90
+ }
91
+ catch {
92
+ return true;
93
+ }
94
+ }
95
+ /** Task 9 (R4.2/R4.6/C11): true <=> `worktreePath` YA está registrado por
96
+ * `git worktree list` como worktree real, checked out exactamente en
97
+ * `branch` — el único caso legítimo de "destino no vacío pero nuestro":
98
+ * `branch`/`worktreePath` son deterministas (derivados 1:1 del trackId al
99
+ * declarar la cohorte, antes del primer intento de `create-worktree`), así
100
+ * que nada más los crea con ese nombre y ese path exactos. Distingue "esto
101
+ * es un intento propio interrumpido" de "esto es genuinamente ajeno" sin
102
+ * ningún estado persistido adicional al que ya vive en el `TrackRef`.
103
+ * Fail-closed (R4.6): cualquier fallo en preguntarle a git, o un
104
+ * `worktreePath` que ni siquiera se puede resolver, es "no probado" ->
105
+ * `false` — el caller sigue tratando el destino como potencialmente ajeno
106
+ * vía `foreignPathExists`, nunca lo adopta a ciegas. */
107
+ function ownedWorktreeExists(repo, worktreePath, branch) {
108
+ if (!fs_1.default.existsSync(worktreePath))
109
+ return false; // nada ahí todavía: no puede ser "nuestro" de un intento previo
110
+ let out;
111
+ try {
112
+ out = git(repo, ['worktree', 'list', '--porcelain']);
113
+ }
114
+ catch {
115
+ return false;
116
+ }
117
+ const wantedRef = `refs/heads/${branch}`;
118
+ for (const block of out.split('\n\n')) {
119
+ const lines = block.split('\n');
120
+ const wtLine = lines.find((l) => l.startsWith('worktree '));
121
+ const branchLine = lines.find((l) => l.startsWith('branch '));
122
+ if (wtLine === undefined || branchLine === undefined)
123
+ continue;
124
+ // Identidad de filesystem, NUNCA comparación de strings: git imprime los paths con
125
+ // separadores POSIX y el nombre largo, mientras el `worktreePath` del journal puede
126
+ // venir en 8.3 con backslashes. Son la misma carpeta escrita de dos formas.
127
+ if ((0, paths_1.sameExistingPath)(wtLine.slice('worktree '.length), worktreePath)
128
+ && branchLine.slice('branch '.length) === wantedRef)
129
+ return true;
130
+ }
131
+ return false;
132
+ }
133
+ /** Task 13 (R4.10/C9): `true` <=> la branch local determinista existe. Nombre
134
+ * determinista (derivado 1:1 del trackId al declarar la cohorte), así que
135
+ * su mera existencia ya es prueba de ownership — a diferencia de un
136
+ * worktree (contenido de filesystem que SÍ podría ser ajeno), una branch es
137
+ * solo un ref; nada más crea una con ese nombre exacto. */
138
+ function branchExists(repo, branch) {
139
+ try {
140
+ git(repo, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]);
141
+ return true;
142
+ }
143
+ catch {
144
+ return false;
145
+ }
146
+ }
147
+ /** Task 9 (paso BRANCH_REMOVED, Task 13 endurece el guard — R4.10/C9): borra
148
+ * la branch local determinista de un track SOLO después de que su worktree
149
+ * ya se quitó. `-d` (nunca `-D`, Step 4 del plan T13): un track que se
150
+ * aborta durante el teardown durable normalmente nunca divergió de su
151
+ * `baseSha` (si divergió, esos commits merecen quedar vivos hasta que un
152
+ * operador decida, no borrarse a la fuerza) — `-d` rehúsa por sí solo si no
153
+ * está mergeada, que es exactamente el comportamiento fail-closed que C9
154
+ * exige. Verifica explícitamente que la branch no siga checked out en
155
+ * ningún worktree ANTES de intentar el borrado (git también lo rehúsa
156
+ * nativamente, pero el chequeo explícito produce un mensaje diagnóstico
157
+ * claro y, sobre todo, trata un `git worktree list` indemostrable como
158
+ * bloqueo — nunca como "no está checked out"). Nunca lanza si la branch ya
159
+ * no existe — idempotente ante reintentos tras crash (R4.2). */
160
+ function removeOwnedBranch(repo, branch) {
161
+ if (!branchExists(repo, branch))
162
+ return; // ya no existe: nada que hacer
163
+ let out;
164
+ try {
165
+ out = git(repo, ['worktree', 'list', '--porcelain']);
166
+ }
167
+ catch (error) {
168
+ throw new Error(`no se pudo verificar si ${branch} sigue checked out — indemostrable: ${error.message}`);
169
+ }
170
+ if (out.split('\n').includes(`branch refs/heads/${branch}`)) {
171
+ throw new Error(`branch ${branch} sigue checked out en un worktree: no se borra`);
172
+ }
173
+ git(repo, ['branch', '-d', branch]);
174
+ }
175
+ function gitCheckTrackId(id) {
176
+ if (!id || id === '.' || id === '..' || id.startsWith('-') || id.includes('/') || id.includes('\\'))
177
+ return false;
178
+ try {
179
+ git(process.cwd(), ['check-ref-format', '--branch', id]);
180
+ return true;
181
+ }
182
+ catch {
183
+ return false;
184
+ }
185
+ }
186
+ function mergeBase(repo, left, right) {
187
+ return git(repo, ['merge-base', left, right]).trim();
188
+ }
189
+ /** R4.1/R4.6: crea el worktree del track SOLO si el destino está
190
+ * demostrablemente vacío. El caller (`tracks.ts`) es responsable de haber
191
+ * verificado `foreignPathExists`/`isAwmGitignored` ANTES de llamar esto —
192
+ * esta función es la única frontera que efectivamente ejecuta `git worktree
193
+ * add`, y nunca adopta ni sobreescribe contenido preexistente. */
194
+ function addOwnedWorktree(repo, ref, baseSha) {
195
+ const parent = path_1.default.dirname(ref.worktreePath);
196
+ fs_1.default.mkdirSync(parent, { recursive: true });
197
+ if (fs_1.default.existsSync(ref.worktreePath) && fs_1.default.readdirSync(ref.worktreePath).length > 0) {
198
+ throw new Error(`destino no vacío: ${ref.worktreePath}`);
199
+ }
200
+ git(repo, ['worktree', 'add', '-b', ref.branch, ref.worktreePath, baseSha]);
201
+ }
202
+ /** El worktree es NUESTRO — o recién creado en la misma llamada (C2, ej.
203
+ * `.awm` no ignorado en `baseSha`), o probado por ownership completo antes
204
+ * de llegar acá (Task 13, ver `worktreeOwnershipProven` en `teardown.ts`) —
205
+ * esto jamás borra algo ajeno (R4.6). Task 13 (R4.10/C9, Step 4 del plan)
206
+ * endurece el guard: YA NO fuerza (`--force` eliminado) — un worktree sucio
207
+ * bloquea, nombrando los paths sin commitear, en vez de descartar trabajo
208
+ * real sin dejar rastro. `dirtyPaths` sobre el WORKTREE mismo (no sobre
209
+ * `repo`): cada worktree tiene su propio árbol de trabajo/index, así que
210
+ * `git status` corrido ahí es la única fuente honesta de su propia
211
+ * limpieza. */
212
+ function removeOwnedWorktree(repo, worktreePath) {
213
+ const dirty = dirtyPaths(worktreePath);
214
+ if (dirty.length > 0) {
215
+ throw new Error(`worktree sucio, no se remueve sin --force: ${[...dirty].sort().join(', ')}`);
216
+ }
217
+ git(repo, ['worktree', 'remove', worktreePath]);
218
+ }
219
+ /** R6.2/R6.6-R6.9/C7 (Task 11): lee el `MERGE_HEAD` REAL del repo — no
220
+ * asume nada de un plain `.git/MERGE_HEAD` porque el plan del cohorte
221
+ * siempre opera sobre el repo PRINCIPAL (nunca un worktree secundario acá),
222
+ * pero `--git-path` es la única forma honesta de resolverlo sin asumir la
223
+ * estructura de `.git` (podría ser un dir o, en teoría, otra cosa).
224
+ * `null` <=> demostrablemente SIN merge en curso (el archivo no existe);
225
+ * cualquier fallo en RESOLVER el path, o el archivo presente pero
226
+ * ilegible, es indemostrable y BLOQUEA (fail-closed) — nunca se interpreta
227
+ * silencio como "no hay merge". */
228
+ function readMergeHead(repo) {
229
+ let gitPath;
230
+ try {
231
+ gitPath = git(repo, ['rev-parse', '--git-path', 'MERGE_HEAD']).trim();
232
+ }
233
+ catch (error) {
234
+ throw new Error(`readMergeHead: no se pudo resolver el path de MERGE_HEAD en ${repo} — indemostrable: ${error.message}`);
235
+ }
236
+ const abs = path_1.default.isAbsolute(gitPath) ? gitPath : path_1.default.join(repo, gitPath);
237
+ if (!fs_1.default.existsSync(abs))
238
+ return null;
239
+ try {
240
+ return fs_1.default.readFileSync(abs, 'utf8').trim();
241
+ }
242
+ catch (error) {
243
+ throw new Error(`readMergeHead: MERGE_HEAD existe en ${abs} pero es ilegible — indemostrable: ${error.message}`);
244
+ }
245
+ }
246
+ /** R6.4/R6.5 (Task 11, reutilizado por `abortOwnedMerge`): mismo criterio
247
+ * fail-closed que `isWorktreeClean` pero devolviendo los paths (nunca se
248
+ * descartan — R6.5). Un fallo de `git status` NUNCA prueba limpieza: un
249
+ * centinela no vacío obliga a cualquier caller a tratar el árbol como
250
+ * sucio en vez de asumir silenciosamente cero paths sucios. */
251
+ function dirtyPaths(repo) {
252
+ let out;
253
+ try {
254
+ out = git(repo, ['status', '--porcelain']);
255
+ }
256
+ catch {
257
+ return ['<estado indemostrable: git status falló>'];
258
+ }
259
+ return out.split('\n').filter((line) => line.length > 0).map((line) => line.slice(3));
260
+ }
261
+ /** R6.6/R6.7 (Task 11): `true` <=> `ancestor` es alcanzable desde
262
+ * `descendant` — usado SOLO cuando `readMergeHead` ya probó que no hay
263
+ * merge en curso, para distinguir "todavía no se intentó" de "ya se
264
+ * mergeó" (un merge `--no-ff` exitoso siempre deja al track como ancestro
265
+ * del nuevo HEAD). Fail-closed hacia `false`: cualquier fallo de git (SHA
266
+ * inválido, repo corrupto) nunca se interpreta como ancestría probada —
267
+ * eso empujaría a `decideJoinReconciliation` hacia `accept-merge` sin
268
+ * evidencia real. */
269
+ function isAncestor(repo, ancestor, descendant) {
270
+ try {
271
+ git(repo, ['merge-base', '--is-ancestor', ancestor, descendant]);
272
+ return true;
273
+ }
274
+ catch {
275
+ return false;
276
+ }
277
+ }
278
+ /** R6.2/R6.3/C7 (Task 11, Step 4 del plan — snippet reproducido exacto):
279
+ * único punto que ejecuta el merge real `no-ff` de un track ya `FROZEN`
280
+ * sobre la rama del plan. `assertPlanHead` ANTES de mutar nada (TOCTOU,
281
+ * C7): un HEAD movido bajo nuestros pies bloquea en vez de mergear a
282
+ * ciegas. Si `git merge` falla (conflicto real, u otra causa), la
283
+ * excepción se propaga tal cual — el caller (`runMergeTrack` en
284
+ * `watch/tracks.ts`) NUNCA interpreta la excepción por sí misma: relee el
285
+ * estado real del repo después para decidir qué pasó de verdad. */
286
+ function mergeFrozenTrack(repo, intent) {
287
+ assertPlanHead(repo, intent.expectedPlanHeadSha);
288
+ git(repo, ['merge', '--no-ff', '--no-edit', intent.expectedTrackHeadSha]);
289
+ }
290
+ /** R6.2/R6.3/C7 (Task 11, Step 4 del plan — snippet exacto): aborta
291
+ * ÚNICAMENTE un merge cuyo `MERGE_HEAD` observado coincide con el
292
+ * `expectedTrackHeadSha` del intent propio — jamás un `MERGE_HEAD` ajeno
293
+ * (protege contra abortar el merge de otro proceso). Verifica, tras el
294
+ * abort, que el árbol quedó demostrablemente limpio (sin `MERGE_HEAD`, sin
295
+ * paths sucios); si no, lanza en vez de reportar éxito sobre un estado que
296
+ * no se pudo probar recuperado. */
297
+ function abortOwnedMerge(repo, intent) {
298
+ const observed = readMergeHead(repo);
299
+ if (observed !== intent.expectedTrackHeadSha)
300
+ throw new Error('no se aborta MERGE_HEAD ajeno');
301
+ git(repo, ['merge', '--abort']);
302
+ if (readMergeHead(repo) !== null || dirtyPaths(repo).length > 0)
303
+ throw new Error('merge --abort no restauró árbol limpio');
304
+ }
305
+ function changedPaths(repo, base, head) {
306
+ const fields = git(repo, ['diff', '--name-status', '-z', '--find-renames', base, head]).split('\0');
307
+ const out = [];
308
+ for (let i = 0; i < fields.length && fields[i] !== '';) {
309
+ const status = fields[i++];
310
+ if (status.startsWith('R') || status.startsWith('C')) {
311
+ out.push({ status, oldPath: fields[i++], path: fields[i++] });
312
+ }
313
+ else {
314
+ out.push({ status, path: fields[i++] });
315
+ }
316
+ }
317
+ return out;
318
+ }
@@ -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
+ }