agentic-workflow-manager 4.0.1 → 4.1.1

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.
@@ -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;
package/dist/src/index.js CHANGED
@@ -477,18 +477,43 @@ program.command('list [package]')
477
477
  console.log(picocolors_1.default.dim(` awm list <pkg> · awm list --all`));
478
478
  (0, prompts_1.outro)(`Run ${picocolors_1.default.green('awm add')} to install artifacts.`);
479
479
  });
480
- program.command('remove')
481
- .description('Remove an installed skill or workflow')
480
+ // Simetrico con `add`: si se puede instalar scripteado, se tiene que poder desinstalar
481
+ // scripteado. `remove` era interactivo puro — sin nombre posicional, sin `--scope`, sin
482
+ // `--yes` — asi que cualquier limpieza automatizada quedaba bloqueada, y el playbook de
483
+ // aceptacion (CORE-17) scripteaba `awm remove dev --yes`, una invocacion que nunca
484
+ // existio. Tercera vez en esta sesion que un playbook se escribio contra lo que la doc
485
+ // prometia y no contra el comportamiento. Ver docs/decisions.md, D-006.
486
+ //
487
+ // El nombre es de BUNDLE, igual que en `add` (D-001).
488
+ program.command('remove [name]')
489
+ .description('Remove an installed bundle interactively, or non-interactively with flags')
482
490
  .option('-a, --agent <agent>', `Target agent(s), comma-separated: ${providers_1.AGENT_TARGETS.join(', ')} (defaults to every enabled agent)`)
