agentic-workflow-manager 3.13.3 → 3.13.4

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.
@@ -112,19 +112,33 @@ function gatherMachine(bundles, agent = 'claude-code') {
112
112
  }
113
113
  catch { /* sin soporte de hooks → ausente */ }
114
114
  // devCore (bundle baseline) — skillsDir is null only for providers with no global skill
115
- // discovery mechanism (today: Copilot); treated the same as "nothing linked here yet".
115
+ // discovery mechanism (today: Copilot); for those, there is no global-scope devCore
116
+ // concept to satisfy at all, so it's treated as trivially satisfied (see below).
116
117
  const skillsDir = (0, providers_1.providerFor)(agent).skill.global;
117
118
  const baseline = bundles.find((b) => b.scope === 'baseline');
118
119
  let devCorePresent = false;
119
120
  let brokenLinks = [];
120
121
  if (baseline) {
121
122
  const skillNames = (0, bundles_1.resolveBundleSkills)(baseline.name, bundles);
122
- const { linked, broken } = skillsDir !== null
123
- ? classifyLinks(skillNames, skillsDir)
124
- : { linked: [], broken: [] };
125
- const absent = skillNames.filter((s) => !linked.includes(s) && !broken.includes(s));
126
- devCorePresent = skillNames.length > 0 && (linked.length + broken.length) > 0;
127
- brokenLinks = [...broken, ...absent];
123
+ if (skillsDir === null) {
124
+ // No global skill directory for this agent (today: Copilot) — there is no
125
+ // global-scope devCore/baseline-bundle concept to satisfy for it at all, so
126
+ // "N/A" is reported as satisfied (present, nothing broken) rather than
127
+ // "missing". Mirrors globalSkills' treatment just below (empty valid/
128
+ // repairable/dead when skillsDir === null). Without this, devCorePresent
129
+ // was unconditionally false here (linked/broken forced to empty arrays),
130
+ // so `machine.devCore` could never be satisfied for Copilot — stepDevCore
131
+ // (init/steps.ts) would fall through on every run and call installBundle at
132
+ // global scope, which throws (skill.global === null), rolling back the
133
+ // ENTIRE `awm init -a copilot` transaction, 100% of the time.
134
+ devCorePresent = true;
135
+ }
136
+ else {
137
+ const { linked, broken } = classifyLinks(skillNames, skillsDir);
138
+ const absent = skillNames.filter((s) => !linked.includes(s) && !broken.includes(s));
139
+ devCorePresent = skillNames.length > 0 && (linked.length + broken.length) > 0;
140
+ brokenLinks = [...broken, ...absent];
141
+ }
128
142
  // Agent-type artifacts are never shared across agents (R12/R13 —
129
143
  // install-planner.ts — unlike skills, where OpenCode and Codex both
130
144
  // resolve to ~/.agents/skills). A shared skill directory already
@@ -155,9 +169,19 @@ function gatherMachine(bundles, agent = 'claude-code') {
155
169
  }
156
170
  }
157
171
  catch { /* sin config → ningún ambient deseado */ }
