agentic-workflow-manager 3.13.0 → 3.13.2

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 (43) hide show
  1. package/dist/src/commands/doctor.js +8 -0
  2. package/dist/src/commands/hooks/shared.js +14 -1
  3. package/dist/src/commands/init.js +5 -1
  4. package/dist/src/commands/registry/add.js +10 -1
  5. package/dist/src/commands/sync.js +4 -0
  6. package/dist/src/commands/update.js +4 -0
  7. package/dist/src/core/atomic-file.js +24 -2
  8. package/dist/src/core/executor.js +40 -1
  9. package/dist/src/core/install-transaction.js +13 -1
  10. package/dist/src/core/journal/process.js +241 -9
  11. package/dist/src/core/paths.js +28 -10
  12. package/dist/src/index.js +0 -3
  13. package/dist/tests/commands/doctor-platform.test.js +48 -4
  14. package/dist/tests/commands/doctor.test.js +19 -0
  15. package/dist/tests/commands/hooks/install-symlink-fallback.test.js +25 -0
  16. package/dist/tests/commands/hooks/status.test.js +23 -3
  17. package/dist/tests/commands/init.test.js +39 -0
  18. package/dist/tests/commands/job/exec-wrapper.test.js +15 -1
  19. package/dist/tests/commands/job/gate-reconcile.test.js +31 -5
  20. package/dist/tests/commands/multi-agent-targeting.test.js +65 -0
  21. package/dist/tests/commands/preflight/preflight.test.js +4 -1
  22. package/dist/tests/commands/registry/add.test.js +27 -0
  23. package/dist/tests/commands/sensors/changed.test.js +13 -0
  24. package/dist/tests/commands/sensors/exec-windows.test.js +13 -0
  25. package/dist/tests/commands/sensors/exec.test.js +28 -6
  26. package/dist/tests/commands/sensors/formatters/ruff.test.js +22 -6
  27. package/dist/tests/commands/sensors/run-changed.test.js +33 -0
  28. package/dist/tests/commands/watch/e2e-crash.test.js +54 -18
  29. package/dist/tests/commands/watch/runner.test.js +32 -2
  30. package/dist/tests/commands/watch/supervisor-loop.test.js +24 -3
  31. package/dist/tests/core/artifact-state.test.js +11 -1
  32. package/dist/tests/core/atomic-file-durable.test.js +48 -1
  33. package/dist/tests/core/atomic-file.test.js +14 -3
  34. package/dist/tests/core/executor.test.js +58 -0
  35. package/dist/tests/core/install-transaction.test.js +59 -2
  36. package/dist/tests/core/journal/adapter.test.js +54 -0
  37. package/dist/tests/core/journal/fingerprint.test.js +10 -2
  38. package/dist/tests/core/journal/process.test.js +208 -9
  39. package/dist/tests/core/journal/store.test.js +8 -2
  40. package/dist/tests/core/no-color.test.js +23 -0
  41. package/dist/tests/core/paths.test.js +32 -5
  42. package/dist/tests/core/registries-sync.test.js +32 -3
  43. package/package.json +1 -1
@@ -16,6 +16,60 @@ describe('ControllerAdapter', () => {
16
16
  expect(a.safeToReplace(ref)).toBe('indeterminate'); // vivo: codex no observa llamadas en vuelo => custodia
17
17
  child.kill('SIGKILL');
18
18
  });
