agentic-workflow-manager 4.1.0 → 5.0.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.
@@ -270,8 +270,20 @@ async function runInit(opts = {}) {
270
270
  json: opts.json,
271
271
  });
272
272
  }
273
- // `result` mirrors the exit code, so a consumer can branch on one field
274
- // instead of correlating stdout with $?: ok → 0, degraded 1, failed → 2.
273
+ // `result` distinguishes `ok` from `degraded` in the JSON; the EXIT CODE does
274
+ // not, on purpose (D-008).
275
+ //
276
+ // `degraded` used to exit `1`. It does not mean init failed — it means the
277
+ // harness still has `pending` steps, which is the NORMAL outcome of a first run:
278
+ // `CONSTITUTION.md` and `AGENTS.md` are written by an agent session, not by the
279
+ // CLI. So a run where nothing broke reported failure, and `awm init --yes && …`
280
+ // died under `set -e` — in the one command whose entire job is bootstrapping a
281
+ // script. Three independent readers called it a bug before it was one; the docs
282
+ // had grown a warning box asking people to ignore the exit code.
283
+ //
284
+ // The exit code now answers "did init do its job?". "Is the harness fully
285
+ // healthy?" is `awm doctor`'s question, and `result` still carries it here for
286
+ // any consumer that wants to branch on it.
275
287
  const result = outcome.after.overall === 'healthy' ? 'ok' : 'degraded';
276
288
  if (opts.json) {
277
289
  process.stdout.write(JSON.stringify({ result, ...outcome }, null, 2) + '\n');
@@ -279,7 +291,7 @@ async function runInit(opts = {}) {
279
291
  else {
280
292
  process.stdout.write(renderInitOutcome(outcome) + '\n');
281
293
  }
282
- return result === 'ok' ? 0 : 1;
294
+ return 0;
283
295
  }
284
296
  // ---------------------------------------------------------------------------
285
297
  // Extension confirmation factory
@@ -16,6 +16,7 @@ const status_1 = require("../../commands/hooks/status");
16
16
  const profile_1 = require("../profile");
17
17
  const bundles_1 = require("../bundles");
18
18
  const skill_integrity_1 = require("../skill-integrity");
19
+ const artifact_state_1 = require("../artifact-state");
19
20
  const paths_1 = require("../paths");
20
21
  const provider_checks_1 = require("./provider-checks");
21
22
  // Estado de un artefacto en <dir>/<skill>: link vivo / symlink colgante / ausente.
@@ -250,10 +251,21 @@ function gatherMachine(bundles, agent = 'claude-code', projectRoot) {
250
251
  ambient: { wanted, installed },
251
252
  contextInjection: gatherContextInjection(),
252
253
  globalSkills: skillsDir !== null
253
- ? (0, skill_integrity_1.classifySkillLinks)(skillsDir, (0, registries_1.contentRoots)())
254
- : { valid: [], repairable: [], dead: [] },
254
+ ? (0, skill_integrity_1.classifySkillLinks)(skillsDir, (0, registries_1.contentRoots)(), (0, skill_integrity_1.managedLinkTargets)(safeReadArtifactState()))
255
+ : { valid: [], repairable: [], dead: [], usurped: [] },
255
256
  };
256
257
  }
