agentic-workflow-manager 3.13.4 → 3.13.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/src/commands/add.js +5 -0
  2. package/dist/src/commands/doctor.js +1 -0
  3. package/dist/src/commands/hooks/claude.js +15 -2
  4. package/dist/src/commands/hooks/shared.js +6 -1
  5. package/dist/src/commands/init.js +1 -0
  6. package/dist/src/commands/pin.js +24 -4
  7. package/dist/src/commands/preflight/checks.js +3 -2
  8. package/dist/src/commands/registry/add.js +1 -1
  9. package/dist/src/commands/sensors/index.js +7 -0
  10. package/dist/src/commands/sensors/init.js +48 -7
  11. package/dist/src/commands/sensors/install.js +20 -11
  12. package/dist/src/commands/sensors/run.js +31 -29
  13. package/dist/src/commands/sync.js +29 -0
  14. package/dist/src/commands/watch/index.js +11 -1
  15. package/dist/src/core/artifact-name.js +80 -0
  16. package/dist/src/core/bundle-install.js +55 -12
  17. package/dist/src/core/bundles.js +72 -13
  18. package/dist/src/core/context/regenerate.js +21 -4
  19. package/dist/src/core/context/strategies/codex-agents.js +18 -2
  20. package/dist/src/core/diagnostics/checks.js +26 -2
  21. package/dist/src/core/diagnostics/context.js +91 -25
  22. package/dist/src/core/diagnostics/provider-checks.js +62 -14
  23. package/dist/src/core/discovery.js +15 -23
  24. package/dist/src/core/export/transform.js +30 -25
  25. package/dist/src/core/frontmatter.js +223 -0
  26. package/dist/src/core/init/mutation-targets.js +11 -0
  27. package/dist/src/core/init/steps.js +70 -19
  28. package/dist/src/core/install-planner.js +62 -17
  29. package/dist/src/core/install-transaction.js +13 -1
  30. package/dist/src/core/journal/adapter.js +10 -1
  31. package/dist/src/core/paths.js +61 -8
  32. package/dist/src/core/profile.js +12 -0
  33. package/dist/src/core/provider-artifacts.js +38 -13
  34. package/dist/src/core/provider-version.js +12 -6
  35. package/dist/src/core/registries.js +40 -19
  36. package/dist/src/core/renderers/canonical-agent.js +33 -2
  37. package/dist/src/core/renderers/registry.js +37 -0
  38. package/dist/src/core/renderers/skill-source.js +11 -19
  39. package/dist/src/core/skill-integrity.js +71 -10
  40. package/dist/src/core/update-check.js +14 -2
  41. package/dist/src/core/versioning.js +17 -2
  42. package/dist/src/index.js +31 -23
  43. package/dist/src/providers/index.js +7 -2
  44. package/dist/src/ui/text.js +10 -2
  45. package/dist/src/utils/config.js +7 -8
  46. package/dist/tests/commands/doctor.test.js +6 -0
  47. package/dist/tests/commands/pin.test.js +29 -2
  48. package/dist/tests/commands/preflight/preflight.test.js +31 -17
  49. package/dist/tests/commands/registry/install-bundles.test.js +6 -6
  50. package/dist/tests/commands/sensors/init-pack-unavailable.test.js +96 -0
  51. package/dist/tests/commands/sensors/install-settings-safety.test.js +87 -0
  52. package/dist/tests/commands/sensors/run-is-read-only.test.js +0 -0
  53. package/dist/tests/commands/sensors/run.test.js +24 -51
  54. package/dist/tests/commands/sensors/status-windows.test.js +39 -22
  55. package/dist/tests/commands/sensors/status.test.js +27 -12
  56. package/dist/tests/core/artifact-name-containment.test.js +86 -0
  57. package/dist/tests/core/context/agents-md-single-slot.test.js +69 -0
  58. package/dist/tests/core/context/regenerate.test.js +39 -8
  59. package/dist/tests/core/diagnostics/checks.test.js +14 -6
  60. package/dist/tests/core/diagnostics/provider-tier.test.js +3 -3
  61. package/dist/tests/core/diagnostics/rendered-artifact-visibility.test.js +69 -0
  62. package/dist/tests/core/discovery.test.js +10 -1
  63. package/dist/tests/core/export/transform.test.js +110 -21
  64. package/dist/tests/core/frontmatter-description-vs-yaml.test.js +118 -0
  65. package/dist/tests/core/init/all-baseline-bundles.test.js +64 -0
  66. package/dist/tests/core/init/context-injection-no-project.test.js +117 -0
  67. package/dist/tests/core/init/orchestrator.test.js +1 -1
  68. package/dist/tests/core/init/steps-registry-sync.test.js +1 -1
  69. package/dist/tests/core/init/steps.test.js +11 -5
  70. package/dist/tests/core/path-resolution-no-shell.test.js +136 -0
  71. package/dist/tests/core/paths.test.js +7 -34
  72. package/dist/tests/core/project-skill-links.test.js +159 -0
  73. package/dist/tests/core/provider-artifacts.test.js +66 -14
  74. package/dist/tests/core/provider-capability-guards.test.js +92 -0
  75. package/dist/tests/core/reconciliation.test.js +15 -15
  76. package/dist/tests/core/registry-manifest.test.js +1 -1
  77. package/dist/tests/core/renderers/canonical-agent.test.js +36 -0
  78. package/dist/tests/core/renderers/cursor-mdc.test.js +37 -7
  79. package/dist/tests/core/renderers/skill-source-block-scalar.test.js +117 -0
  80. package/dist/tests/core/semver-fails-closed.test.js +30 -0
  81. package/dist/tests/core/skill-integrity.test.js +10 -10
  82. package/dist/tests/core/skill-repair-safety.test.js +92 -0
  83. package/dist/tests/core/sync-profile-atomicity.test.js +122 -0
  84. package/dist/tests/integration/copilot-init-isolated.test.js +2 -2
  85. package/dist/tests/providers/index.test.js +1 -1
  86. package/dist/tests/structural/renderer-table-is-single-source.test.js +95 -0
  87. package/dist/tests/structural/symlink-type-is-explicit.test.js +73 -0
  88. package/dist/tests/ui/text.test.js +9 -0
  89. package/dist/tests/utils/registry-view-overrides.test.js +2 -2
  90. package/package.json +6 -1