19
+ /** Regresion (CI windows-latest): reproduce el bug real via mock de
20
+ * process.platform, ya que no hay windows real disponible en este
21
+ * entorno. Antes del fix, refIsAlive en win32 caia en la rama POSIX
22
+ * (ps/pgrep), y ps/pgrep alli — cuando resuelven en el PATH — son el
23
+ * ps/pgrep EMULADO de MSYS/Cygwin, ciego a pids nativos: para el hijo
24
+ * real y vivo que spawnStructured produce, devolvian exit 1 ("no such
25
+ * process" segun su vista emulada), que el codigo interpretaba como
26
+ * "el SO confirmo la muerte" -> refIsAlive(ref) daba `false` -> este
27
+ * mismo test daba `safeToReplace(ref) === 'safe'` para un proceso
28
+ * VIVO, violando "JAMAS safe sin evidencia" (R4.2b). Tras el fix,
29
+ * refIsAlive en win32 usa process.kill(pid,0) (respaldado por el
30
+ * kernel via libuv) y nunca ps/pgrep. */
31
+ test('safeToReplace en win32 (mockeado): muerto probado (ESRCH real) => safe; vivo => indeterminate, JAMAS safe por ceguera de ps/pgrep emulado (R4.2b)', async () => {
32
+ const originalPlatform = process.platform;
33
+ try {
34
+ const a = (0, adapter_1.adapterFor)('codex');
35
+ const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 3000)'], process.cwd(), 'nWin32');
36
+ Object.defineProperty(process, 'platform', { value: 'win32' });
37
+ // El pid vive de verdad: nunca 'safe' sin evidencia real de muerte.
38
+ expect(a.safeToReplace(ref)).toBe('indeterminate');
39
+ // Un ps/pgrep emulado y ciego (exit 1 para un pid nativo real) ya
40
+ // NO puede empujar el veredicto hacia 'safe': refIsAlive en win32
41
+ // nunca invoca ps/pgrep — SI invoca powershell.exe (win32ProcessInfo,
42
+ // R6 ronda 2) para la verificacion de identidad completa cuando el
43
+ // ref no esta degradado, que es un mecanismo distinto y legitimo
44
+ // (WMI, no la capa emulada MSYS/Cygwin) — no se afirma "cero
45
+ // llamadas", se afirma "ps/pgrep especificamente, nunca".
46
+ const cp = require('child_process');
47
+ const execSpy = jest.spyOn(cp, 'execFileSync').mockImplementation(() => {
48
+ const err = new Error('no matches found');
49
+ err.status = 1;
50
+ throw err;
51
+ });
52
+ expect(a.safeToReplace(ref)).toBe('indeterminate');
53
+ const calledBinaries = execSpy.mock.calls.map((call) => call[0]);
54
+ expect(calledBinaries).not.toContain('ps');
55
+ expect(calledBinaries).not.toContain('pgrep');
56
+ execSpy.mockRestore();
57
+ child.kill('SIGKILL');
58
+ // Muerte PROBADA por el SO (ESRCH real de process.kill(pid,0)):
59
+ // recien ahi es legitimo declarar 'safe'.
60
+ const deadRef = { ...ref };
61
+ const killSpy = jest.spyOn(process, 'kill').mockImplementation(() => {
62
+ const err = new Error('no such process');
63
+ err.code = 'ESRCH';
64
+ throw err;
65
+ });
66
+ expect(a.safeToReplace(deadRef)).toBe('safe');
67
+ killSpy.mockRestore();
68
+ }
69
+ finally {
70
+ Object.defineProperty(process, 'platform', { value: originalPlatform });
71
+ }
72
+ });
19
73
  test('launchArgv construye el comando de reanudacion journal-first (R4.8)', () => {
20
74
  const argv = (0, adapter_1.adapterFor)('codex').launchArgv('retoma desde next_action');
21
75
  expect(argv[0]).toBe('codex');
@@ -111,7 +111,15 @@ describe('computeFingerprint', () => {
111
111
  * de un git real) — sin el fix, esto tumba al hijo; con el fix, sobrevive. */
112
112
  describe('fingerprint.ts git(): stdio explicito evita inheritStderr hacia un pipe roto', () => {
113
113
  const DIST_ENTRY = path_1.default.resolve(__dirname, '..', '..', '..', 'dist', 'src', 'core', 'journal', 'fingerprint.js');
114
- const REAL_GIT = (0, child_process_1.execFileSync)('which', ['git'], { encoding: 'utf8' }).trim();
114
+ // `which` is POSIX-only and not reliably on PATH in a pwsh-shell windows-latest
115
+ // runner even though Git for Windows is installed (Windows uses `where`). `where`
116
+ // can print one match per line when git is reachable via more than one PATH
117
+ // entry, so only the FIRST line is a valid single path to spawn directly — the
118
+ // rest would make execFileSync try to exec a multi-line string as one path.
119
+ const REAL_GIT = (0, child_process_1.execFileSync)(process.platform === 'win32' ? 'where' : 'which', ['git'], { encoding: 'utf8' })
120
+ .split(/\r?\n/)
121
+ .map((line) => line.trim())
122
+ .find((line) => line.length > 0);
115
123
  beforeAll(() => {
116
124
  if (!fs_1.default.existsSync(DIST_ENTRY)) {
117
125
  throw new Error('dist ausente: corre `cd cli && npm run build` antes de este test (verifica el dist compilado real, no el source transpilado por ts-jest)');
@@ -143,7 +151,7 @@ describe('fingerprint.ts git(): stdio explicito evita inheritStderr hacia un pip
143
151
  `);
144
152
  const child = (0, child_process_1.spawn)(process.execPath, [childScript], {
145
153
  cwd: workDir,
146
- env: { ...process.env, PATH: `${workDir}:${process.env.PATH}` },
154
+ env: { ...process.env, PATH: `${workDir}${path_1.default.delimiter}${process.env.PATH}` },
147
155
  stdio: ['ignore', 'pipe', 'pipe'],
148
156
  detached: true,
149
157
  });
@@ -8,6 +8,7 @@ const os_1 = __importDefault(require("os"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const child_process_1 = require("child_process");
10
10
  const process_1 = require("../../../src/core/journal/process");
11
+ const paths_1 = require("../../../src/core/paths");
11
12
  describe('process identity', () => {
12
13
  test('spawnStructured produce ProcessRef con tupla completa (R2.1, R4.7)', async () => {
13
14
  const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 5000)'], process.cwd(), 'nonce-abc');
@@ -15,24 +16,52 @@ describe('process identity', () => {
15
16
  expect(ref.spawnNonce).toBe('nonce-abc');
16
17
  expect(typeof ref.startTime).toBe('string');
17
18
  expect(ref.processGroup).toBeGreaterThan(0);
18
- expect(ref.psArgsDigest).toMatch(/^[0-9a-f]{16}$/);
19
+ // hex real cuando `ps` pudo observar el proceso (caso normal en
20
+ // POSIX); sentinel 'unknown' documentado (ver captureRefFor) cuando
21
+ // no pudo — en win32 esto es la ruta NORMAL, no un error: `ps`/`pgrep`
22
+ // ahi (si resuelven en el PATH) son el ps/pgrep emulado de
23
+ // MSYS/Cygwin, ciego a procesos nativos (ver pidExistsNative en
24
+ // src/core/journal/process.ts). El formato exacto de este campo
25
+ // nunca es la fuente de verdad de vida/muerte — solo lo es refIsAlive
26
+ // (ver los tests de la seccion 'process identity (win32, mockeado)').
27
+ expect(ref.psArgsDigest).toMatch(/^([0-9a-f]{16}|unknown)$/);
19
28
  expect((0, process_1.refIsAlive)(ref)).toBe(true);
20
29
  const dead = await (0, process_1.terminateGroupConfirmed)(ref, { termGraceMs: 300, killGraceMs: 300 });
21
30
  expect(dead).toBe(true);
22
31
  expect((0, process_1.refIsAlive)(ref)).toBe(false);
23
32
  });
24
- test('refIsAlive rechaza cualquier campo distinto de la tupla completa (R2.1)', () => {
33
+ test('refIsAlive rechaza cualquier campo distinto de la tupla completa (R2.1) — en win32 nativo, solo identidad reducida (ronda 3, ver src/core/journal/process.ts)', () => {
25
34
  const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 3000)'], process.cwd(), 'n2');
26
- expect((0, process_1.refIsAlive)({ ...ref, startTime: 'otro-momento' })).toBe(false);
27
- expect((0, process_1.refIsAlive)({ ...ref, spawnNonce: 'otro-nonce' })).toBe(false);
28
- expect((0, process_1.refIsAlive)({ ...ref, argvDigest: 'ffffffffffffffff' })).toBe(false);
29
- expect((0, process_1.refIsAlive)({ ...ref, psArgsDigest: 'ffffffffffffffff' })).toBe(false);
30
- expect((0, process_1.refIsAlive)({ ...ref, processGroup: ref.processGroup + 1 })).toBe(false);
35
+ if ((0, paths_1.isWindowsNative)()) {
36
+ // win32 real (ronda 3): refIsAlive solo valida pid existente +
37
+ // processGroup === pid; el resto de la tupla es informativo
38
+ // (via WMI, captureRefFor) pero NO gatea el veredicto — gap
39
+ // aceptado y documentado (ver refIsAlive en process.ts).
40
+ expect((0, process_1.refIsAlive)({ ...ref, startTime: 'otro-momento' })).toBe(true);
41
+ expect((0, process_1.refIsAlive)({ ...ref, spawnNonce: 'otro-nonce' })).toBe(true);
42
+ expect((0, process_1.refIsAlive)({ ...ref, argvDigest: 'ffffffffffffffff' })).toBe(true);
43
+ expect((0, process_1.refIsAlive)({ ...ref, psArgsDigest: 'ffffffffffffffff' })).toBe(true);
44
+ expect((0, process_1.refIsAlive)({ ...ref, processGroup: ref.processGroup + 1 })).toBe(false);
45
+ }
46
+ else {
47
+ expect((0, process_1.refIsAlive)({ ...ref, startTime: 'otro-momento' })).toBe(false);
48
+ expect((0, process_1.refIsAlive)({ ...ref, spawnNonce: 'otro-nonce' })).toBe(false);
49
+ expect((0, process_1.refIsAlive)({ ...ref, argvDigest: 'ffffffffffffffff' })).toBe(false);
50
+ expect((0, process_1.refIsAlive)({ ...ref, psArgsDigest: 'ffffffffffffffff' })).toBe(false);
51
+ expect((0, process_1.refIsAlive)({ ...ref, processGroup: ref.processGroup + 1 })).toBe(false);
52
+ }
31
53
  child.kill('SIGKILL');
32
54
  });
33
55
  test('terminateGroupConfirmed no senializa un PGID si la identidad del lider no coincide', async () => {
34
56
  const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 5000)'], process.cwd(), 'n-no-kill');
35
- const mismatched = { ...ref, startTime: 'identidad-de-otro-proceso' };
57
+ // win32 (ronda 3): un mismatch de SOLO startTime ya no lo detecta
58
+ // refIsAlive ahi (gap de reciclado de PID aceptado, ver process.ts) —
59
+ // se usa un mismatch de processGroup, que SI se valida en ambas
60
+ // plataformas, para que este test siga siendo significativo en
61
+ // cualquier host.
62
+ const mismatched = (0, paths_1.isWindowsNative)()
63
+ ? { ...ref, processGroup: ref.processGroup + 1 }
64
+ : { ...ref, startTime: 'identidad-de-otro-proceso' };
36
65
  const confirmed = await (0, process_1.terminateGroupConfirmed)(mismatched, { termGraceMs: 20, killGraceMs: 20 });
37
66
  expect(confirmed).toBe(false);
38
67
  expect((0, process_1.refIsAlive)(ref)).toBe(true);
@@ -148,6 +177,176 @@ describe('process identity', () => {
148
177
  }
149
178
  });
150
179
  });
180
+ /** Regresion (CI windows-latest, primera corrida real de la matriz): ps/pgrep,
181
+ * cuando resuelven en el PATH en Windows, son el ps/pgrep EMULADO de
182
+ * MSYS/Cygwin (Git for Windows) — una capa con su propia tabla de pids,
183
+ * ciega a procesos nativos spawneados via CreateProcess (exactamente lo que
184
+ * produce spawnStructured). El codigo viejo interpretaba el exit 1 de ese
185
+ * ps/pgrep "ciego" como "el SO confirmo que el pid no existe" — falso, y
186
+ * rompia el invariante "JAMAS safe sin evidencia": `safeToReplace` devolvia
187
+ * 'safe' para un proceso genuinamente vivo (ver adapter.test.ts). No hay
188
+ * windows-latest real disponible en este entorno; estos tests mockean
189
+ * `process.platform` (mismo patron que
190
+ * tests/commands/sensors/exec-windows.test.ts, que ya cubre exactamente
191
+ * este problema para sensors/exec.ts::killTree) para ejercitar la rama
192
+ * win32 REAL del codigo de produccion contra un pid real y vivo. */
193
+ describe('process identity (win32, mockeado — sin windows real disponible en este entorno)', () => {
194
+ const originalPlatform = process.platform;
195
+ afterEach(() => {
196
+ Object.defineProperty(process, 'platform', { value: originalPlatform });
197
+ jest.restoreAllMocks();
198
+ });
199
+ test('refIsAlive en win32 usa process.kill(pid,0), NUNCA ps/pgrep — reproduce el bug: un ps/pgrep "ciego" que devuelve exit 1 para un pid real y vivo ya no lo declara muerto (R2.1, R4.2b)', () => {
200
+ // Ronda 3 (ver refIsAlive en process.ts): el veredicto win32 ya NO
201
+ // depende de si la identidad esta degradada o no ('unknown' vs
202
+ // datos reales de WMI) — refIsAlive ahi SOLO llama a
203
+ // pidExistsNative (process.kill) + convencion de processGroup,
204
+ // incondicionalmente. La precondicion original de este test
205
+ // ("identidad degradada porque no hay powershell.exe real en este
206
+ // entorno") ya no es ni necesaria ni confiable: en windows-latest
207
+ // CI real, powershell.exe SI esta disponible y captureRefFor
208
+ // devuelve un startTime real via WMI — lo cual esta bien, porque
209
+ // esta rama de refIsAlive nunca lo consulta de todos modos.
210
+ Object.defineProperty(process, 'platform', { value: 'win32' });
211
+ const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 3000)'], process.cwd(), 'n-win32-a');
212
+ const cp = require('child_process');
213
+ // Simula EXACTAMENTE el bug real de CI: ps/pgrep "corren" pero
214
+ // devuelven exit 1 (ceguera de MSYS a pids nativos) para un pid que
215
+ // esta genuinamente vivo — el codigo viejo confiaba en esto.
216
+ const execSpy = jest.spyOn(cp, 'execFileSync').mockImplementation(() => {
217
+ const err = new Error('no matches found');
218
+ err.status = 1;
219
+ throw err;
220
+ });
221
+ try {
222
+ expect((0, process_1.refIsAlive)(ref)).toBe(true); // vivo de verdad: nunca declarado muerto
223
+ expect(execSpy).not.toHaveBeenCalled(); // refIsAlive en win32 ni siquiera intenta ps/pgrep/WMI
224
+ }
225
+ finally {
226
+ child.kill('SIGKILL');
227
+ }
228
+ });
229
+ test('refIsAlive en win32 declara muerte SOLO con ESRCH real de process.kill(pid,0) (R2.1)', () => {
230
+ Object.defineProperty(process, 'platform', { value: 'win32' });
231
+ const killSpy = jest.spyOn(process, 'kill').mockImplementation(() => {
232
+ const err = new Error('no such process');
233
+ err.code = 'ESRCH';
234
+ throw err;
235
+ });
236
+ const fakeRef = { pid: 999999, startTime: 'x', spawnNonce: 'n', argvDigest: 'd', processGroup: 999999, psArgsDigest: 'x' };
237
+ expect((0, process_1.refIsAlive)(fakeRef)).toBe(false);
238
+ expect(killSpy).toHaveBeenCalledWith(999999, 0);
239
+ });
240
+ test('refIsAlive en win32 NUNCA declara muerte por un error que no sea ESRCH (ej. EPERM: el pid existe pero sin permiso de senializarlo) (R2.1)', () => {
241
+ Object.defineProperty(process, 'platform', { value: 'win32' });
242
+ jest.spyOn(process, 'kill').mockImplementation(() => {
243
+ const err = new Error('operation not permitted');
244
+ err.code = 'EPERM';
245
+ throw err;
246
+ });
247
+ // Ronda 3: refIsAlive en win32 ya no consulta WMI/powershell — solo
248
+ // pidExistsNative + convencion de processGroup. EPERM (el pid existe
249
+ // pero sin permiso de senializarlo) no es ESRCH => pidExistsNative
250
+ // dice "vivo"; execFileSync no deberia ni invocarse.
251
+ const cp = require('child_process');
252
+ const execSpy = jest.spyOn(cp, 'execFileSync').mockImplementation((...args) => {
253
+ throw new Error('llamada inesperada a execFileSync en este test: ' + args[0]);
254
+ });
255
+ try {
256
+ const fakeRef = { pid: 4242, startTime: 'x', spawnNonce: 'n', argvDigest: 'd', processGroup: 4242, psArgsDigest: 'x' };
257
+ expect((0, process_1.refIsAlive)(fakeRef)).toBe(true);
258
+ expect(execSpy).not.toHaveBeenCalled();
259
+ }
260
+ finally {
261
+ execSpy.mockRestore();
262
+ }
263
+ });
264
+ test('terminateGroupConfirmed en win32 usa `taskkill /pid <pid> /T /F`, NUNCA process.kill(-pgid) (mismo patron probado en sensors/exec.ts::killTree, ver tests/commands/sensors/exec-windows.test.ts)', async () => {
265
+ // Spawnea en modo POSIX real (platform sin mockear todavia) para que
266
+ // ref.processGroup sea un pgid real de ps (detached:true en esta
267
+ // plataforma) — evita que el pgid observado sea el del test runner.
268
+ const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 5000)'], process.cwd(), 'n-win32-taskkill');
269
+ Object.defineProperty(process, 'platform', { value: 'win32' });
270
+ const cp = require('child_process');
271
+ // Ronda 3: refIsAlive en win32 ya no consulta WMI/powershell — solo
272
+ // pidExistsNative (process.kill) + convencion de processGroup, asi
273
+ // que el unico execFileSync que este camino dispara es taskkill.
274
+ const execSpy = jest.spyOn(cp, 'execFileSync').mockImplementation((...args) => {
275
+ const [cmd] = args;
276
+ if (cmd === 'taskkill') {
277
+ child.kill('SIGKILL'); // simula taskkill matando de verdad al pid real
278
+ return '';
279
+ }
280
+ throw new Error('llamada inesperada a execFileSync en este test: ' + cmd);
281
+ });
282
+ const posixKillSpy = jest.spyOn(process, 'kill');
283
+ const dead = await (0, process_1.terminateGroupConfirmed)(ref, { termGraceMs: 2000, killGraceMs: 500 });
284
+ expect(execSpy).toHaveBeenCalledWith('taskkill', ['/pid', String(ref.pid), '/T', '/F'], expect.anything());
285
+ expect(dead).toBe(true);
286
+ // Nunca la convencion POSIX de pid negativo (grupo) en esta plataforma.
287
+ for (const call of posixKillSpy.mock.calls) {
288
+ expect(call[0]).toBeGreaterThanOrEqual(0);
289
+ }
290
+ }, 15000);
291
+ });
292
+ /** Ronda 2 del fix win32 (R2.1/R6) agrego captura de identidad completa
293
+ * (startTime/psArgsDigest reales) via WMI (`Get-CimInstance Win32_Process`,
294
+ * ver win32ProcessInfo) para `captureRefFor`. Ronda 3 (ver refIsAlive en
295
+ * process.ts) revirtio el USO de esa captura como gate de liveness —
296
+ * `refIsAlive` en win32 volvio a pid-existence + convencion de
297
+ * processGroup solamente, tras un falso negativo real en CI (WMI
298
+ * demostradamente no confiable en su primera corrida real) — pero la
299
+ * CAPTURA en si (`captureRefFor`) sigue poblando esos campos como
300
+ * informacion persistida en el ProcessRef, asi que estos dos tests de
301
+ * captura siguen vigentes. Los tests que ejercitaban `refIsAlive` via el
302
+ * camino WMI (ronda 2) fueron removidos: ese camino ya no existe en
303
+ * produccion — ver la suite generica de arriba
304
+ * ('refIsAlive rechaza cualquier campo...') para la cobertura actual de
305
+ * refIsAlive en win32. */
306
+ describe('process identity (win32, mockeado) — captura de identidad via WMI (R2.1/R6, ronda 2)', () => {
307
+ const originalPlatform = process.platform;
308
+ afterEach(() => {
309
+ Object.defineProperty(process, 'platform', { value: originalPlatform });
310
+ jest.restoreAllMocks();
311
+ });
312
+ function mockPowershell(response) {
313
+ const cp = require('child_process');
314
+ jest.spyOn(cp, 'execFileSync').mockImplementation((...args) => {
315
+ const [cmd] = args;
316
+ if (cmd === 'powershell.exe')
317
+ return typeof response === 'function' ? response() : response;
318
+ throw new Error('llamada inesperada a execFileSync en este test: ' + cmd);
319
+ });
320
+ }
321
+ test('captureRefFor en win32 usa WMI para obtener startTime/psArgsDigest REALES cuando powershell responde (R2.1)', () => {
322
+ Object.defineProperty(process, 'platform', { value: 'win32' });
323
+ const fakeCreationDate = '2026-08-08T00:00:00.0000000-00:00';
324
+ mockPowershell(JSON.stringify({ CreationDate: fakeCreationDate, CommandLine: 'C:\\node.exe fake-argv' }));
325
+ const ref = (0, process_1.captureRefFor)(process.pid, 'nonce-win32-wmi-ok', ['node', 'fake-argv']);
326
+ expect(ref.startTime).toBe(fakeCreationDate); // ya NO 'unknown': WMI respondio
327
+ expect(ref.psArgsDigest).toMatch(/^[0-9a-f]{16}$/); // digest real, no sentinel
328
+ expect(ref.processGroup).toBe(process.pid); // convencion fija win32: sin pgid real jamas
329
+ });
330
+ test('captureRefFor en win32 degrada a unknown si WMI/powershell no responde, nunca crashea (R2.1) — mismo contrato que la rama POSIX', () => {
331
+ Object.defineProperty(process, 'platform', { value: 'win32' });
332
+ const cp = require('child_process');
333
+ const execSpy = jest.spyOn(cp, 'execFileSync').mockImplementation(() => {
334
+ const err = new Error('powershell no encontrado');
335
+ err.code = 'ENOENT';
336
+ throw err;
337
+ });
338
+ try {
339
+ expect(() => (0, process_1.captureRefFor)(process.pid, 'nonce-win32-wmi-fail', ['node'])).not.toThrow();
340
+ const ref = (0, process_1.captureRefFor)(process.pid, 'nonce-win32-wmi-fail', ['node']);
341
+ expect(ref.startTime).toBe('unknown');
342
+ expect(ref.psArgsDigest).toBe('unknown');
343
+ expect(ref.processGroup).toBe(process.pid);
344
+ }
345
+ finally {
346
+ execSpy.mockRestore();
347
+ }
348
+ });
349
+ });
151
350
  /** Defense-in-depth: los execFileSync internos de este archivo (psField,
152
351
  * sleepSync, groupIsGone, activitySnapshot) deben capturar el stderr del
153
352
  * subproceso INTERNAMENTE, nunca relayearlo al stderr del proceso llamante
@@ -192,7 +391,7 @@ describe('process.ts execFileSync: stdio explicito evita inheritStderr hacia un
192
391
  `);
193
392
  const child = (0, child_process_1.spawn)(process.execPath, [childScript], {
194
393
  cwd: workDir,
195
- env: { ...process.env, PATH: `${workDir}:${process.env.PATH}` },
394
+ env: { ...process.env, PATH: `${workDir}${path_1.default.delimiter}${process.env.PATH}` },
196
395
  stdio: ['ignore', 'pipe', 'pipe'],
197
396
  detached: true,
198
397
  });
@@ -15,8 +15,14 @@ describe('journal store', () => {
15
15
  test('initJournal crea 0700/0600 y estado inicial valido (R1.2)', () => {
16
16
  (0, store_1.initJournal)(repo, 'rama');
17
17
  const dir = (0, paths_1.journalDir)(repo, 'rama');
18
- expect(fs_1.default.statSync(dir).mode & 0o777).toBe(0o700);
19
- expect(fs_1.default.statSync((0, paths_1.statePath)(repo, 'rama')).mode & 0o777).toBe(0o600);
18
+ // Windows fs.chmod can only toggle the read-only attribute, not set granular
19
+ // POSIX bits -- see tests/core/atomic-file.test.ts (files, confirmed against
20
+ // real windows-latest CI) and tests/core/install-transaction.test.ts
21
+ // (directories, same reasoning) for the 0o666/0o777 shapes.
22
+ // Confirmed against real windows-latest CI (2026-08-08): directories get the same
23
+ // 0o666 shape as files there, not 0o777 as first reasoned.
24
+ expect(fs_1.default.statSync(dir).mode & 0o777).toBe(process.platform === 'win32' ? 0o666 : 0o700);
25
+ expect(fs_1.default.statSync((0, paths_1.statePath)(repo, 'rama')).mode & 0o777).toBe(process.platform === 'win32' ? 0o666 : 0o600);
20
26
  const r = (0, store_1.readJournal)(repo, 'rama');
21
27
  expect(r.corrupt).toBe(false);
22
28
  expect(r.state.revision).toBe(0);
@@ -0,0 +1,23 @@
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
+ // tests/core/no-color.test.ts
7
+ //
8
+ // Regression for R6 (2026-08-08): picocolors treats being on win32 OR having a `CI`
9
+ // env var set as automatic color support, REGARDLESS of TTY status — GitHub Actions
10
+ // sets `CI=true` on every runner (any OS) and this suite's own tests assert exact/
11
+ // substring CLI text output that silently breaks once ANSI escapes are interleaved
12
+ // into it. jest.setup.js forces `NO_COLOR=1` before any test file loads specifically
13
+ // to make this deterministic everywhere this suite runs, not just where it happens
14
+ // to be colorless by accident (no real TTY, no CI env). This test pins that: if
15
+ // jest.setup.js's NO_COLOR line were ever removed or the wiring in jest.config.js
16
+ // broke, this is the one test that fails on ITS OWN merits, not as a side effect of
17
+ // some other test's substring assertion breaking.
18
+ const picocolors_1 = __importDefault(require("picocolors"));
19
+ test('picocolors never emits ANSI escapes during this test run', () => {
20
+ expect(picocolors_1.default.isColorSupported).toBe(false);
21
+ expect(picocolors_1.default.red('x')).toBe('x');
22
+ expect(picocolors_1.default.green('x')).toBe('x');
23
+ });
@@ -67,17 +67,44 @@ describe('core/paths', () => {
67
67
  setPlatform('darwin');
68
68
  expect((0, paths_1.platformLabel)()).toBe('macOS');
69
69
  setPlatform('win32');
70
- expect((0, paths_1.platformLabel)()).toContain('WSL');
70
+ // Windows is a first-class, CI-verified platform since R6 — the label no
71
+ // longer hedges toward WSL, and must not silently regress back to it.
72
+ expect((0, paths_1.platformLabel)()).toBe('Windows (native, CI-verified)');
73
+ expect((0, paths_1.platformLabel)()).not.toContain('WSL');
71
74
  });
72
- it('warnIfUnsupportedPlatform calls the logger only on win32', () => {
75
+ it('noteWindowsCaveat calls the logger only on win32, with the narrow watch-supervisor gap', () => {
73
76
  const calls = [];
74
77
  const log = (m) => calls.push(m);
75
78
  setPlatform('linux');
76
- (0, paths_1.warnIfUnsupportedPlatform)(log);
79
+ (0, paths_1.noteWindowsCaveat)(log);
77
80
  expect(calls).toHaveLength(0);
78
81
  setPlatform('win32');
79
- (0, paths_1.warnIfUnsupportedPlatform)(log);
80
- expect(calls).toEqual([paths_1.WINDOWS_NATIVE_WARNING]);
82
+ (0, paths_1.noteWindowsCaveat)(log);
83
+ expect(calls).toEqual([paths_1.WINDOWS_KNOWN_GAP]);
84
+ // The message must assert Windows support, not disclaim it, and must name
85
+ // the one specific gap rather than a blanket "some things may not work"
86
+ // hedge — pinning both halves so neither regresses independently.
87
+ expect(paths_1.WINDOWS_KNOWN_GAP).toMatch(/supported and continuously verified/i);
88
+ expect(paths_1.WINDOWS_KNOWN_GAP).toMatch(/awm watch/i);
89
+ expect(paths_1.WINDOWS_KNOWN_GAP).not.toMatch(/WSL/i);
90
+ expect(paths_1.WINDOWS_KNOWN_GAP).not.toMatch(/not supported/i);
91
+ });
92
+ it('noteWindowsCaveat propagates a throwing logger instead of swallowing it', () => {
93
+ // `noteWindowsCaveat` has no try/catch around the logger call — callers
94
+ // (init.ts/update.ts/sync.ts/doctor.ts) all pass simple `console.log`
95
+ // wrappers that are not expected to throw, and every caller controls its
96
+ // own logger, so there is no shared reason for this helper to be
97
+ // defensive on their behalf. Pin that behavior explicitly: a throwing
98
+ // logger's error propagates out of `noteWindowsCaveat`, it is not
99
+ // swallowed.
100
+ setPlatform('win32');
101
+ const boom = new Error('logger exploded');
102
+ const throwingLog = () => { throw boom; };
103
+ expect(() => (0, paths_1.noteWindowsCaveat)(throwingLog)).toThrow(boom);
104
+ // And on non-Windows the logger is never even invoked, so a throwing
105
+ // logger is harmless there.
106
+ setPlatform('linux');
107
+ expect(() => (0, paths_1.noteWindowsCaveat)(throwingLog)).not.toThrow();
81
108
  });
82
109
  describe('resolveOnPath', () => {
83
110
  it('uses `command -v` on POSIX and returns true when the binary resolves', () => {
@@ -9,6 +9,35 @@ const path_1 = __importDefault(require("path"));
9
9
  const os_1 = __importDefault(require("os"));
10
10
  const child_process_1 = require("child_process");
11
11
  const GIT = (cwd, cmd) => (0, child_process_1.execSync)(`git -c user.email=t@t.t -c user.name=t -c tag.gpgSign=false ${cmd}`, { cwd, stdio: 'pipe' });
12
+ // Real `git clone`/`pull` against local file:// fixtures, several times per
13
+ // test — on windows-latest CI this is measurably slower than on POSIX (NTFS
14
+ // overhead, antivirus scanning of freshly-written objects) and the jest
15
+ // default of 5000ms proved too tight in real CI (regression: real windows-latest
16
+ // run, "Exceeded timeout of 5000 ms"). Matches the pattern already used by
17
+ // other real-subprocess suites in this repo (supervisor-loop.test.ts: 60000,
18
+ // e2e-crash.test.ts: 180000).
19
+ jest.setTimeout(30000);
20
+ /** git en win32 puede mantener un handle abierto sobre `.git/objects` por un
21
+ * instante despues de que el proceso `git` retorna (buffering/flush del
22
+ * filesystem, o un git-index-lock que el SO tarda en soltar) — rmSync
23
+ * inmediato entonces produce EBUSY/ENOTEMPTY (regresion: real windows-latest
24
+ * CI). Mismo patron ya aplicado en tests/commands/watch/runner.test.ts para
25
+ * un problema de fondo identico (escritura async en vuelo vs cleanup
26
+ * inmediato). */
27
+ async function rmSyncRetryingBusy(target, attempts = 10, delayMs = 100) {
28
+ for (let i = 0; i < attempts; i++) {
29
+ try {
30
+ fs_1.default.rmSync(target, { recursive: true, force: true });
31
+ return;
32
+ }
33
+ catch (error) {
34
+ const code = error.code;
35
+ if ((code !== 'EBUSY' && code !== 'ENOTEMPTY') || i === attempts - 1)
36
+ throw error;
37
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
38
+ }
39
+ }
40
+ }
12
41
  /** Creates a git source repo with a skill, returns its path (serves as local remote). */
13
42
  function makeSourceRepo(base, skillName) {
14
43
  const dir = path_1.default.join(base, `src-${skillName}`);
@@ -33,9 +62,9 @@ describe('syncRegistries (git fixtures locales)', () => {
33
62
  process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
34
63
  jest.resetModules();
35
64
  });
36
- afterEach(() => {
37
- fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
38
- fs_1.default.rmSync(tmpWork, { recursive: true, force: true });
65
+ afterEach(async () => {
66
+ await rmSyncRetryingBusy(tmpHome);
67
+ await rmSyncRetryingBusy(tmpWork);
39
68
  if (originalHome === undefined)
40
69
  delete process.env.HOME;
41
70
  else
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-workflow-manager",
3
- "version": "3.13.0",
3
+ "version": "3.13.2",
4
4
  "main": "dist/src/index.js",
5
5
  "bin": {
6
6
  "awm": "./dist/src/index.js"