158
- const installed = wanted.filter((name) => {
172
+ // Same guard as devCorePresent above: an agent with no global skill
173
+ // directory (today: Copilot) has no way to ever receive a globally-
174
+ // installed ambient bundle either — there is no global-scope "ambient"
175
+ // concept to satisfy for it at all. Without this, `installed` was forced
176
+ // to `[]` unconditionally for Copilot regardless of `wanted`, so
177
+ // stepAmbient (init/steps.ts) would treat every entry of a machine-level
178
+ // `~/.awm/config.json`'s `ambient` array as permanently missing and call
179
+ // installBundle at global scope — which throws for Copilot exactly like
180
+ // the devCore bug this file already fixes. Reported as "N/A == already
181
+ // satisfied" (installed), not "wanted but always missing".
182
+ const installed = skillsDir === null ? [...wanted] : wanted.filter((name) => {
159
183
  const skillNames = (0, bundles_1.resolveBundleSkills)(name, bundles);
160
- if (skillNames.length === 0 || skillsDir === null)
184
+ if (skillNames.length === 0)
161
185
  return false;
162
186
  const { linked } = classifyLinks(skillNames, skillsDir);
163
187
  return linked.length === skillNames.length;
@@ -298,11 +298,28 @@ function sleepMsSync(ms) {
298
298
  * con una pausa breve ANTES de declarar ESRCH definitivo no debilita el
299
299
  * invariante "jamas muerte sin evidencia" — un proceso genuinamente muerto
300
300
  * sigue reportando ESRCH en el reintento; esto solo absorbe el falso
301
- * negativo transitorio. Costo maximo: ~150ms, y SOLO en la rama que ya iba
302
- * a declarar "no existe" — el camino feliz (proceso vivo, exito inmediato)
303
- * no paga nada. */
304
- const PID_EXISTS_RETRY_ATTEMPTS = 3;
305
- const PID_EXISTS_RETRY_DELAY_MS = 50;
301
+ * negativo transitorio. Costo maximo, y SOLO en la rama que ya iba a
302
+ * declarar "no existe" — el camino feliz (proceso vivo, exito inmediato)
303
+ * no paga nada.
304
+ *
305
+ * R6 post-mortem #2: el presupuesto original (3 intentos, 50ms => 100ms de
306
+ * espera real) sobrevivio 2 corridas reales de windows-latest tras mergear
307
+ * este mismo mecanismo, pero una tercera corrida real (commit identico,
308
+ * ningun cambio en este archivo) volvio a fallar EL MISMO assert en EL
309
+ * MISMO test — siempre el PRIMER spawn del archivo, nunca los siguientes
310
+ * (que reusan un binario node.exe ya "calentado" por el SO/AV en ese
311
+ * proceso de test). Eso apunta a latencia de arranque en frio (primer
312
+ * spawn del job) empujando el tiempo de visibilidad de OpenProcess mas
313
+ * alla del presupuesto anterior — no una condicion de carrera nueva, la
314
+ * MISMA, con cola mas larga de lo que 100ms cubria. Presupuesto ampliado a
315
+ * 10 intentos / 100ms (hasta ~900ms de espera real) para darle margen real
316
+ * a ese arranque en frio, siguiendo cuestionando el mismo mecanismo en vez
317
+ * de reemplazarlo (systematic-debugging: 2+ fallas del mismo sintoma exacto
318
+ * primero exige ampliar el mismo remedio antes de descartar la arquitectura
319
+ * — a diferencia del patron de "cada intento revela un problema nuevo en
320
+ * otro lugar", que si justificaria cuestionar el diseño). */
321
+ const PID_EXISTS_RETRY_ATTEMPTS = 10;
322
+ const PID_EXISTS_RETRY_DELAY_MS = 100;
306
323
  function pidExistsNative(pid) {
307
324
  for (let attempt = 0; attempt < PID_EXISTS_RETRY_ATTEMPTS; attempt++) {
308
325
  try {
@@ -103,6 +103,27 @@ describe('gatherContext', () => {
103
103
  // must surface as a broken/missing link — NOT a clean, skippable state.
104
104
  expect(ctx.machine.devCore.brokenLinks).toContain('development-process.toml');
105
105
  });
106
+ // Regression for the confirmed production bug: `awm init -a copilot` crashed
107
+ // 100% of the time with "machine.devCore: skill global scope is not
108
+ // supported by Copilot...", rolling back the whole init transaction.
109
+ // Copilot has no global skill directory (providers/index.ts's
110
+ // `skill.global === null`) — before this fix, devCorePresent was
111
+ // unconditionally false in that case (linked/broken forced to empty
112
+ // arrays), so `machine.devCore` could never be satisfied and stepDevCore
113
+ // (init/steps.ts) fell through to a global-scope installBundle call every
114
+ // single run, which throws for Copilot. Now it's reported as trivially
115
+ // satisfied ("N/A" == "nothing to do"), matching how `globalSkills`
116
+ // already treats the same null-skillsDir case.
117
+ it('machine: devCore is trivially satisfied (present, no broken links) for an agent with no global skill directory (copilot)', () => {
118
+ const { gatherContext } = require('../../../src/core/diagnostics/context');
119
+ const ctx = gatherContext({
120
+ cwd: tmpHome,
121
+ bundles: [bundle('dev-core', 'baseline', ['brainstorming'])],
122
+ agent: 'copilot',
123
+ });
124
+ expect(ctx.machine.devCore.present).toBe(true);
125
+ expect(ctx.machine.devCore.brokenLinks).toEqual([]);
126
+ });
106
127
  it('machine: ambient wanted read from ~/.awm/config.json, installed reflects links', () => {
107
128
  fs_1.default.mkdirSync(path_1.default.join(tmpHome, '.awm'), { recursive: true });
108
129
  fs_1.default.writeFileSync(path_1.default.join(tmpHome, '.awm', 'config.json'), JSON.stringify({ ambient: ['personal-notion'] }));
@@ -116,6 +137,29 @@ describe('gatherContext', () => {
116
137
  expect(ctx.machine.ambient.wanted).toEqual(['personal-notion']);
117
138
  expect(ctx.machine.ambient.installed).toEqual(['personal-notion']);
118
139
  });
140
+ // Regression for the SAME structural bug as the devCore fix above, just in
141
+ // the `ambient` computation a few lines below it: Copilot has no global
142
+ // skill directory (skill.global === null), so before this fix `installed`
143
+ // was forced to `[]` unconditionally regardless of `wanted`. That made
144
+ // `stepAmbient` (init/steps.ts) treat every entry in a machine-level
145
+ // `~/.awm/config.json`'s `ambient` array as permanently missing and call
146
+ // installBundle at GLOBAL scope for Copilot — which throws with the exact
147
+ // same "skill global scope is not supported by Copilot" error the devCore
148
+ // bug had, and rolls back the whole init transaction. Now `installed`
149
+ // mirrors `wanted` when skillsDir is null (N/A treated as satisfied,
150
+ // nothing to install), matching devCore's treatment above.
151
+ it('machine: ambient is trivially satisfied (installed mirrors wanted) for an agent with no global skill directory (copilot)', () => {
152
+ fs_1.default.mkdirSync(path_1.default.join(tmpHome, '.awm'), { recursive: true });
153
+ fs_1.default.writeFileSync(path_1.default.join(tmpHome, '.awm', 'config.json'), JSON.stringify({ ambient: ['personal-notion'] }));
154
+ const { gatherContext } = require('../../../src/core/diagnostics/context');
155
+ const bundles = [
156
+ bundle('dev-core', 'baseline', ['brainstorming']),
157
+ bundle('personal-notion', 'ambient', ['notion-skill']),
158
+ ];
159
+ const ctx = gatherContext({ cwd: tmpHome, bundles, agent: 'copilot' });
160
+ expect(ctx.machine.ambient.wanted).toEqual(['personal-notion']);
161
+ expect(ctx.machine.ambient.installed).toEqual(['personal-notion']);
162
+ });
119
163
  it('machine: contextInjection empty when opencode config is absent', () => {
120
164
  const { gatherContext } = require('../../../src/core/diagnostics/context');
121
165
  const ctx = gatherContext({ cwd: tmpHome, bundles: [] });
@@ -8,6 +8,7 @@ const os_1 = __importDefault(require("os"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const steps_1 = require("../../../src/core/init/steps");
10
10
  const providers_1 = require("../../../src/providers");
11
+ const context_1 = require("../../../src/core/diagnostics/context");
11
12
  function bundle(name, scope, skills) {
12
13
  return {
13
14
  name, description: '', version: '1.0.0', scope, visibility: 'public',
@@ -141,6 +142,60 @@ describe('stepHook / stepDevCore / stepAmbient', () => {
141
142
  expect((0, steps_1.stepDevCore)(deps({ machine: m, project: null }, a)).action).toBe('applied');
142
143
  expect(a.installBundle).toHaveBeenCalled();
143
144
  });
145
+ // Regression for the confirmed production bug: `awm init -a copilot` crashed
146
+ // 100% of the time with "machine.devCore: skill global scope is not
147
+ // supported by Copilot...", rolling back the ENTIRE init transaction (even
148
+ // project-local artifacts like AGENTS.md). Root cause: Copilot has no
149
+ // global skill directory (providerFor('copilot').skill.global === null),
150
+ // so gatherMachine's devCorePresent was permanently false for it, and
151
+ // stepDevCore fell through to installBundle at global scope on every run
152
+ // — an install that always throws. Fixed in diagnostics/context.ts:
153
+ // devCore.present is now reported `true` (N/A treated as satisfied) when
154
+ // skill.global is null, so this step's existing skip guard applies
155
+ // naturally.
156
+ //
157
+ // Unlike the rest of this describe block (which hand-builds `machine()`
158
+ // fixtures — a fine choice for exercising stepDevCore's own skip-guard
159
+ // logic in isolation), this test calls the REAL `gatherContext` (the
160
+ // function diagnostics/context.ts's fix actually lives in) with an
161
+ // isolated HOME/AWM_HOME, and feeds ITS real output into stepDevCore.
162
+ // That's deliberate: an earlier version of this test hand-built
163
+ // `devCore: { present: true, brokenLinks: [] }` directly and asserted
164
+ // against it, which only re-verified stepDevCore's pre-existing skip
165
+ // guard — reverting the context.ts fix left that version GREEN because it
166
+ // never called the fixed code at all. This version goes RED on revert:
167
+ // gatherContext would then report `devCore.present: false` for Copilot,
168
+ // stepDevCore would fall through to `installBundle`, and the assertions
169
+ // below (`action === 'skipped'`, `installBundle` not called) would fail.
170
+ it('devCore skips cleanly (never calls installBundle) for an agent with no global skill directory (copilot) — via real gatherContext', () => {
171
+ expect((0, providers_1.providerFor)('copilot').skill.global).toBeNull();
172
+ const tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-steps-copilot-devcore-'));
173
+ const originalHome = process.env.HOME;
174
+ const originalAwmHome = process.env.AWM_HOME;
175
+ try {
176
+ process.env.HOME = tmpHome;
177
+ process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
178
+ const baselineBundle = bundle('dev-core', 'baseline', ['brainstorming']);
179
+ const ctx = (0, context_1.gatherContext)({ cwd: tmpHome, bundles: [baselineBundle], agent: 'copilot' });
180
+ const a = spies();
181
+ const r = (0, steps_1.stepDevCore)(deps({ machine: ctx.machine, project: null }, a, {
182
+ agent: 'copilot', enabledAgents: ['copilot'], bundles: [baselineBundle],
183
+ }));
184
+ expect(r.action).toBe('skipped');
185
+ expect(a.installBundle).not.toHaveBeenCalled();
186
+ }
187
+ finally {
188
+ fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
189
+ if (originalHome === undefined)
190
+ delete process.env.HOME;
191
+ else
192
+ process.env.HOME = originalHome;
193
+ if (originalAwmHome === undefined)
194
+ delete process.env.AWM_HOME;
195
+ else
196
+ process.env.AWM_HOME = originalAwmHome;
197
+ }
198
+ });
144
199
  it('ambient installs only missing wanted', () => {
145
200
  const a = spies();
146
201
  const m = machine();
@@ -11,7 +11,31 @@ const process_1 = require("../../../src/core/journal/process");
11
11
  const paths_1 = require("../../../src/core/paths");
12
12
  describe('process identity', () => {
13
13
  test('spawnStructured produce ProcessRef con tupla completa (R2.1, R4.7)', async () => {
14
- const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 5000)'], process.cwd(), 'nonce-abc');
14
+ // Reintento de INSTANCIA completa (no solo de la consulta), a proposito:
15
+ // esta misma linea fallo 2 veces reales consecutivas en windows-latest
16
+ // CI, la segunda vez YA con el presupuesto interno de pidExistsNative
17
+ // ampliado a 10 intentos/100ms (~900ms de espera real dentro de
18
+ // refIsAlive) — evidencia de que NO es (solo) latencia de visibilidad
19
+ // de OpenProcess bajo carga: un proceso genuinamente vivo no deberia
20
+ // seguir siendo invisible tras casi un segundo de reintentos. Apunta
21
+ // en cambio a que el hijo mismo puede terminar genuinamente muy
22
+ // temprano en este runner (imagen windows-2025-vs2026, sospecha no
23
+ // confirmable sin Windows real: AV/Defender interviniendo un `node -e`
24
+ // recien lanzado bajo carga pesada de CI). pidExistsNative reintenta
25
+ // la MISMA consulta sobre el MISMO pid — si ese pid ya esta
26
+ // genuinamente muerto, reintentar la consulta jamas ayuda. Este loop
27
+ // reintenta el SPAWN entero: un intento NUEVO puede sobrevivir donde
28
+ // el anterior no lo hizo. Si los 3 intentos mueren temprano, el
29
+ // ultimo `expect` de abajo sigue fallando fuerte — no enmascara una
30
+ // regresion real de refIsAlive.
31
+ let { child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 5000)'], process.cwd(), 'nonce-abc');
32
+ for (let attempt = 1; attempt < 3 && !(0, process_1.refIsAlive)(ref); attempt++) {
33
+ try {
34
+ child.kill('SIGKILL');
35
+ }
36
+ catch { /* ya ausente */ }
37
+ ({ child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 5000)'], process.cwd(), 'nonce-abc'));
38
+ }
15
39
  expect(ref.pid).toBe(child.pid);
16
40
  expect(ref.spawnNonce).toBe('nonce-abc');
17
41
  expect(typeof ref.startTime).toBe('string');
@@ -253,6 +277,35 @@ describe('process identity (win32, mockeado — sin windows real disponible en e
253
277
  expect((0, process_1.refIsAlive)(fakeRef)).toBe(true); // NUNCA declara muerte por el ESRCH transitorio de los primeros 2 intentos
254
278
  expect(killSpy).toHaveBeenCalledTimes(3);
255
279
  });
280
+ test('pidExistsNative absorbe una carrera transitoria mas larga que el presupuesto original — arranque en frio del primer spawn del job (regresion #2: misma falla real, mismo test, tras el fix de 3x50ms ya mergeado)', () => {
281
+ Object.defineProperty(process, 'platform', { value: 'win32' });
282
+ let calls = 0;
283
+ const killSpy = jest.spyOn(process, 'kill').mockImplementation(() => {
284
+ calls++;
285
+ // El pid tarda 9 intentos en "aparecer" — mas alla del presupuesto
286
+ // anterior (3 intentos) pero dentro del ampliado (10 intentos).
287
+ if (calls < 9) {
288
+ const err = new Error('transient');
289
+ err.code = 'ESRCH';
290
+ throw err;
291
+ }
292
+ return true;
293
+ });
294
+ const fakeRef = { pid: 424243, startTime: 'x', spawnNonce: 'n', argvDigest: 'd', processGroup: 424243, psArgsDigest: 'x' };
295
+ expect((0, process_1.refIsAlive)(fakeRef)).toBe(true);
296
+ expect(killSpy).toHaveBeenCalledTimes(9);
297
+ });
298
+ test('pidExistsNative declara muerte solo tras agotar el presupuesto ampliado (10 intentos) — un ESRCH sostenido nunca se lee como vivo', () => {
299
+ Object.defineProperty(process, 'platform', { value: 'win32' });
300
+ const killSpy = jest.spyOn(process, 'kill').mockImplementation(() => {
301
+ const err = new Error('gone');
302
+ err.code = 'ESRCH';
303
+ throw err;
304
+ });
305
+ const fakeRef = { pid: 424244, startTime: 'x', spawnNonce: 'n', argvDigest: 'd', processGroup: 424244, psArgsDigest: 'x' };
306
+ expect((0, process_1.refIsAlive)(fakeRef)).toBe(false);
307
+ expect(killSpy).toHaveBeenCalledTimes(10);
308
+ });
256
309
  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)', () => {
257
310
  Object.defineProperty(process, 'platform', { value: 'win32' });
258
311
  jest.spyOn(process, 'kill').mockImplementation(() => {
@@ -14,6 +14,31 @@ const path_1 = __importDefault(require("path"));
14
14
  const os_1 = __importDefault(require("os"));
15
15
  const child_process_1 = require("child_process");
16
16
  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' });
17
+ // Real `git init`/clone-adjacent work per test, several times over — on
18
+ // windows-latest CI this is measurably slower than on POSIX and the jest
19
+ // default of 5000ms proved too tight in real CI (regression: real
20
+ // windows-latest run, "Exceeded timeout of 5000 ms"). Same class of issue
21
+ // already fixed in registries-sync.test.ts earlier this session — this
22
+ // sibling file has the identical real-git-fixture pattern and was missed.
23
+ jest.setTimeout(30000);
24
+ /** git en win32 puede mantener un handle abierto sobre `.git/hooks`/`.git/objects`
25
+ * por un instante despues de que el proceso `git` retorna — rmSync inmediato
26
+ * entonces produce EBUSY/ENOTEMPTY (regresion: real windows-latest CI). Mismo
27
+ * patron ya aplicado en registries-sync.test.ts y runner.test.ts. */
28
+ async function rmSyncRetryingBusy(target, attempts = 10, delayMs = 100) {
29
+ for (let i = 0; i < attempts; i++) {
30
+ try {
31
+ fs_1.default.rmSync(target, { recursive: true, force: true });
32
+ return;
33
+ }
34
+ catch (error) {
35
+ const code = error.code;
36
+ if ((code !== 'EBUSY' && code !== 'ENOTEMPTY') || i === attempts - 1)
37
+ throw error;
38
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
39
+ }
40
+ }
41
+ }
17
42
  function makeRegistryWithManifest(base, name, manifest) {
18
43
  const dir = path_1.default.join(base, name);
19
44
  fs_1.default.mkdirSync(dir, { recursive: true });
@@ -37,9 +62,9 @@ describe('verifyMinCliVersions (gate de awm sync / awm update)', () => {
37
62
  process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
38
63
  jest.resetModules();
39
64
  });
40
- afterEach(() => {
41
- fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
42
- fs_1.default.rmSync(tmpWork, { recursive: true, force: true });
65
+ afterEach(async () => {
66
+ await rmSyncRetryingBusy(tmpHome);
67
+ await rmSyncRetryingBusy(tmpWork);
43
68
  if (originalHome === undefined)
44
69
  delete process.env.HOME;
45
70
  else
@@ -0,0 +1,203 @@
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
+ // cli/tests/integration/copilot-init-isolated.test.ts
7
+ //
8
+ // Regression E2E for a confirmed, 100%-reproducible production bug:
9
+ // `awm init --agent copilot` crashed deterministically with
10
+ // "machine.devCore: skill global scope is not supported by Copilot..." and
11
+ // rolled back the ENTIRE init transaction (even project-local artifacts like
12
+ // AGENTS.md).
13
+ //
14
+ // Root cause: Copilot has no global skill directory
15
+ // (providers/index.ts's `skill.global === null`), so
16
+ // diagnostics/context.ts's `gatherMachine` computed `devCore.present` as
17
+ // permanently `false` for Copilot (no way to ever satisfy it). That made
18
+ // `stepDevCore` (init/steps.ts) fall through on every run and call
19
+ // `installBundle` at GLOBAL scope, which throws for a provider with no
20
+ // global skill mechanism — an install that was always guaranteed to fail.
21
+ //
22
+ // Fixed in diagnostics/context.ts: when `skillsDir === null`, there is no
23
+ // global-scope devCore/baseline-bundle concept to satisfy for this agent at
24
+ // all, so `devCorePresent` is now reported `true` (N/A treated as
25
+ // satisfied) — mirroring how `globalSkills` already treats the same
26
+ // null-skillsDir case as trivially healthy. `stepDevCore`'s existing
27
+ // `present && brokenLinks.length === 0 -> skip` guard then naturally never
28
+ // falls through to the doomed install, with no redundant guard needed in
29
+ // steps.ts itself.
30
+ //
31
+ // This test lets `awm init --agent copilot` run its REAL, unstubbed
32
+ // pipeline end-to-end against a hand-seeded registry fixture (same pattern
33
+ // as codex-provider-isolated.test.ts) — never against the real `~/.awm`
34
+ // (CLAUDE.md's "never touch ~/.awm" rule; HOME/AWM_HOME are isolated
35
+ // tmpdirs for the whole test).
36
+ const fs_1 = __importDefault(require("fs"));
37
+ const os_1 = __importDefault(require("os"));
38
+ const path_1 = __importDefault(require("path"));
39
+ describe('copilot provider — isolated home E2E (devCore global-scope guard regression)', () => {
40
+ let tmpHome;
41
+ let tmpWork;
42
+ let originalHome;
43
+ let originalAwmHome;
44
+ let writeSpy;
45
+ beforeEach(() => {
46
+ tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-copilot-e2e-home-'));
47
+ tmpWork = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-copilot-e2e-work-'));
48
+ originalHome = process.env.HOME;
49
+ originalAwmHome = process.env.AWM_HOME;
50
+ process.env.HOME = tmpHome;
51
+ process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
52
+ jest.resetModules();
53
+ writeSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
54
+ });
55
+ afterEach(() => {
56
+ writeSpy.mockRestore();
57
+ fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
58
+ fs_1.default.rmSync(tmpWork, { recursive: true, force: true });
59
+ if (originalHome === undefined)
60
+ delete process.env.HOME;
61
+ else
62
+ process.env.HOME = originalHome;
63
+ if (originalAwmHome === undefined)
64
+ delete process.env.AWM_HOME;
65
+ else
66
+ process.env.AWM_HOME = originalAwmHome;
67
+ });
68
+ /** Same fixture shape as codex-provider-isolated.test.ts's seedPublicRegistryFixture:
69
+ * a real content-root registry (no git repo needed) with the bootstrap skill and one
70
+ * baseline bundle. Copilot needs no hooks (providers/index.ts has no `hooks` config
71
+ * for it) and no native agent renderer (`agent: null`), so the fixture is intentionally
72
+ * smaller than Codex's. */
73
+ function seedPublicRegistryFixture(root) {
74
+ fs_1.default.mkdirSync(path_1.default.join(root, 'hooks'), { recursive: true });
75
+ fs_1.default.writeFileSync(path_1.default.join(root, 'hooks/session-start'), '#!/bin/sh\necho "{}"\n', { mode: 0o755 });
76
+ fs_1.default.writeFileSync(path_1.default.join(root, 'hooks/run-hook.cmd'), '#!/bin/sh\nexec sh "$1"\n', { mode: 0o755 });
77
+ fs_1.default.mkdirSync(path_1.default.join(root, 'skills/using-awm'), { recursive: true });
78
+ fs_1.default.writeFileSync(path_1.default.join(root, 'skills/using-awm/SKILL.md'), '---\nname: using-awm\n---\nMUST invoke skills.');
79
+ fs_1.default.mkdirSync(path_1.default.join(root, 'skills/development-process'), { recursive: true });
80
+ fs_1.default.writeFileSync(path_1.default.join(root, 'skills/development-process/SKILL.md'), '---\nname: development-process\n---\nOrchestrates the dev lifecycle.');
81
+ fs_1.default.writeFileSync(path_1.default.join(root, 'catalog.json'), JSON.stringify({
82
+ version: 1,
83
+ bundles: [{ name: 'dev-core', source: 'bundles/dev-core', version: '1.0.0', scope: 'baseline', visibility: 'public' }],
84
+ }));
85
+ fs_1.default.mkdirSync(path_1.default.join(root, 'bundles/dev-core'), { recursive: true });
86
+ fs_1.default.writeFileSync(path_1.default.join(root, 'bundles/dev-core/bundle.json'), JSON.stringify({
87
+ name: 'dev-core', description: '', version: '1.0.0', scope: 'baseline', visibility: 'public',
88
+ dependsOn: [], skills: ['development-process'], workflows: [], agents: [],
89
+ }));
90
+ fs_1.default.mkdirSync(path_1.default.join(tmpHome, '.awm'), { recursive: true });
91
+ fs_1.default.writeFileSync(path_1.default.join(tmpHome, '.awm/registries.json'), JSON.stringify([{ name: 'baseline', remote: 'https://example.invalid/baseline.git' }], null, 2));
92
+ }
93
+ function readPrefs() {
94
+ return JSON.parse(fs_1.default.readFileSync(path_1.default.join(tmpHome, '.awm/preferences.json'), 'utf8'));
95
+ }
96
+ it('completes without crashing/rolling back (real bug repro) and delivers content via AGENTS.md, not a doomed global-scope install', async () => {
97
+ seedPublicRegistryFixture(path_1.default.join(tmpHome, '.awm/registries/baseline'));
98
+ const { runInit } = require('../../src/commands/init');
99
+ // Real, full, unstubbed init for copilot. Before the fix, this threw
100
+ // "machine.devCore: skill global scope is not supported by Copilot: GitHub
101
+ // Copilot has no user-level skill discovery mechanism — skills must be
102
+ // installed per-project." and rolled back the whole transaction (exit 2,
103
+ // AGENTS.md reverted, preferences never committed).
104
+ const code = await runInit({ cwd: tmpWork, yes: true, agent: 'copilot' });
105
+ // 0 (healthy) or 1 (degraded-but-completed) — NOT 2 (failed/rolled back).
106
+ expect(code).toBeLessThanOrEqual(1);
107
+ // The transaction actually committed: copilot is now a real enabled agent.
108
+ expect(readPrefs().enabledAgents).toEqual(['copilot']);
109
+ // Copilot has no global skill directory at all — confirm nothing was
110
+ // (wrongly) written there, and no ~/.github tree was invented.
111
+ expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.github'))).toBe(false);
112
+ // Content delivery for Copilot happens via its managed-agents-md injection
113
+ // mechanism at PROJECT scope (stepContextInjection, globalPath === null ->
114
+ // scope 'local') — confirm that path is unaffected by this fix and still
115
+ // really delivers the using-awm skill content into the project AGENTS.md.
116
+ const agentsMd = fs_1.default.readFileSync(path_1.default.join(tmpWork, 'AGENTS.md'), 'utf8');
117
+ expect(agentsMd).toContain('MUST invoke skills.');
118
+ });
119
+ // Regression for the SAME structural bug as the devCore one above, in the
120
+ // sibling `ambient` computation (diagnostics/context.ts's `gatherMachine`,
121
+ // a few lines below devCorePresent): before the fix, `installed` was
122
+ // forced to `[]` unconditionally whenever `skillsDir === null` (Copilot),
123
+ // regardless of what `~/.awm/config.json`'s `ambient` array wanted. That
124
+ // made stepAmbient (init/steps.ts) treat every wanted ambient bundle as
125
+ // permanently missing and call installBundle at GLOBAL scope for
126
+ // Copilot — throwing "skill global scope is not supported by Copilot"
127
+ // and rolling back the whole init transaction, exactly like the devCore
128
+ // bug. Nothing in the current CLI writes `ambient` into
129
+ // `~/.awm/config.json` automatically, but it IS read unconditionally by
130
+ // production code and is a real, documented mechanism a user/script can
131
+ // populate — so this hand-seeds it, same as a real machine config would.
132
+ it('with a machine-level ambient bundle configured, completes without crashing/rolling back (ambient sibling of the devCore bug)', async () => {
133
+ const registryRoot = path_1.default.join(tmpHome, '.awm/registries/baseline');
134
+ seedPublicRegistryFixture(registryRoot);
135
+ // Add an ambient-scope bundle to the registry fixture so `wanted`
136
+ // resolves to real skills (resolveBundleSkills needs the bundle to
137
+ // actually exist in `bundles`, discovered via discoverAllBundles()).
138
+ fs_1.default.mkdirSync(path_1.default.join(registryRoot, 'skills/ambient-skill'), { recursive: true });
139
+ fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'skills/ambient-skill/SKILL.md'), '---\nname: ambient-skill\ndescription: An ambient bundle skill.\n---\nAmbient.');
140
+ const catalog = JSON.parse(fs_1.default.readFileSync(path_1.default.join(registryRoot, 'catalog.json'), 'utf8'));
141
+ catalog.bundles.push({ name: 'personal-notion', source: 'bundles/personal-notion', version: '1.0.0', scope: 'ambient', visibility: 'public' });
142
+ fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'catalog.json'), JSON.stringify(catalog));
143
+ fs_1.default.mkdirSync(path_1.default.join(registryRoot, 'bundles/personal-notion'), { recursive: true });
144
+ fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'bundles/personal-notion/bundle.json'), JSON.stringify({
145
+ name: 'personal-notion', description: '', version: '1.0.0', scope: 'ambient', visibility: 'public',
146
+ dependsOn: [], skills: ['ambient-skill'], workflows: [], agents: [],
147
+ }));
148
+ // Seed the machine-level ambient config exactly as a user/script would.
149
+ fs_1.default.writeFileSync(path_1.default.join(tmpHome, '.awm/config.json'), JSON.stringify({ ambient: ['personal-notion'] }));
150
+ const { runInit } = require('../../src/commands/init');
151
+ // Before the fix, this threw "machine.ambient: skill global scope is
152
+ // not supported by Copilot..." (same class as the devCore crash) and
153
+ // rolled back the whole transaction (exit 2, AGENTS.md reverted,
154
+ // preferences never committed).
155
+ const code = await runInit({ cwd: tmpWork, yes: true, agent: 'copilot' });
156
+ // 0 (healthy) or 1 (degraded-but-completed) — NOT 2 (failed/rolled back).
157
+ expect(code).toBeLessThanOrEqual(1);
158
+ // The transaction actually committed.
159
+ expect(readPrefs().enabledAgents).toEqual(['copilot']);
160
+ // Copilot has no global skill directory at all — confirm the ambient
161
+ // bundle was NOT (wrongly) installed at global scope either.
162
+ expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.github'))).toBe(false);
163
+ });
164
+ it('a subsequent `awm add <bundle> -a copilot` works once init has actually committed', async () => {
165
+ seedPublicRegistryFixture(path_1.default.join(tmpHome, '.awm/registries/baseline'));
166
+ // Add a second, non-baseline bundle the project can explicitly activate
167
+ // locally (Copilot only supports local-scope skill delivery — providers/
168
+ // index.ts's `skill.local: '.github/instructions'`).
169
+ const registryRoot = path_1.default.join(tmpHome, '.awm/registries/baseline');
170
+ fs_1.default.mkdirSync(path_1.default.join(registryRoot, 'skills/extra-skill'), { recursive: true });
171
+ fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'skills/extra-skill/SKILL.md'), '---\nname: extra-skill\ndescription: An extra project skill.\n---\nExtra.');
172
+ fs_1.default.mkdirSync(path_1.default.join(registryRoot, 'bundles/extra'), { recursive: true });
173
+ fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'bundles/extra/bundle.json'), JSON.stringify({
174
+ name: 'extra', description: '', version: '1.0.0', scope: 'project', visibility: 'public',
175
+ dependsOn: [], skills: ['extra-skill'], workflows: [], agents: [],
176
+ }));
177
+ const catalog = JSON.parse(fs_1.default.readFileSync(path_1.default.join(registryRoot, 'catalog.json'), 'utf8'));
178
+ catalog.bundles.push({ name: 'extra', source: 'bundles/extra', version: '1.0.0', scope: 'project', visibility: 'public' });
179
+ fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'catalog.json'), JSON.stringify(catalog));
180
+ // findProjectRoot needs a real project marker (.git/, package.json, or
181
+ // .awm/profile.json) — a bare tmpdir isn't one (same pattern as
182
+ // tests/commands/add.test.ts).
183
+ fs_1.default.writeFileSync(path_1.default.join(tmpWork, 'package.json'), JSON.stringify({ name: 'fixture' }));
184
+ const { runInit } = require('../../src/commands/init');
185
+ const initCode = await runInit({ cwd: tmpWork, yes: true, agent: 'copilot' });
186
+ expect(initCode).toBeLessThanOrEqual(1);
187
+ expect(readPrefs().enabledAgents).toEqual(['copilot']);
188
+ // This was "permanently blocked" per the bug report — it requires a prior
189
+ // COMMITTED `awm init --agent copilot` (resolveAgentTargetsOrError rejects
190
+ // any agent not in enabledAgents), which never happened before the fix
191
+ // because every copilot init rolled back before saving preferences.
192
+ const { runAddBundleCore } = require('../../src/commands/add');
193
+ const { loadPreferences } = require('../../src/utils/config');
194
+ const { discoverAllBundles } = require('../../src/core/bundles');
195
+ const { prefs } = loadPreferences('copilot');
196
+ const bundles = discoverAllBundles();
197
+ const result = runAddBundleCore({ name: 'extra', agent: 'copilot', cwd: tmpWork }, prefs, bundles);
198
+ expect(result.code).toBe(0);
199
+ expect(result.result?.installed.length).toBeGreaterThan(0);
200
+ const instructionsDir = path_1.default.join(tmpWork, '.github/instructions');
201
+ expect(fs_1.default.existsSync(instructionsDir)).toBe(true);
202
+ });
203
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-workflow-manager",
3
- "version": "3.13.3",
3
+ "version": "3.13.4",
4
4
  "main": "dist/src/index.js",
5
5
  "bin": {
6
6
  "awm": "./dist/src/index.js"