@@ -0,0 +1,159 @@
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
+ // Project-scope skill symlinks had no maintenance path at all.
7
+ //
8
+ // The machinery exists and is exercised every `awm init` / `awm registry sync`:
9
+ // `classifySkillLinks` finds dangling symlinks, `repairSkillLinks` re-links the ones
10
+ // the registry can still serve and prunes the ones it cannot. But every entry point
11
+ // pointed it at `provider.skill.global` only. A dangling link under a PROJECT's
12
+ // `.claude/skills/` (registry re-cloned, skill renamed upstream, bundle removed from
13
+ // `.awm/profile.json`) was:
14
+ //
15
+ // - never healed — `awm sync` installs what the profile declares and looks at
16
+ // nothing else, so a link whose bundle was dropped is simply left behind;
17
+ // - never pruned — it stays in the tree indefinitely, and the agent trips on it
18
+ // when it tries to read the skill's SKILL.md;
19
+ // - never reported — `awm doctor`'s `globalSkills` reads the global dir, so nothing
20
+ // on any surface mentions it.
21
+ //
22
+ // For Copilot the gap is total, not partial: `skill.global` is null, so *every* skill
23
+ // it has is project-scope, and none of them had any integrity path whatsoever.
24
+ const fs_1 = __importDefault(require("fs"));
25
+ const os_1 = __importDefault(require("os"));
26
+ const path_1 = __importDefault(require("path"));
27
+ const skill_integrity_1 = require("../../src/core/skill-integrity");
28
+ const providers_1 = require("../../src/providers");
29
+ describe('project-scope skill links get the same integrity path as global ones', () => {
30
+ let registry;
31
+ let projectRoot;
32
+ const made = [];
33
+ beforeEach(() => {
34
+ registry = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-projlink-reg-'));
35
+ projectRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-projlink-proj-'));
36
+ made.push(registry, projectRoot);
37
+ fs_1.default.mkdirSync(path_1.default.join(registry, 'skills', 'alive'), { recursive: true });
38
+ fs_1.default.writeFileSync(path_1.default.join(registry, 'skills', 'alive', 'SKILL.md'), '# alive\n');
39
+ });
40
+ afterAll(() => { for (const d of made)
41
+ fs_1.default.rmSync(d, { recursive: true, force: true }); });
42
+ /** The dir where `agent`'s project-scope skills live, with the links pre-created. */
43
+ function localSkillsDir(agent, links) {
44
+ const dir = path_1.default.join(projectRoot, (0, providers_1.providerFor)(agent).skill.local);
45
+ fs_1.default.mkdirSync(dir, { recursive: true });
46
+ for (const [name, target] of Object.entries(links))
47
+ fs_1.default.symlinkSync(target, path_1.default.join(dir, name));
48
+ return dir;
49
+ }
50
+ it('classifies a dangling project link as repairable when the registry still has it', () => {
51
+ const dir = localSkillsDir('claude-code', { alive: path_1.default.join(registry, 'gone', 'alive') });
52
+ const out = (0, skill_integrity_1.classifySkillLinks)(dir, [registry]);
53
+ expect(out.repairable).toEqual(['alive']);
54
+ expect(out.dead).toEqual([]);
55
+ });
56
+ it('classifies a dangling project link as dead when the registry no longer has it', () => {
57
+ const dir = localSkillsDir('claude-code', { removed: path_1.default.join(registry, 'skills', 'removed') });
58
+ const out = (0, skill_integrity_1.classifySkillLinks)(dir, [registry]);
59
+ expect(out.dead).toEqual(['removed']);
60
+ expect(out.repairable).toEqual([]);
61
+ });
62
+ it('awm sync heals a project link the registry can still serve', () => {
63
+ const dir = localSkillsDir('claude-code', { alive: path_1.default.join(registry, 'moved', 'alive') });
64
+ const results = (0, skill_integrity_1.reconcileProjectSkillLinks)(projectRoot, ['claude-code'], [registry]);
65
+ expect(results.find(r => r.agent === 'claude-code').result.relinked).toEqual(['alive']);
66
+ expect(fs_1.default.existsSync(path_1.default.join(dir, 'alive', 'SKILL.md'))).toBe(true);
67
+ });
68
+ it('prunes a project link nothing can serve any more', () => {
69
+ const dir = localSkillsDir('claude-code', { removed: path_1.default.join(registry, 'skills', 'removed') });
70
+ const results = (0, skill_integrity_1.reconcileProjectSkillLinks)(projectRoot, ['claude-code'], [registry]);
71
+ expect(results.find(r => r.agent === 'claude-code').result.pruned).toEqual(['removed']);
72
+ expect(fs_1.default.existsSync(path_1.default.join(dir, 'removed'))).toBe(false);
73
+ expect(fs_1.default.lstatSync(dir).isDirectory()).toBe(true);
74
+ });
75
+ it('covers Copilot, whose skills are ONLY ever project-scope', () => {
76
+ expect((0, providers_1.providerFor)('copilot').skill.global).toBeNull(); // premise of this test
77
+ const dir = localSkillsDir('copilot', { alive: path_1.default.join(registry, 'moved', 'alive') });
78
+ const results = (0, skill_integrity_1.reconcileProjectSkillLinks)(projectRoot, ['copilot'], [registry]);
79
+ expect(results.find(r => r.agent === 'copilot').result.relinked).toEqual(['alive']);
80
+ expect(fs_1.default.existsSync(path_1.default.join(dir, 'alive', 'SKILL.md'))).toBe(true);
81
+ });
82
+ it('leaves the user\'s own files alone — only dangling symlinks are touched', () => {
83
+ const dir = localSkillsDir('claude-code', { alive: path_1.default.join(registry, 'skills', 'alive') });
84
+ fs_1.default.mkdirSync(path_1.default.join(dir, 'hand-written'));
85
+ fs_1.default.writeFileSync(path_1.default.join(dir, 'hand-written', 'SKILL.md'), '# mine\n');
86
+ const results = (0, skill_integrity_1.reconcileProjectSkillLinks)(projectRoot, ['claude-code'], [registry]);
87
+ const r = results.find(x => x.agent === 'claude-code').result;
88
+ expect(r.relinked).toEqual([]);
89
+ expect(r.pruned).toEqual([]);
90
+ expect(fs_1.default.readFileSync(path_1.default.join(dir, 'hand-written', 'SKILL.md'), 'utf-8')).toBe('# mine\n');
91
+ expect(fs_1.default.existsSync(path_1.default.join(dir, 'alive'))).toBe(true);
92
+ });
93
+ it('skips agents whose project skills dir does not exist', () => {
94
+ const results = (0, skill_integrity_1.reconcileProjectSkillLinks)(projectRoot, ['claude-code', 'copilot'], [registry]);
95
+ expect(results).toEqual([]);
96
+ });
97
+ });
98
+ // Wiring, not just capability: I15's actual defect was that the healing primitive
99
+ // already existed and nothing called it for project scope. A green unit test of the
100
+ // primitive would have looked identical before and after. So assert the call.
101
+ describe('awm sync reaches the project-scope reconciliation', () => {
102
+ let projectRoot;
103
+ let home;
104
+ let saved;
105
+ beforeEach(() => {
106
+ jest.resetModules();
107
+ home = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-syncwire-home-'));
108
+ saved = { HOME: process.env.HOME, AWM_HOME: process.env.AWM_HOME };
109
+ process.env.HOME = home;
110
+ process.env.AWM_HOME = path_1.default.join(home, '.awm');
111
+ fs_1.default.mkdirSync(path_1.default.join(home, '.awm'), { recursive: true });
112
+ fs_1.default.writeFileSync(path_1.default.join(home, '.awm', 'preferences.json'), JSON.stringify({
113
+ enabledAgents: ['claude-code'], defaultAgent: 'claude-code',
114
+ installMethod: 'symlink', defaultScope: 'global',
115
+ }));
116
+ projectRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-syncwire-proj-'));
117
+ fs_1.default.mkdirSync(path_1.default.join(projectRoot, '.awm'), { recursive: true });
118
+ });
119
+ afterEach(() => {
120
+ if (saved.HOME === undefined)
121
+ delete process.env.HOME;
122
+ else
123
+ process.env.HOME = saved.HOME;
124
+ if (saved.AWM_HOME === undefined)
125
+ delete process.env.AWM_HOME;
126
+ else
127
+ process.env.AWM_HOME = saved.AWM_HOME;
128
+ fs_1.default.rmSync(home, { recursive: true, force: true });
129
+ fs_1.default.rmSync(projectRoot, { recursive: true, force: true });
130
+ });
131
+ const writeProfile = (extensions) => fs_1.default.writeFileSync(path_1.default.join(projectRoot, '.awm', 'profile.json'), JSON.stringify({ extensions }));
132
+ async function sync(spy) {
133
+ const { runSyncCore } = require('../../src/commands/sync');
134
+ return runSyncCore({ cwd: projectRoot }, {
135
+ syncRegistries: async () => [],
136
+ verifyMinCliVersions: () => [],
137
+ verifyProjectPins: async () => [],
138
+ syncProfile: () => ({ installed: [], skipped: [], extensions: [], transactionIds: [], modifiedFiles: [] }),
139
+ reconcileProjectSkillLinks: spy,
140
+ });
141
+ }
142
+ it('reconciles project links on a normal sync', async () => {
143
+ writeProfile(['demo']);
144
+ const spy = jest.fn((_root, _agents, _roots) => []);
145
+ await sync(spy);
146
+ expect(spy).toHaveBeenCalledTimes(1);
147
+ expect(spy.mock.calls[0][0]).toBe(projectRoot);
148
+ expect(spy.mock.calls[0][1]).toEqual(['claude-code']);
149
+ });
150
+ it('reconciles project links even when the profile has no extensions left', async () => {
151
+ // The case that produces orphans in the first place: the extension that owned
152
+ // them was removed. That path used to return early, touching nothing.
153
+ writeProfile([]);
154
+ const spy = jest.fn((_root, _agents, _roots) => []);
155
+ const out = await sync(spy);
156
+ expect(out.code).toBe(0);
157
+ expect(spy).toHaveBeenCalledTimes(1);
158
+ });
159
+ });
@@ -42,24 +42,76 @@ describe('scanLegacyArtifacts', () => {
42
42
  process.env.AWM_HOME = originalAwmHome;
43
43
  fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
44
44
  });