258
+ /** `readArtifactState` tira si el JSON esta roto — correcto para un comando que va a
259
+ * ESCRIBIR sobre ese estado, inaceptable para uno que solo diagnostica. Aca degrada a
260
+ * "sin ledger": se pierde la deteccion de usurpaciones, no el resto del reporte. */
261
+ function safeReadArtifactState() {
262
+ try {
263
+ return (0, artifact_state_1.readArtifactState)();
264
+ }
265
+ catch {
266
+ return [];
267
+ }
268
+ }
257
269
  function gatherProject(root, bundles, agent = 'claude-code') {
258
270
  const profile = (0, profile_1.readProfile)(root);
259
271
  const profilePresent = fs_1.default.existsSync(path_1.default.join(root, '.awm', 'profile.json'));
@@ -294,7 +306,12 @@ function gatherContext(opts = {}) {
294
306
  const agent = opts.agent ?? 'claude-code';
295
307
  const root = (0, profile_1.findProjectRoot)(cwd);
296
308
  const agents = opts.agents ?? [agent];
297
- const scanSkills = opts.scanSkills ?? ((dir) => (0, skill_integrity_1.classifySkillLinks)(dir, (0, registries_1.contentRoots)()));
309
+ // El ledger se lee UNA vez por corrida y se comparte con todos los dirs escaneados:
310
+ // es lo unico que distingue "el usuario puso este directorio" de "algo reemplazo
311
+ // nuestro symlink". Best-effort — un ledger ausente o corrupto degrada a "no puedo
312
+ // detectar usurpaciones", nunca revienta un comando de diagnostico.
313
+ const managed = (0, skill_integrity_1.managedLinkTargets)(safeReadArtifactState());
314
+ const scanSkills = opts.scanSkills ?? ((dir) => (0, skill_integrity_1.classifySkillLinks)(dir, (0, registries_1.contentRoots)(), managed));
298
315
  return {
299
316
  machine: gatherMachine(bundles, agent, root ?? undefined),
300
317
  project: root ? gatherProject(root, bundles, agent) : null,
@@ -108,13 +108,20 @@ function skillsGlobalCheck(dir, owners, integrity, renderer) {
108
108
  }
109
109
  const shared = owners.length > 1;
110
110
  const broken = integrity.repairable.length + integrity.dead.length;
111
+ // Usurpado ≠ roto: el link no cuelga, DESAPARECIO — otro instalador dejo un
112
+ // directorio real con el mismo nombre encima. El agente carga esa skill, no la
113
+ // nuestra, y hasta que esto se reporto el scan solo miraba symlinks, asi que el
114
+ // caso era literalmente invisible y `overall` decia `healthy`. No se auto-repara:
115
+ // borrar un directorio real con contenido de un tercero es destructivo y necesita
116
+ // que lo pida una persona.
117
+ const usurped = integrity.usurped.length;
111
118
  // Broken links are checked BEFORE shared: 'shared' is a non-degrading/OK state
112
119
  // (see checks.ts's DEGRADING_PROVIDER_STATES), so if it were set unconditionally
113
120
  // for a shared dir it would silently mask real broken/dead symlinks — a green
114
121
  // checkmark next to "N broken links → repair-global-skills" would contradict its
115
122
  // own trailing text, and `overall` would never degrade despite real breakage.
116
123
  let state;
117
- if (broken > 0) {
124
+ if (broken > 0 || usurped > 0) {
118
125
  state = 'broken';
119
126
  }
120
127
  else if (shared) {
@@ -131,8 +138,18 @@ function skillsGlobalCheck(dir, owners, integrity, renderer) {
131
138
  state,
132
139
  target: dir,
133
140
  owners: shared ? owners : undefined,
134
- detail: broken > 0 ? `${broken} broken links` : undefined,
135
- remediationCode: broken > 0 ? 'repair-global-skills' : undefined,
141
+ detail: [
142
+ broken > 0 ? `${broken} broken links` : null,
143
+ usurped > 0
144
+ ? `${usurped} replaced by non-AWM content (${integrity.usurped.join(', ')})`
145
+ : null,
146
+ ].filter(Boolean).join('; ') || undefined,
147
+ // Una usurpacion NO la arregla `repair-global-skills` (solo toca symlinks
148
+ // colgantes), asi que ofrecer ese remedio seria mandar al usuario a un comando
149
+ // que no cambia nada. Reinstalar el bundle es lo que la resuelve.
150
+ remediationCode: usurped > 0
151
+ ? 'reinstall-usurped-skills'
152
+ : broken > 0 ? 'repair-global-skills' : undefined,
136
153
  };
137
154
  }
138
155
  /** R8: verify the Codex `.toml` agents this run's renderer would have produced still parse. */
@@ -334,7 +351,8 @@ function gatherProviderChecks(agents, scanSkills, projectRoot) {
334
351
  const provider = (0, providers_1.providerFor)(agent);
335
352
  const dir = provider.skill.global;
336
353
  const owners = (dir !== null ? ownersByDir.get(dir) : undefined) ?? [agent];
337
- const integrity = (dir !== null ? scansByDir.get(dir) : undefined) ?? { valid: [], repairable: [], dead: [] };
354
+ const integrity = (dir !== null ? scansByDir.get(dir) : undefined)
355
+ ?? { valid: [], repairable: [], dead: [], usurped: [] };
338
356
  const checks = [
339
357
  binaryVersionCheck(agent),
340
358
  skillsGlobalCheck(dir, owners, integrity, provider.skill.renderer),
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.managedLinkTargets = managedLinkTargets;
6
7
  exports.classifySkillLinks = classifySkillLinks;
7
8
  exports.repairSkillLinks = repairSkillLinks;
8
9
  exports.reconcileAllSkillLinks = reconcileAllSkillLinks;
@@ -19,12 +20,34 @@ function findRegistrySkillPath(registryContentDirs, name) {
19
20
  }
20
21
  return null;
21
22
  }
23
+ /** Normaliza una ruta para comparar contra el ledger: absoluta siempre, y en
24
+ * Windows tambien case-insensitive, porque NTFS lo es y el ledger guarda la
25
+ * ruta con el casing que tuvo al instalarse, no el que tiene al leerse. */
26
+ function pathKey(p) {
27
+ const resolved = path_1.default.resolve(p);
28
+ return (0, paths_1.isWindowsNative)() ? resolved.toLowerCase() : resolved;
29
+ }
30
+ /** Targets que el ledger de artefactos declara instalados con el renderer `link`,
31
+ * normalizados con `pathKey` para comparar contra rutas del filesystem.
32
+ *
33
+ * Solo `link`: para un renderer que produce archivos reales (`cursor-mdc`,
34
+ * `copilot-instructions`) "no es un symlink" es lo esperado, no una usurpacion. */
35
+ function managedLinkTargets(records) {
36
+ return new Set(records.filter((r) => r.renderer === 'link').map((r) => pathKey(r.targetPath)));
37
+ }
22
38
  /** Clasifica cada entrada de `skillsDir` (read-only, no muta nada).
23
39
  * Agnostico al alcance: `skillsDir` es global (`provider.skill.global`) o de
24
40
  * proyecto (`<projectRoot>/<provider.skill.local>`) — el nombre decia "Global" y
25
- * eso basto para que nadie lo apuntara nunca a un dir de proyecto. */
26
- function classifySkillLinks(skillsDir, registryContentDirs) {
27
- const out = { valid: [], repairable: [], dead: [] };
41
+ * eso basto para que nadie lo apuntara nunca a un dir de proyecto.
42
+ *
43
+ * `managedTargets` (opcional, ver `managedLinkTargets`) es lo que distingue
44
+ * "un directorio que el usuario puso ahi" de "un directorio que reemplazo un
45
+ * link nuestro". Sin el, el scan solo mira symlinks y una usurpacion es
46
+ * literalmente invisible: `awm doctor` reporta `healthy` mientras el agente
47
+ * carga la skill de otro. Por defecto vacio, para que los llamadores que no
48
+ * tienen ledger a mano conserven el comportamiento anterior. */
49
+ function classifySkillLinks(skillsDir, registryContentDirs, managedTargets = new Set()) {
50
+ const out = { valid: [], repairable: [], dead: [], usurped: [] };
28
51
  let entries;
29
52
  try {
30
53
  entries = fs_1.default.readdirSync(skillsDir);
@@ -41,8 +64,13 @@ function classifySkillLinks(skillsDir, registryContentDirs) {
41
64
  catch {
42
65
  continue;
43
66
  }
44
- if (!lst.isSymbolicLink())
45
- continue; // dirs/archivos reales no son nuestro problema
67
+ if (!lst.isSymbolicLink()) {
68
+ // Un dir/archivo real que puso el usuario no es nuestro problema. Uno
69
+ // que el ledger declara nuestro SI lo es — ahi hubo un reemplazo.
70
+ if (managedTargets.has(pathKey(p)))
71
+ out.usurped.push(name);
72
+ continue;
73
+ }
46
74
  if (fs_1.default.existsSync(p)) {
47
75
  out.valid.push(name);
48
76
  continue;
@@ -109,14 +109,17 @@ describe('runInit', () => {
109
109
  expect(caveatCalls).toHaveLength(0);
110
110
  });
111
111
  });
112
- it('returns exit 1 on a bare HOME and never prompts with --yes (cache stubbed)', async () => {
112
+ it('returns exit 0 on a bare HOME and never prompts with --yes (cache stubbed)', async () => {
113
113
  const { runInit } = require('../../src/commands/init');
114
114
  const code = await runInit({
115
115
  cwd: tmpHome,
116
116
  yes: true,
117
117
  actions: { syncCache: async () => { }, installHook: () => ({ status: 'installed' }) },
118
118
  });
119
- expect(code).toBe(1); // cache/hook/devCore siguen ausentes (syncCache no-op) degradado
119
+ // Degradado (cache/hook/devCore siguen ausentes con syncCache no-op) pero NADA
120
+ // fallo, y el exit code de `init` responde por init, no por la salud del
121
+ // harness — ver D-008. Esto salia 1 y rompia `awm init --yes && …` bajo `set -e`.
122
+ expect(code).toBe(0);
120
123
  });
121
124
  it('--json emits a parseable InitOutcome', async () => {
122
125
  const { runInit } = require('../../src/commands/init');
@@ -131,7 +134,10 @@ describe('runInit', () => {
131
134
  expect(Array.isArray(parsed.steps)).toBe(true);
132
135
  expect(parsed.after.overall).toBe('degraded');
133
136
  expect(parsed.result).toBe('degraded'); // success envelope is self-describing too
134
- expect(code).toBe(1);
137
+ // El exit code deja de duplicar `result`: la distincion ok/degradado sigue
138
+ // disponible para quien la quiera, en el campo, no en `$?` (D-008).
139
+ expect(parsed.failed).toBe(0);
140
+ expect(code).toBe(0);
135
141
  });
136
142
  const prefsFile = () => path_1.default.join(process.env.AWM_HOME, 'preferences.json');
137
143
  const readPreferences = () => JSON.parse(fs_1.default.readFileSync(prefsFile(), 'utf-8'));
@@ -239,6 +245,40 @@ describe('runInit', () => {
239
245
  .toEqual(['claude-code', 'codex', 'opencode']);
240
246
  });
241
247
  // -----------------------------------------------------------------------
248
+ // D-008 — el exit code de `init` responde por init, no por la salud del harness
249
+ // -----------------------------------------------------------------------
250
+ //
251
+ // El contrato entero en un solo lugar, porque el sitio que lo rompia era el
252
+ // ausente: ningun test preguntaba "¿un script puede encadenar `awm init &&`?".
253
+ // Habia dos assertions de `toBe(1)`, y las dos *documentaban* el bug en vez de
254
+ // detenerlo. Tres lectores independientes lo reportaron como fallo antes de que
255
+ // lo fuera, y la doc habia crecido un recuadro pidiendo ignorar el exit code.
256
+ it('a degraded-but-successful run exits 0, so `awm init && next` survives set -e', async () => {
257
+ const { runInit } = require('../../src/commands/init');
258
+ const code = await runInit({
259
+ cwd: tmpHome,
260
+ yes: true,
261
+ json: true,
262
+ actions: { syncCache: async () => { }, installHook: () => ({ status: 'installed' }) },
263
+ });
264
+ const parsed = JSON.parse(writeSpy.mock.calls.map((c) => c[0]).join(''));
265
+ // Las tres afirmaciones juntas son el punto: degradado, sin nada fallado, exit 0.
266
+ // Cualquiera de las tres sola se puede satisfacer sin el fix.
267
+ expect(parsed.result).toBe('degraded');
268
+ expect(parsed.failed).toBe(0);
269
+ expect(code).toBe(0);
270
+ });
271
+ it('still refuses with exit 2 when a gate rejects — 0 does not mean "always succeed"', async () => {
272
+ const { runInit } = require('../../src/commands/init');
273
+ const code = await runInit({
274
+ cwd: tmpHome,
275
+ agent: 'codex',
276
+ yes: true,
277
+ assertProviderSupported: () => { throw new Error('requires Codex >= 0.145.0'); },
278
+ });
279
+ expect(code).toBe(2);
280
+ });
281
+ // -----------------------------------------------------------------------
242
282
  // Step 1 — gate ordering, backup/rollback atomicity, Codex/Claude coexistence
243
283
  // -----------------------------------------------------------------------
244
284
  it('gates Codex before preferences or provider writes', async () => {
@@ -8,7 +8,7 @@ function healthyMachine() {
8
8
  devCore: { present: true, brokenLinks: [] },
9
9
  ambient: { wanted: [], installed: [] },
10
10
  contextInjection: [],
11
- globalSkills: { valid: [], repairable: [], dead: [] },
11
+ globalSkills: { valid: [], repairable: [], dead: [], usurped: [] },
12
12
  };
13
13
  }
14
14
  function healthyProject() {
@@ -173,12 +173,12 @@ describe('machineChecks — global skill integrity', () => {
173
173
  };
174
174
  }
175
175
  it('ok when no broken global skill links', () => {
176
- const report = (0, checks_1.runChecks)(machineCtx({ valid: ['a'], repairable: [], dead: [] }));
176
+ const report = (0, checks_1.runChecks)(machineCtx({ valid: ['a'], repairable: [], dead: [], usurped: [] }));
177
177
  const row = report.results.find((r) => r.id === 'machine.globalSkills');
178
178
  expect(row?.status).toBe('ok');
179
179
  });
180
180
  it('warns with awm init remedy when there are broken links', () => {
181
- const report = (0, checks_1.runChecks)(machineCtx({ valid: ['a'], repairable: ['b'], dead: ['c'] }));
181
+ const report = (0, checks_1.runChecks)(machineCtx({ valid: ['a'], repairable: ['b'], dead: ['c'], usurped: [] }));
182
182
  const row = report.results.find((r) => r.id === 'machine.globalSkills');
183
183
  expect(row?.status).toBe('warn');
184
184
  expect(row?.detail).toContain('2'); // 1 repairable + 1 dead
@@ -240,7 +240,7 @@ describe('gatherContext — providers matrix (Task 9)', () => {
240
240
  process.env.AWM_HOME = originalAwmHome;
241
241
  });
242
242
  function healthySharedSkills() {
243
- return { valid: ['development-process'], repairable: [], dead: [] };
243
+ return { valid: ['development-process'], repairable: [], dead: [], usurped: [] };
244
244
  }
245
245
  it('reports shared skills for both owners without scanning twice', () => {
246
246
  // OpenCode and Codex both read/write ~/.agents/skills (providers/index.ts) —
@@ -252,7 +252,7 @@ describe('gatherContext — providers matrix (Task 9)', () => {
252
252
  expect(report.providers.every((provider) => provider.checks.some((check) => check.state === 'shared'))).toBe(true);
253
253
  });
254
254
  it('marks skills.global healthy (not shared) for a single unshared provider', () => {
255
- const scan = jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
255
+ const scan = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
256
256
  const { gatherContext } = require('../../../src/core/diagnostics/context');
257
257
  const report = gatherContext({ cwd: tmpHome, bundles: [], agents: ['claude-code'], scanSkills: scan });
258
258
  expect(scan).toHaveBeenCalledTimes(1);
@@ -177,7 +177,7 @@ describe('gatherProviderChecks — shared skills.global does not mask broken lin
177
177
  const { computeProviderOverall } = require('../../../src/core/diagnostics/checks');
178
178
  // OpenCode and Codex share ~/.agents/skills (providers/index.ts). Stub the
179
179
  // scan to report 2 broken/dead links on that shared directory.
180
- const scanSkills = jest.fn(() => ({ valid: ['ok-skill'], repairable: ['stale-skill'], dead: ['ghost-skill'] }));
180
+ const scanSkills = jest.fn(() => ({ valid: ['ok-skill'], repairable: ['stale-skill'], dead: ['ghost-skill'], usurped: [] }));
181
181
  const ctx = gatherContext({ cwd: tmpHome, bundles: [], agents: ['opencode', 'codex'], scanSkills });
182
182
  expect(scanSkills).toHaveBeenCalledTimes(1);
183
183
  for (const provider of ctx.providers) {
@@ -192,7 +192,7 @@ describe('gatherProviderChecks — shared skills.global does not mask broken lin
192
192
  it('still reports shared (healthy, non-degrading) when the shared dir has no broken links', () => {
193
193
  const { gatherContext } = require('../../../src/core/diagnostics/context');
194
194
  const { computeProviderOverall } = require('../../../src/core/diagnostics/checks');
195
- const scanSkills = jest.fn(() => ({ valid: ['ok-skill'], repairable: [], dead: [] }));
195
+ const scanSkills = jest.fn(() => ({ valid: ['ok-skill'], repairable: [], dead: [], usurped: [] }));
196
196
  const ctx = gatherContext({ cwd: tmpHome, bundles: [], agents: ['opencode', 'codex'], scanSkills });
197
197
  for (const provider of ctx.providers) {
198
198
  const skillsCheck = provider.checks.find((c) => c.id === 'skills.global');
@@ -237,7 +237,7 @@ describe('gatherProviderChecks — agents.native reports broken on a malformed C
237
237
  fs_1.default.mkdirSync(agentsDir, { recursive: true });
238
238
  fs_1.default.writeFileSync(path_1.default.join(agentsDir, 'development-process.toml'), 'name = "development-process"\n# missing the developer_instructions key entirely\n');
239
239
  const { gatherContext } = require('../../../src/core/diagnostics/context');
240
- const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
240
+ const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
241
241
  const ctx = gatherContext({ cwd: tmpHome, bundles: [], agents: ['codex'], scanSkills });
242
242
  const codex = ctx.providers.find((p) => p.id === 'codex');
243
243
  const agentsNative = codex.checks.find((c) => c.id === 'agents.native');
@@ -51,7 +51,7 @@ describe('contextGlobalCheck — scope-aware (Task 4.4 / deferred Task 4.2 findi
51
51
  return root;
52
52
  }
53
53
  function scanSkillsStub() {
54
- return jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
54
+ return jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
55
55
  }
56
56
  beforeEach(() => {
57
57
  tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-provider-tier-home-'));
@@ -177,7 +177,7 @@ describe('skillsGlobalCheck — renderer-aware (Task 4.4 / deferred Task 4.3 fin
177
177
  // classifySkillLinks only ever sees symlinks (`if (!lst.isSymbolicLink()) continue;`)
178
178
  // — a real scan over rulesDir would find nothing here either. Stubbed explicitly so the
179
179
  // test proves the FIX (renderer-gating), not an accident of what classifySkillLinks does.
180
- const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
180
+ const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
181
181
  const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
182
182
  const facts = gatherProviderChecks(['cursor'], scanSkills);
183
183
  const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
@@ -216,7 +216,7 @@ describe('skillsGlobalCheck — renderer-aware (Task 4.4 / deferred Task 4.3 fin
216
216
  });
217
217
  const rulesDir = path_1.default.join(tmpHome, '.cursor/rules');
218
218
  expect(fs_1.default.existsSync(path_1.default.join(rulesDir, 'using-awm.mdc'))).toBe(true);
219
- const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
219
+ const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
220
220
  const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
221
221
  const facts = gatherProviderChecks(['cursor'], scanSkills);
222
222
  const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
@@ -225,7 +225,7 @@ describe('skillsGlobalCheck — renderer-aware (Task 4.4 / deferred Task 4.3 fin
225
225
  fs_1.default.rmSync(content, { recursive: true, force: true });
226
226
  });
227
227
  it('non-link renderer with an empty/missing dir reports absent, not a false healthy', () => {
228
- const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
228
+ const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
229
229
  const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
230
230
  const facts = gatherProviderChecks(['cursor'], scanSkills);
231
231
  const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
@@ -234,7 +234,7 @@ describe('skillsGlobalCheck — renderer-aware (Task 4.4 / deferred Task 4.3 fin
234
234
  it('link renderer (claude-code) behavior is completely unchanged — regression', () => {
235
235
  const skillsDir = path_1.default.join(tmpHome, '.claude/skills');
236
236
  fs_1.default.mkdirSync(skillsDir, { recursive: true });
237
- const scanSkills = jest.fn(() => ({ valid: ['using-awm'], repairable: [], dead: [] }));
237
+ const scanSkills = jest.fn(() => ({ valid: ['using-awm'], repairable: [], dead: [], usurped: [] }));
238
238
  const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
239
239
  const facts = gatherProviderChecks(['claude-code'], scanSkills);
240
240
  const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
@@ -244,7 +244,7 @@ describe('skillsGlobalCheck — renderer-aware (Task 4.4 / deferred Task 4.3 fin
244
244
  it('link renderer (claude-code) still reports broken links — regression', () => {
245
245
  const skillsDir = path_1.default.join(tmpHome, '.claude/skills');
246
246
  fs_1.default.mkdirSync(skillsDir, { recursive: true });
247
- const scanSkills = jest.fn(() => ({ valid: [], repairable: ['stale-skill'], dead: [] }));
247
+ const scanSkills = jest.fn(() => ({ valid: [], repairable: ['stale-skill'], dead: [], usurped: [] }));
248
248
  const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
249
249
  const facts = gatherProviderChecks(['claude-code'], scanSkills);
250
250
  const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
@@ -263,7 +263,7 @@ describe('skillsGlobalCheck — renderer-aware (Task 4.4 / deferred Task 4.3 fin
263
263
  // neither ends in `.mdc`, so neither is AWM-shaped evidence.
264
264
  fs_1.default.writeFileSync(path_1.default.join(rulesDir, 'notes.txt'), 'my own notes, not an AWM rule');
265
265
  fs_1.default.mkdirSync(path_1.default.join(rulesDir, 'some-user-dir'));
266
- const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
266
+ const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
267
267
  const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
268
268
  const facts = gatherProviderChecks(['cursor'], scanSkills);
269
269
  const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
@@ -274,7 +274,7 @@ describe('skillsGlobalCheck — renderer-aware (Task 4.4 / deferred Task 4.3 fin
274
274
  fs_1.default.mkdirSync(rulesDir, { recursive: true });
275
275
  fs_1.default.writeFileSync(path_1.default.join(rulesDir, 'notes.txt'), 'my own notes, not an AWM rule');
276
276
  fs_1.default.writeFileSync(path_1.default.join(rulesDir, 'foo.mdc'), '---\ndescription: foo\nglobs:\nalwaysApply: false\n---\n\nBody.');
277
- const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [] }));
277
+ const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
278
278
  const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
279
279
  const facts = gatherProviderChecks(['cursor'], scanSkills);
280
280
  const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
@@ -61,7 +61,7 @@ describe('stepCache — registry sync error results', () => {
61
61
  devCore: { present: true, brokenLinks: [] },
62
62
  ambient: { wanted: [], installed: [] },
63
63
  contextInjection: [],
64
- globalSkills: { valid: [], repairable: [], dead: [] },
64
+ globalSkills: { valid: [], repairable: [], dead: [], usurped: [] },
65
65
  },
66
66
  project: null,
67
67
  };
@@ -23,7 +23,7 @@ function machine() {
23
23
  devCore: { present: true, brokenLinks: [] },
24
24
  ambient: { wanted: [], installed: [] },
25
25
  contextInjection: [],
26
- globalSkills: { valid: [], repairable: [], dead: [] },
26
+ globalSkills: { valid: [], repairable: [], dead: [], usurped: [] },
27
27
  };
28
28
  }
29
29
  function project(over = {}) {
@@ -429,7 +429,7 @@ describe('stepGlobalSkillsRepair', () => {
429
429
  const a = spies();
430
430
  a.repairGlobalSkills = jest.fn(() => ({ relinked: ['b'], pruned: ['c'], failed: [] }));
431
431
  const m = machine();
432
- m.globalSkills = { valid: ['a'], repairable: ['b'], dead: ['c'] };
432
+ m.globalSkills = { valid: ['a'], repairable: ['b'], dead: ['c'], usurped: [] };
433
433
  const r = (0, steps_1.stepGlobalSkillsRepair)(deps({ machine: m, project: null }, a));
434
434
  expect(r.action).toBe('applied');
435
435
  expect(a.repairGlobalSkills).toHaveBeenCalledTimes(1);
@@ -440,7 +440,7 @@ describe('stepGlobalSkillsRepair', () => {
440
440
  const a = spies();
441
441
  a.repairGlobalSkills = jest.fn(() => ({ relinked: ['b'], pruned: [], failed: [] }));
442
442
  const m = machine();
443
- m.globalSkills = { valid: [], repairable: ['b'], dead: [] };
443
+ m.globalSkills = { valid: [], repairable: ['b'], dead: [], usurped: [] };
444
444
  const r = (0, steps_1.stepGlobalSkillsRepair)(deps({ machine: m, project: null }, a, { agent: 'opencode' }));
445
445
  expect(r.action).toBe('applied');
446
446
  expect(a.repairGlobalSkills).toHaveBeenCalledWith((0, providers_1.providerFor)('opencode').skill.global, expect.any(Array));
@@ -451,7 +451,7 @@ describe('stepGlobalSkillsRepair', () => {
451
451
  const m = machine();
452
452
  // Broken-count is nonzero, so the ONLY thing that can make this skip is the
453
453
  // null-global-dir guard itself, not the "nothing broken" early return above.
454
- m.globalSkills = { valid: [], repairable: ['b'], dead: ['c'] };
454
+ m.globalSkills = { valid: [], repairable: ['b'], dead: ['c'], usurped: [] };
455
455
  const r = (0, steps_1.stepGlobalSkillsRepair)(deps({ machine: m, project: null }, a, { agent: 'copilot' }));
456
456
  expect(r.action).toBe('skipped');
457
457
  expect(a.repairGlobalSkills).not.toHaveBeenCalled();
@@ -71,7 +71,7 @@ describe('classifySkillLinks', () => {
71
71
  });
72
72
  it('returns empty arrays when the skills dir does not exist', () => {
73
73
  const result = (0, skill_integrity_1.classifySkillLinks)('/nonexistent/dir', ['/also/nonexistent']);
74
- expect(result).toEqual({ valid: [], repairable: [], dead: [] });
74
+ expect(result).toEqual({ valid: [], repairable: [], dead: [], usurped: [] });
75
75
  });
76
76
  });
77
77
  describe('reconcileAllSkillLinks (#4 — awm update, all providers)', () => {
@@ -0,0 +1,126 @@
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
+ // Un symlink gestionado por AWM que otro instalador reemplaza por un directorio real
7
+ // era INVISIBLE para todo el sistema de diagnostico.
8
+ //
9
+ // Encontrado corriendo el playbook `agent-matrix` para `claude-code` contra el binario
10
+ // publicado: `awm init` dejo `~/.claude/skills/mermaid-diagrams` como symlink al registry
11
+ // baseline y lo anoto en `state/artifacts.json`; la primera sesion real de Claude Code
12
+ // materializo su propia skill `mermaid-diagrams` (bundled, mismo nombre) encima, pisando
13
+ // el symlink con un directorio real distinto — otra `description`, sin `version`, con un
14
+ // README.md que la nuestra no tiene.
15
+ //
16
+ // Despues de eso:
17
+ // - `awm doctor -a claude-code` reportaba `skills.global: healthy`, `overall: healthy`,
18
+ // exit 0;
19
+ // - `awm sync` no lo tocaba;
20
+ // - el agente cargaba la skill del tercero, no la instalada.
21
+ //
22
+ // La causa es una sola linea de `classifySkillLinks`: `if (!lst.isSymbolicLink()) continue`.
23
+ // Correcta para una skill que el usuario puso a mano — AWM no debe tocarla — y equivocada
24
+ // cuando el ledger de artefactos dice que esa ruta exacta es nuestra. El clasificador nunca
25
+ // consultaba el ledger, asi que no podia distinguir los dos casos.
26
+ const fs_1 = __importDefault(require("fs"));
27
+ const path_1 = __importDefault(require("path"));
28
+ const skill_integrity_1 = require("../../src/core/skill-integrity");
29
+ const tmp_1 = require("../support/tmp");
30
+ describe('a managed skill link replaced by third-party content is detected', () => {
31
+ let registry;
32
+ let skillsDir;
33
+ const made = [];
34
+ const record = (targetPath, renderer = 'link') => ({
35
+ name: path_1.default.basename(targetPath),
36
+ type: 'skill',
37
+ scope: 'global',
38
+ targetPath,
39
+ sourcePath: path_1.default.join(registry, 'skills', path_1.default.basename(targetPath)),
40
+ renderer,
41
+ owners: ['claude-code'],
42
+ });
43
+ beforeEach(() => {
44
+ registry = (0, tmp_1.mkCanonicalTmpDir)('awm-usurp-reg-');
45
+ skillsDir = (0, tmp_1.mkCanonicalTmpDir)('awm-usurp-skills-');
46
+ made.push(registry, skillsDir);
47
+ fs_1.default.mkdirSync(path_1.default.join(registry, 'skills', 'mermaid-diagrams'), { recursive: true });
48
+ fs_1.default.writeFileSync(path_1.default.join(registry, 'skills', 'mermaid-diagrams', 'SKILL.md'), '# awm\n');
49
+ });
50
+ afterAll(() => { for (const d of made)
51
+ fs_1.default.rmSync(d, { recursive: true, force: true }); });
52
+ /** Lo que hace el tercero: borra nuestro symlink y deja su propio directorio. */
53
+ function usurp(name) {
54
+ const p = path_1.default.join(skillsDir, name);
55
+ fs_1.default.rmSync(p, { recursive: true, force: true });
56
+ fs_1.default.mkdirSync(p, { recursive: true });
57
+ fs_1.default.writeFileSync(path_1.default.join(p, 'SKILL.md'), '# someone else\n');
58
+ return p;
59
+ }
60
+ it('reports the replaced entry as usurped, not as healthy', () => {
61
+ const target = path_1.default.join(skillsDir, 'mermaid-diagrams');
62
+ fs_1.default.symlinkSync(path_1.default.join(registry, 'skills', 'mermaid-diagrams'), target);
63
+ // Antes de la usurpacion: un link vivo y nada mas.
64
+ const managed = (0, skill_integrity_1.managedLinkTargets)([record(target)]);
65
+ const before = (0, skill_integrity_1.classifySkillLinks)(skillsDir, [registry], managed);
66
+ expect(before.valid).toEqual(['mermaid-diagrams']);
67
+ expect(before.usurped).toEqual([]);
68
+ usurp('mermaid-diagrams');
69
+ const after = (0, skill_integrity_1.classifySkillLinks)(skillsDir, [registry], managed);
70
+ expect(after.usurped).toEqual(['mermaid-diagrams']);
71
+ // Y no se cuela por ninguna de las categorias existentes: `valid` es lo que hace
72
+ // que `doctor` diga `healthy`, y `repairable`/`dead` mandarian a un remedio
73
+ // (`repair-global-skills`) que solo toca symlinks y aca no cambiaria nada.
74
+ expect(after.valid).toEqual([]);
75
+ expect(after.repairable).toEqual([]);
76
+ expect(after.dead).toEqual([]);
77
+ });
78
+ it('leaves a directory the user created alone — it is not in the ledger', () => {
79
+ usurp('my-own-skill');
80
+ const scan = (0, skill_integrity_1.classifySkillLinks)(skillsDir, [registry], (0, skill_integrity_1.managedLinkTargets)([]));
81
+ expect(scan.usurped).toEqual([]);
82
+ expect(scan.valid).toEqual([]);
83
+ });
84
+ it('does not flag a rendered artifact: a real file is what `cursor-mdc` produces', () => {
85
+ // El ledger tambien anota artefactos renderizados (`.mdc`, `.instructions.md`).
86
+ // Para esos, "no es un symlink" es el estado correcto — contarlos como usurpados
87
+ // pintaria de rojo cada instalacion sana de Cursor y Copilot.
88
+ const target = path_1.default.join(skillsDir, 'rendered.mdc');
89
+ fs_1.default.writeFileSync(target, '---\ndescription: x\n---\n');
90
+ const managed = (0, skill_integrity_1.managedLinkTargets)([record(target, 'cursor-mdc')]);
91
+ expect(managed.size).toBe(0);
92
+ expect((0, skill_integrity_1.classifySkillLinks)(skillsDir, [registry], managed).usurped).toEqual([]);
93
+ });
94
+ it('defaults to no detection when no ledger is passed (callers without one are unchanged)', () => {
95
+ usurp('mermaid-diagrams');
96
+ expect((0, skill_integrity_1.classifySkillLinks)(skillsDir, [registry]).usurped).toEqual([]);
97
+ });
98
+ });
99
+ // El hallazgo del playbook no fue "el clasificador no ve X" — fue "doctor dice healthy".
100
+ // Detectarlo en `classifySkillLinks` no sirve de nada si el check que lo consume lo
101
+ // descarta, asi que la superficie que reporto el bug se asserta por separado.
102
+ describe('awm doctor degrades on a usurped global skill', () => {
103
+ const { gatherProviderChecks } = require('../../src/core/diagnostics/provider-checks');
104
+ function checkFor(usurped, repairable = []) {
105
+ const scan = jest.fn(() => ({ valid: [], repairable, dead: [], usurped }));
106
+ const facts = gatherProviderChecks(['claude-code'], scan);
107
+ return facts[0].checks.find((c) => c.id === 'skills.global');
108
+ }
109
+ it('is broken, names what was replaced, and does not offer a remedy that cannot fix it', () => {
110
+ const check = checkFor(['mermaid-diagrams']);
111
+ expect(check.state).toBe('broken');
112
+ expect(check.detail).toContain('mermaid-diagrams');
113
+ // `repair-global-skills` solo re-linkea symlinks colgantes: mandaria al usuario a
114
+ // un comando que corre limpio y no cambia nada.
115
+ expect(check.remediationCode).toBe('reinstall-usurped-skills');
116
+ });
117
+ it('still reports plain broken links the old way when nothing was usurped', () => {
118
+ const check = checkFor([], ['gone']);
119
+ expect(check.state).toBe('broken');
120
+ expect(check.detail).toBe('1 broken links');
121
+ expect(check.remediationCode).toBe('repair-global-skills');
122
+ });
123
+ it('is healthy when neither happened', () => {
124
+ expect(checkFor([]).state).not.toBe('broken');
125
+ });
126
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-workflow-manager",
3
- "version": "4.1.0",
3
+ "version": "5.0.0",
4
4
  "main": "dist/src/index.js",
5
5
  "bin": {
6
6
  "awm": "./dist/src/index.js"