483
- .action(async (options) => {
491
+ .option('-s, --scope <scope>', 'Scope: local or global (skips the prompt)')
492
+ .option('-y, --yes', 'Skip the confirmation prompt (requires a name)')
493
+ .action(async (name, options) => {
484
494
  (0, prompts_1.intro)(picocolors_1.default.bgCyan(picocolors_1.default.black(' AWM - Remove Artifact ')));
485
495
  const prefs = (0, config_1.getPreferences)();
496
+ // `--yes` sin nombre borraria lo que el usuario nunca eligio: un `rm -rf` silencioso
497
+ // sobre todo lo instalado. Se rechaza antes de tocar nada.
498
+ if (options.yes && !name) {
499
+ console.error(picocolors_1.default.red('--yes requires a bundle name: `awm remove <bundle> --yes`.'));
500
+ console.error(picocolors_1.default.dim('Without a name, removal stays interactive so you see what you are deleting.'));
501
+ process.exit(1);
502
+ }
486
503
  // Multi-agent selection (matching the add command flow). --agent skips
487
504
  // the interactive prompt and resolves the same way as add/sync/update/doctor (R12/R13).
488
505
  let targetAgents;
489
506
  if (options.agent) {
490
507
  targetAgents = resolveTargetsOrExit(prefs, options.agent);
491
508
  }
509
+ else if (options.yes) {
510
+ // `--yes` significa CERO prompts, no "cero confirmaciones". Sin esto, un
511
+ // `awm remove dev --yes` sin `--agent` seguia abriendo el multiselect de
512
+ // agentes y colgaba cualquier script — el flag prometia no-interactivo y no lo
513
+ // era. Sin `--agent`, el default son los agentes habilitados, igual que en
514
+ // `add`, `sync`, `update` y `doctor`.
515
+ targetAgents = [...prefs.enabledAgents];
516
+ }
492
517
  else {
493
518
  const agentChoice = await (0, prompts_1.multiselect)({
494
519
  message: 'From which agent(s)?',
@@ -502,16 +527,26 @@ program.command('remove')
502
527
  handleCancel(agentChoice);
503
528
  targetAgents = agentChoice;
504
529
  }
505
- const scopeChoice = await (0, prompts_1.select)({
506
- message: 'Scope?',
507
- options: [
508
- { value: 'local', label: 'Project (Local)' },
509
- { value: 'global', label: 'Global' }
510
- ],
511
- initialValue: prefs.defaultScope
512
- });
513
- handleCancel(scopeChoice);
514
- const scopeVal = scopeChoice;
530
+ let scopeVal;
531
+ if (options.scope) {
532
+ if (options.scope !== 'local' && options.scope !== 'global') {
533
+ console.error(picocolors_1.default.red(`Invalid scope "${options.scope}". Use: local or global.`));
534
+ process.exit(1);
535
+ }
536
+ scopeVal = options.scope;
537
+ }
538
+ else {
539
+ const scopeChoice = await (0, prompts_1.select)({
540
+ message: 'Scope?',
541
+ options: [
542
+ { value: 'local', label: 'Project (Local)' },
543
+ { value: 'global', label: 'Global' }
544
+ ],
545
+ initialValue: prefs.defaultScope
546
+ });
547
+ handleCancel(scopeChoice);
548
+ scopeVal = scopeChoice;
549
+ }
515
550
  // projectRoot explicito: `config.local` es relativo, y resolverlo contra
516
551
  // `process.cwd()` hacia que `awm remove` no encontrara nada desde un
517
552
  // subdirectorio — y que le pasara rutas RELATIVAS a fs.rmSync cuando si.
@@ -532,16 +567,43 @@ program.command('remove')
532
567
  }
533
568
  return `${icons} ${c.baseName} ${picocolors_1.default.dim(`(in: ${Array.from(locations).join(', ')})`)}`;
534
569
  });
535
- const toRemove = await (0, prompts_1.multiselect)({
536
- message: 'Select artifact(s) to remove',
537
- options: groupedOpts,
538
- required: true
539
- });
540
- handleCancel(toRemove);
541
- const resolved = resolveSelectedArtifacts(toRemove);
570
+ let resolved;
571
+ if (name) {
572
+ // Los artefactos del bundle pedido que estan REALMENTE instalados en este
573
+ // scope. Se cruza contra `installed`: lo que no esta instalado no se puede
574
+ // remover, y decirlo es mejor que borrar un subconjunto en silencio.
575
+ const bundle = (0, bundles_1.discoverAllBundles)().find((b) => b.name === name);
576
+ if (!bundle) {
577
+ console.error(picocolors_1.default.red(`Bundle "${name}" not found in registry.`));
578
+ console.error(picocolors_1.default.dim('Run `awm list` to see available packages.'));
579
+ process.exit(1);
580
+ }
581
+ const owned = new Set([
582
+ ...bundle.skills.map((sk) => sk.name),
583
+ ...bundle.workflows,
584
+ ...bundle.agents,
585
+ ]);
586
+ resolved = installed.filter((a) => owned.has(a.name));
587
+ if (resolved.length === 0) {
588
+ (0, prompts_1.outro)(picocolors_1.default.yellow(`Nothing from "${name}" is installed at ${scopeVal} scope for the selected agent(s).`));
589
+ process.exit(0);
590
+ }
591
+ }
592
+ else {
593
+ const toRemove = await (0, prompts_1.multiselect)({
594
+ message: 'Select artifact(s) to remove',
595
+ options: groupedOpts,
596
+ required: true
597
+ });
598
+ handleCancel(toRemove);
599
+ resolved = resolveSelectedArtifacts(toRemove);
600
+ }
542
601
  const names = resolved.map(a => picocolors_1.default.red(a.name)).join(', ');
543
- const confirmRemove = await (0, prompts_1.confirm)({ message: `Remove ${names}?` });
544
- handleCancel(confirmRemove);
602
+ // `--yes` salta la confirmacion, nunca la seleccion: lo que se borra siempre sale
603
+ // de un nombre que el usuario escribio.
604
+ const confirmRemove = options.yes ? true : await (0, prompts_1.confirm)({ message: `Remove ${names}?` });
605
+ if (!options.yes)
606
+ handleCancel(confirmRemove);
545
607
  if (confirmRemove) {
546
608
  try {
547
609
  for (const artifact of resolved) {
@@ -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.0.1",
3
+ "version": "4.1.1",
4
4
  "main": "dist/src/index.js",
5
5
  "bin": {
6
6
  "awm": "./dist/src/index.js"