45
- it('never lists Codex TOML agents for legacy removal', () => {
45
+ /** Registra artefactos como propiedad de AWM, que es lo que ahora habilita
46
+ * que `awm remove` los ofrezca. */
47
+ function own(records) {
48
+ const file = path_1.default.join(tmpHome, '.awm', 'state', 'artifacts.json');
49
+ fs_1.default.mkdirSync(path_1.default.dirname(file), { recursive: true });
50
+ fs_1.default.writeFileSync(file, JSON.stringify(records.map((r) => ({
51
+ ...r, scope: 'global', sourcePath: '/tmp/src', owners: ['codex'],
52
+ }))));
53
+ }
54
+ it('lista los artefactos renderizados que AWM instalo, incluidos los .toml de codex', () => {
55
+ // Este test asertaba lo contrario: que los agents TOML de codex NUNCA
56
+ // se listaran. Eso codificaba el bug — `scanLegacyArtifacts` se tragaba
57
+ // `UnsupportedRendererError`, asi que `awm remove` era un no-op
58
+ // permanente para los skills de cursor/copilot y los agents de codex
59
+ // ("no hay artefactos instalados", con el directorio lleno).
60
+ // Un artefacto que AWM instalo tiene que poder desinstalarse.
46
61
  const skillPath = path_1.default.join(tmpHome, '.agents/skills/linked-skill');
47
- const agentPath = path_1.default.join(tmpHome, '.codex/agents/protected.toml');
62
+ const agentPath = path_1.default.join(tmpHome, '.codex/agents/rendered.toml');
48
63
  fs_1.default.mkdirSync(skillPath, { recursive: true });
49
64
  fs_1.default.mkdirSync(path_1.default.dirname(agentPath), { recursive: true });
50
- fs_1.default.writeFileSync(agentPath, 'name = "protected"\n');
65
+ fs_1.default.writeFileSync(agentPath, 'name = "rendered"\n');
66
+ own([
67
+ { name: 'linked-skill', type: 'skill', targetPath: skillPath, renderer: 'link' },
68
+ { name: 'rendered.toml', type: 'agent', targetPath: agentPath, renderer: 'codex-agent-toml' },
69
+ ]);
51
70
  const listed = (0, provider_artifacts_1.scanLegacyArtifacts)(['codex'], 'global');
52
- const legacyRemove = jest.fn((target) => fs_1.default.rmSync(target, {
53
- recursive: true,
54
- force: true,
55
- }));
56
- for (const artifact of listed) {
57
- for (const fullPath of artifact.fullPaths)
58
- legacyRemove(fullPath);
59
- }
60
- expect(listed.map((artifact) => artifact.name)).toEqual(['linked-skill']);
61
- expect(legacyRemove).not.toHaveBeenCalledWith(agentPath);
62
- expect(fs_1.default.existsSync(agentPath)).toBe(true);
71
+ expect(listed.map((a) => a.name).sort()).toEqual(['linked-skill', 'rendered.toml']);
72
+ });
73
+ it('NO lista archivos que AWM no instalo — el directorio no es suyo para ofrecerlo', () => {
74
+ // Antes listaba el directorio entero, asi que un skill escrito a mano
75
+ // por el usuario aparecia en el menu de borrado.
76
+ const mine = path_1.default.join(tmpHome, '.agents/skills/awm-skill');
77
+ const theirs = path_1.default.join(tmpHome, '.agents/skills/mi-skill-personal');
78
+ fs_1.default.mkdirSync(mine, { recursive: true });
79
+ fs_1.default.mkdirSync(theirs, { recursive: true });
80
+ own([{ name: 'awm-skill', type: 'skill', targetPath: mine, renderer: 'link' }]);
81
+ const listed = (0, provider_artifacts_1.scanLegacyArtifacts)(['codex'], 'global');
82
+ expect(listed.map((a) => a.name)).toEqual(['awm-skill']);
83
+ expect(listed.flatMap((a) => a.fullPaths)).not.toContain(theirs);
84
+ });
85
+ it('no colapsa dos tipos distintos que comparten nombre (borraria el equivocado)', () => {
86
+ // Antes la clave era solo el nombre, a traves de todos los tipos: un
87
+ // workflow `deploy.md` y un agent `deploy.md` colapsaban en UNA entrada,
88
+ // asi que elegir "deploy.md" borraba ambos.
89
+ const wf = path_1.default.join(tmpHome, '.gemini/antigravity/global_workflows/deploy.md');
90
+ const ag = path_1.default.join(tmpHome, '.claude/agents/deploy.md');
91
+ fs_1.default.mkdirSync(path_1.default.dirname(wf), { recursive: true });
92
+ fs_1.default.mkdirSync(path_1.default.dirname(ag), { recursive: true });
93
+ fs_1.default.writeFileSync(wf, 'wf');
94
+ fs_1.default.writeFileSync(ag, 'ag');
95
+ own([
96
+ { name: 'deploy.md', type: 'workflow', targetPath: wf, renderer: 'link' },
97
+ { name: 'deploy.md', type: 'agent', targetPath: ag, renderer: 'link' },
98
+ ]);
99
+ const listed = (0, provider_artifacts_1.scanLegacyArtifacts)(['antigravity', 'claude-code'], 'global');
100
+ expect(listed).toHaveLength(2);
101
+ for (const a of listed)
102
+ expect(a.fullPaths).toHaveLength(1);
103
+ });
104
+ it('scope local se resuelve contra el projectRoot dado, no contra cwd', () => {
105
+ const projectRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-proj-'));
106
+ const local = path_1.default.join(projectRoot, '.claude/skills/local-skill');
107
+ fs_1.default.mkdirSync(local, { recursive: true });
108
+ own([{ name: 'local-skill', type: 'skill', targetPath: local, renderer: 'link' }]);
109
+ const listed = (0, provider_artifacts_1.scanLegacyArtifacts)(['claude-code'], 'local', projectRoot);
110
+ expect(listed.map((a) => a.name)).toEqual(['local-skill']);
111
+ // Y la ruta entregada es ABSOLUTA: antes era relativa, y se la pasaba
112
+ // tal cual a fs.rmSync.
113
+ expect(path_1.default.isAbsolute(listed[0].fullPaths[0])).toBe(true);
114
+ fs_1.default.rmSync(projectRoot, { recursive: true, force: true });
63
115
  });
64
116
  it('Gap C — an agent with a null global skill dir (copilot) at global scope skips cleanly instead of crashing', () => {
65
117
  // Copilot's skill.global is null (no user-level skill discovery mechanism —
@@ -0,0 +1,92 @@
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
+ // Regresion: un provider que estructuralmente NO puede recibir un artefacto en
7
+ // cierto scope (hoy: copilot en global — no tiene descubrimiento de skills a
8
+ // nivel usuario) hacia TIRAR el plan entero, rompiendo el comando para todos
9
+ // los demas agentes de la maquina.
10
+ //
11
+ // Sintoma confirmado contra el binario real:
12
+ // awm init -a claude-code -y && awm init -a copilot -y && awm update
13
+ // → Artifact reconciliation failed: skill global scope is not supported by Copilot
14
+ // exit=1
15
+ // El comando moria ANTES del re-sync de hooks, asi que claude-code dejaba de
16
+ // recibir actualizaciones — y no existe comando para deshabilitar un agente,
17
+ // mientras `awm doctor` seguia diciendo "healthy".
18
+ //
19
+ // La causa era una asimetria de guarda clasica: el `.filter()` interno de
20
+ // `assertCompleteSharedGroup` ya toleraba el caso, pero la linea de arriba
21
+ // —que corre primero— no.
22
+ const path_1 = __importDefault(require("path"));
23
+ const install_planner_1 = require("../../src/core/install-planner");
24
+ const skill = {
25
+ name: 'using-awm', type: 'skill', installName: 'using-awm', sourcePath: '/tmp/reg/skills/using-awm',
26
+ };
27
+ describe('planInstall: un provider incapaz no puede romper el plan de los demas', () => {
28
+ it('scope global con copilot habilitado: planifica para los capaces y saltea copilot', () => {
29
+ const plan = (0, install_planner_1.planInstall)({
30
+ artifacts: [skill],
31
+ selectedAgents: ['claude-code', 'copilot'],
32
+ enabledAgents: ['claude-code', 'copilot'],
33
+ scope: 'global',
34
+ projectRoot: '/tmp/proj',
35
+ method: 'symlink',
36
+ });
37
+ const owners = plan.operations.flatMap((op) => op.owners);
38
+ expect(owners).toContain('claude-code');
39
+ expect(owners).not.toContain('copilot');
40
+ });
41
+ it('no tira aunque copilot este habilitado pero no seleccionado (el caso de awm update)', () => {
42
+ expect(() => (0, install_planner_1.planInstall)({
43
+ artifacts: [skill],
44
+ selectedAgents: ['claude-code'],
45
+ enabledAgents: ['claude-code', 'copilot'],
46
+ scope: 'global',
47
+ projectRoot: '/tmp/proj',
48
+ method: 'symlink',
49
+ })).not.toThrow();
50
+ });
51
+ it('peticion EXPLICITA de copilot en global: sigue tirando CON la explicacion', () => {
52
+ // Aca no hay a quien saltear — el usuario pidio justo esa combinacion, y
53
+ // lo que necesita es leer por que no se puede, no un exito silencioso.
54
+ expect(() => (0, install_planner_1.planInstall)({
55
+ artifacts: [skill],
56
+ selectedAgents: ['copilot'],
57
+ enabledAgents: ['copilot'],
58
+ scope: 'global',
59
+ projectRoot: '/tmp/proj',
60
+ method: 'symlink',
61
+ })).toThrow(/per-project|not supported/i);
62
+ });
63
+ it('copilot en scope local SI recibe el artefacto (su unico scope valido)', () => {
64
+ const plan = (0, install_planner_1.planInstall)({
65
+ artifacts: [skill],
66
+ selectedAgents: ['copilot'],
67
+ enabledAgents: ['copilot'],
68
+ scope: 'local',
69
+ projectRoot: '/tmp/proj',
70
+ method: 'symlink',
71
+ });
72
+ expect(plan.operations).toHaveLength(1);
73
+ // `path.join`, no un literal con '/': `physicalTarget` construye la ruta con el
74
+ // separador de la plataforma, asi que en Windows el destino real es
75
+ // `.github\instructions` y la asercion POSIX-hardcodeada fallaba sobre un
76
+ // destino perfectamente correcto.
77
+ expect(plan.operations[0].targetPath).toContain(path_1.default.join('.github', 'instructions'));
78
+ expect(plan.operations[0].targetPath).toContain('using-awm.instructions.md');
79
+ });
80
+ it('el grupo compartido (opencode + codex) se sigue exigiendo completo', () => {
81
+ // La guarda no debe aflojar R14: ambos resuelven a ~/.agents/skills, asi
82
+ // que seleccionar solo uno seguiria siendo un cambio divergente.
83
+ expect(() => (0, install_planner_1.planInstall)({
84
+ artifacts: [skill],
85
+ selectedAgents: ['codex'],
86
+ enabledAgents: ['codex', 'opencode'],
87
+ scope: 'global',
88
+ projectRoot: '/tmp/proj',
89
+ method: 'symlink',
90
+ })).toThrow(/shared skill target/i);
91
+ });
92
+ });
@@ -138,10 +138,18 @@ describe('planReconciliation (real, unmocked)', () => {
138
138
  // points nowhere — resolveBundleClosure just drops the missing dep.
139
139
  expect(names).toEqual(['ambient-skill', 'brainstorming', 'dangling-skill']);
140
140
  });
141
- it('skips a bundle whose closure genuinely fails to resolve, without aborting reconciliation for the rest', () => {
142
- // Malformed dependsOn (not an array) makes resolveBundleClosure throw
143
- // when it tries to iterate it a real "unresolvable closure" error,
144
- // unlike a plain dangling name reference (see test above).
141
+ it('un dependsOn malformado ya no rompe nada: el campo invalido se ignora y el bundle instala', () => {
142
+ // Este test asertaba que un `dependsOn` no-array hacia TIRAR a
143
+ // `resolveBundleClosure` y que reconciliation lo salteaba. Desde que
144
+ // `discoverBundles` valida forma sobre el contenido del registry (que es
145
+ // input no confiable), ese campo se normaliza a `[]` en vez de
146
+ // propagarse hasta un `for...of` sobre un numero. O sea: la entrada ya
147
+ // no falla, y el bundle instala normalmente ignorando el campo roto.
148
+ //
149
+ // Es una mejora, no una regresion — antes UN archivo malformado en
150
+ // cualquier registry tumbaba `awm list`/`awm add` con un stack trace
151
+ // crudo. El `catch` de reconciliation sigue en su lugar como defensa en
152
+ // profundidad, pero esta entrada ya no lo ejercita.
145
153
  writeBundle(contentRoot, 'broken', 'baseline', ['broken-skill'], 5);
146
154
  addToCatalog(contentRoot, 'broken', 'baseline');
147
155
  const { planReconciliation } = require('../../src/core/reconciliation');
@@ -149,17 +157,9 @@ describe('planReconciliation (real, unmocked)', () => {
149
157
  expect(() => {
150
158
  plan = planReconciliation({ targets: ['claude-code'], roots: [contentRoot] });
151
159
  }).not.toThrow();
152
- // The healthy bundles (baseline 'dev' + ambient 'ambient-x') still
153
- // reconcile correctly.
154
160
  const names = plan.operations.map((op) => op.name).sort();
155
- expect(names).toEqual(['ambient-skill', 'brainstorming']);
156
- // The broken bundle is cleanly absent no partial/broken entry for
157
- // 'broken-skill' or any operation touching it.
158
- expect(names).not.toContain('broken-skill');
159
- expect(plan.operations.some((op) => op.sourcePath?.includes('broken'))).toBe(false);
160
- // Applying the plan end-to-end still works (no dangling reference to
161
- // the broken bundle leaked into the plan).
162
- const { applyInstallPlan } = require('../../src/core/install-transaction');
163
- expect(() => applyInstallPlan(plan)).not.toThrow();
161
+ // Los bundles sanos siguen reconciliando, y el que tenia el campo roto
162
+ // tambiensin su dependencia invalida.
163
+ expect(names).toEqual(['ambient-skill', 'brainstorming', 'broken-skill']);
164
164
  });
165
165
  });
@@ -69,7 +69,7 @@ describe('registry manifest (awm-registry.json)', () => {
69
69
  });
70
70
  it('registryNameForPath maps configured registries and returns null for unknown paths', () => {
71
71
  const m = load();
72
- const regPath = path_1.default.join(m.REGISTRIES_DIR, 'team-acme', 'skills', 'x');
72
+ const regPath = path_1.default.join(m.registriesDir(), 'team-acme', 'skills', 'x');
73
73
  expect(m.registryNameForPath(regPath)).toBe('team-acme');
74
74
  expect(m.registryNameForPath('/somewhere/else')).toBeNull();
75
75
  });
@@ -46,6 +46,42 @@ it('ignores provider-only mode while retaining canonical instructions', () => {
46
46
  instructions: expect.stringContaining('You do NOT write code directly.'),
47
47
  }); // verifies R8, R9
48
48
  });
49
+ it('acepta una description en block scalar y la resuelve (regresion: rompia awm add -a codex)', () => {
50
+ // El parser exigia que TODA linea del frontmatter fuera `clave: valor`, asi
51
+ // que las lineas indentadas de un bloque lanzaban "invalid canonical agent
52
+ // frontmatter line" y abortaban el install de codex. Peor: discovery lee el
53
+ // MISMO archivo y (ya arreglado) mostraba la descripcion bien en el picker,
54
+ // asi que el crash llegaba despues de que la UI dijera que todo estaba OK.
55
+ const source = [
56
+ '---',
57
+ 'name: bloque-agente',
58
+ 'description: >-',
59
+ ' Primera linea de la descripcion',
60
+ ' y su continuacion.',
61
+ '---',
62
+ 'Cuerpo de instrucciones.',
63
+ ].join('\n');
64
+ expect((0, canonical_agent_1.parseCanonicalAgent)(source)).toEqual({
65
+ name: 'bloque-agente',
66
+ description: 'Primera linea de la descripcion y su continuacion.',
67
+ instructions: 'Cuerpo de instrucciones.',
68
+ });
69
+ });
70
+ it('acepta un escalar plano multilinea y lo pliega (js-yaml lee exactamente esto)', () => {
71
+ // Ojo: una version previa de este test exigia un throw aca. Estaba mal —
72
+ // `description: x` seguido de una linea indentada es un escalar plano
73
+ // multilinea, YAML perfectamente valido, y js-yaml lo lee como
74
+ // "x suelta e indentada". Asertar el throw codificaba una divergencia con
75
+ // YAML, no un contrato.
76
+ const source = '---\nname: ok\ndescription: x\n suelta e indentada\n---\nbody';
77
+ expect((0, canonical_agent_1.parseCanonicalAgent)(source).description).toBe('x suelta e indentada');
78
+ });
79
+ it('sigue rechazando una linea que no es ni clave ni continuacion indentada', () => {
80
+ // El fix no afloja la validacion estricta: una linea en columna 0 que no es
81
+ // `clave: valor` sigue siendo frontmatter malformado.
82
+ const source = '---\nname: ok\ndescription: x\nno soy un campo\n---\nbody';
83
+ expect(() => (0, canonical_agent_1.parseCanonicalAgent)(source)).toThrow('invalid canonical agent frontmatter line');
84
+ });
49
85
  it.each([
50
86
  ['---\nname: Bad Name\ndescription: x\n---\nbody', 'invalid agent name'],
51
87
  ['---\nname: ok\ndescription:\n---\nbody', 'non-empty description'],
@@ -49,11 +49,18 @@ Body content.
49
49
  const rendered = (0, cursor_mdc_1.renderCursorMdc)(source);
50
50
  expect(rendered).toContain('description: "*starred description"');
51
51
  });
52
- it('throws rather than embedding a literal block-scalar indicator as the description', () => {
53
- // Regression: a YAML block scalar (`description: >-` / `|-`) means the
54
- // real text lives on the FOLLOWING indented lines, not on this line —
55
- // the original code took the bare indicator itself as the description,
56
- // which would have rendered the literal string ">-" into the .mdc file.
52
+ it('resuelve un block scalar leyendo sus lineas indentadas, sin emitir el indicador literal', () => {
53
+ // Regression, en dos etapas:
54
+ // 1. La version original tomaba el indicador (`>-`) COMO la descripcion,
55
+ // lo que habria escrito el literal ">-" dentro del .mdc.
56
+ // 2. El remedio siguiente lo detectaba pero lo colapsaba a '' (=> throw),
57
+ // tratando una forma YAML valida como "descripcion ausente" — este
58
+ // test codificaba ESE comportamiento parcial. Rompia `awm add
59
+ // <bundle> -a cursor` contra un skill real del registry baseline
60
+ // (extract-design-md), que usa exactamente esta forma.
61
+ // El contrato correcto, y el que se asserta ahora: leer el texto de las
62
+ // lineas indentadas. La intencion original del test — jamas emitir el
63
+ // indicador crudo — se conserva explicitamente abajo.
57
64
  const source = `---
58
65
  name: block-skill
59
66
  description: >-
@@ -63,16 +70,27 @@ description: >-
63
70
 
64
71
  Body content.
65
72
  `;
66
- expect(() => (0, cursor_mdc_1.renderCursorMdc)(source)).toThrow('non-empty description');
73
+ const rendered = (0, cursor_mdc_1.renderCursorMdc)(source);
74
+ expect(rendered).toContain('description: This description spans multiple lines.');
75
+ expect(rendered).not.toContain('>-');
67
76
  });
68
77
  it('quotes a description containing a mid-string " #" (starts a YAML comment, truncating the rest)', () => {
69
78
  // Regression: the original YAML_UNSAFE regex only caught `#` at the START
70
79
  // of the string — a `#` preceded by whitespace ANYWHERE in a plain scalar
71
80
  // also starts a comment. Unquoted, "Use this #important skill" would
72
81
  // render as YAML that silently truncates to "Use this".
82
+ //
83
+ // El fuente esta ENTRECOMILLADO a proposito: ahi el `#` es literal y
84
+ // sobrevive al parseo, que es la unica manera de que un `#` llegue al
85
+ // renderer y haya algo que escapar. Antes el fixture usaba la forma SIN
86
+ // comillas, pero YAML dice que ahi ` #` abre un comentario — el lector lo
87
+ // trataba como texto (divergencia con cualquier parser real) y este test
88
+ // se apoyaba en esa divergencia. El contrato que el test realmente
89
+ // protege — nunca emitir un `#` sin comillas en la SALIDA — queda intacto
90
+ // y ahora se ejercita con una entrada que de verdad lo contiene.
73
91
  const source = `---
74
92
  name: hash-skill
75
- description: Use this #important skill
93
+ description: "Use this #important skill"
76
94
  ---
77
95
 
78
96
  Body content.
@@ -81,6 +99,18 @@ Body content.
81
99
  expect(rendered).toContain('description: "Use this #important skill"');
82
100
  expect(rendered).not.toContain('description: Use this #important skill');
83
101
  });
102
+ it('trata un " #" en una description SIN comillas como comentario YAML, igual que un parser real', () => {
103
+ const source = `---
104
+ name: hash-plano
105
+ description: Use this #important skill
106
+ ---
107
+
108
+ Body content.
109
+ `;
110
+ // js-yaml devuelve "Use this" para esta entrada: el comentario no es parte
111
+ // del valor. El renderer debe coincidir con esa lectura, no inventar texto.
112
+ expect((0, cursor_mdc_1.renderCursorMdc)(source)).toContain('description: Use this\n');
113
+ });
84
114
  it('quotes a description containing an embedded null byte / control character instead of emitting it raw', () => {
85
115
  // Regression: an embedded control/null byte is invalid in a YAML plain
86
116
  // scalar regardless of position — the original code's YAML_UNSAFE regex
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ // Regresion: `description: >-` (YAML block scalar) rompia `awm add <bundle>
4
+ // -a copilot|cursor` contra un skill REAL del registry baseline
5
+ // (skills/extract-design-md/SKILL.md, que usa exactamente esta forma).
6
+ //
7
+ // Un block scalar es YAML perfectamente valido: el indicador (`>-`, `|`, ...)
8
+ // va en la linea de la clave y el texto real vive en las lineas indentadas
9
+ // que siguen. El parser trataba el indicador como si fuera el valor, lo
10
+ // detectaba como "no es una descripcion de verdad", y lo colapsaba a '' —
11
+ // que en `parseSkillSource` significa throw ("requires a non-empty
12
+ // description") y en `readArtifactDescription` significa degradar en
13
+ // silencio a descripcion vacia. Ninguno de los dos leia jamas las lineas
14
+ // siguientes, que es donde estaba el texto todo el tiempo.
15
+ const skill_source_1 = require("../../../src/core/renderers/skill-source");
16
+ const cursor_mdc_1 = require("../../../src/core/renderers/cursor-mdc");
17
+ const copilot_instructions_1 = require("../../../src/core/renderers/copilot-instructions");
18
+ // Copia fiel del frontmatter de skills/extract-design-md/SKILL.md en
19
+ // awm-baseline-registry — el caso real que dispara el bug, no un fixture
20
+ // inventado.
21
+ const realBlockScalarSkill = `---
22
+ name: extract-design-md
23
+ version: "1.0.1"
24
+ description: >-
25
+ Extract a comprehensive design system (DESIGN.md) directly from frontend source
26
+ code — React, Vue, Svelte, Angular, plain HTML/CSS, or any web framework.
27
+ ---
28
+
29
+ # Extract Design MD
30
+
31
+ Body content.
32
+ `;
33
+ describe('parseSkillSource: descripciones en block scalar (regresion real del registry)', () => {
34
+ it('lee el texto de las lineas indentadas de un `>-`, en vez de tratar el indicador como el valor', () => {
35
+ const { description } = (0, skill_source_1.parseSkillSource)(realBlockScalarSkill);
36
+ expect(description).toBe('Extract a comprehensive design system (DESIGN.md) directly from frontend source ' +
37
+ 'code — React, Vue, Svelte, Angular, plain HTML/CSS, or any web framework.');
38
+ });
39
+ it('pliega (folded, `>`) las lineas en espacios — no conserva los saltos de linea del fuente', () => {
40
+ const source = `---
41
+ name: folded
42
+ description: >
43
+ primera linea
44
+ segunda linea
45
+ ---
46
+
47
+ Body.
48
+ `;
49
+ expect((0, skill_source_1.parseSkillSource)(source).description).toBe('primera linea segunda linea');
50
+ });
51
+ it('conserva los saltos de linea de un literal (`|`) — semantica YAML distinta de `>`', () => {
52
+ const source = `---
53
+ name: literal
54
+ description: |
55
+ primera linea
56
+ segunda linea
57
+ ---
58
+
59
+ Body.
60
+ `;
61
+ expect((0, skill_source_1.parseSkillSource)(source).description).toBe('primera linea\nsegunda linea');
62
+ });
63
+ it('trata una linea en blanco dentro de un folded como separador de parrafo, no como espacio', () => {
64
+ const source = `---
65
+ name: folded-parrafos
66
+ description: >-
67
+ parrafo uno
68
+
69
+ parrafo dos
70
+ ---
71
+
72
+ Body.
73
+ `;
74
+ expect((0, skill_source_1.parseSkillSource)(source).description).toBe('parrafo uno\nparrafo dos');
75
+ });
76
+ it('no absorbe las claves siguientes del frontmatter como parte del bloque (corta en la primera linea no indentada)', () => {
77
+ const source = `---
78
+ description: >-
79
+ solo esto pertenece al bloque
80
+ name: no-soy-parte-del-bloque
81
+ version: "9.9.9"
82
+ ---
83
+
84
+ Body.
85
+ `;
86
+ expect((0, skill_source_1.parseSkillSource)(source).description).toBe('solo esto pertenece al bloque');
87
+ });
88
+ it('resuelve el bloque igual con line endings CRLF (el repo es CRLF-tolerante y corre CI en windows-latest)', () => {
89
+ const source = '---\r\nname: crlf\r\ndescription: >-\r\n linea uno\r\n linea dos\r\n---\r\n\r\nBody aqui.\r\n';
90
+ expect((0, skill_source_1.parseSkillSource)(source).description).toBe('linea uno linea dos');
91
+ });
92
+ it('sigue lanzando si el bloque esta genuinamente vacio — un indicador sin lineas indentadas NO es una descripcion', () => {
93
+ const source = `---
94
+ name: bloque-vacio
95
+ description: >-
96
+ name: otra-clave
97
+ ---
98
+
99
+ Body.
100
+ `;
101
+ expect(() => (0, skill_source_1.parseSkillSource)(source)).toThrow(/non-empty description/);
102
+ });
103
+ });
104
+ describe('renderers: el skill real del registry se renderiza para ambos providers', () => {
105
+ it('renderCursorMdc emite la descripcion real, nunca el indicador `>-` literal', () => {
106
+ const rendered = (0, cursor_mdc_1.renderCursorMdc)(realBlockScalarSkill);
107
+ expect(rendered).toContain('Extract a comprehensive design system');
108
+ // El modo de fallo que este test ancla: emitir el indicador crudo.
109
+ expect(rendered).not.toContain('description: >-');
110
+ expect(rendered).not.toContain('description: ""');
111
+ });
112
+ it('renderCopilotInstructions no crashea (no necesita la descripcion, pero parseSkillSource la exigia igual)', () => {
113
+ const rendered = (0, copilot_instructions_1.renderCopilotInstructions)(realBlockScalarSkill);
114
+ expect(rendered).toContain('applyTo: "**"');
115
+ expect(rendered).toContain('# Extract Design MD');
116
+ });
117
+ });