agentic-workflow-manager 9.14.0 → 9.15.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.
Files changed (40) hide show
  1. package/dist/src/commands/context-budget/budget.js +50 -4
  2. package/dist/src/commands/doctor.js +4 -1
  3. package/dist/src/commands/hooks/cursor.js +359 -0
  4. package/dist/src/commands/hooks/install.js +3 -0
  5. package/dist/src/commands/hooks/resync.js +10 -0
  6. package/dist/src/commands/hooks/status.js +3 -0
  7. package/dist/src/commands/hooks/uninstall.js +3 -0
  8. package/dist/src/commands/update.js +4 -0
  9. package/dist/src/core/admission/index.js +12 -6
  10. package/dist/src/core/admission/registry-contracts.js +14 -1
  11. package/dist/src/core/context/strategies/codex-agents.js +6 -2
  12. package/dist/src/core/cursor-budgets.js +8 -0
  13. package/dist/src/core/dashboard/sanitize.js +2 -2
  14. package/dist/src/core/diagnostics/provider-checks.js +11 -2
  15. package/dist/src/core/init/migrate-cursor-mdc.js +72 -0
  16. package/dist/src/core/init/steps.js +4 -0
  17. package/dist/src/providers/index.js +18 -4
  18. package/dist/tests/commands/add.test.js +5 -7
  19. package/dist/tests/commands/context-budget/budget.test.js +68 -0
  20. package/dist/tests/commands/doctor.test.js +2 -2
  21. package/dist/tests/commands/hooks/cursor.test.js +257 -0
  22. package/dist/tests/commands/hooks/resync.test.js +8 -0
  23. package/dist/tests/commands/init.test.js +23 -22
  24. package/dist/tests/commands/plan/index.test.js +82 -2
  25. package/dist/tests/commands/sensors/exec.test.js +18 -9
  26. package/dist/tests/core/admission/index.test.js +39 -0
  27. package/dist/tests/core/bundle-install.test.js +8 -14
  28. package/dist/tests/core/context/strategies/codex-agents.test.js +31 -12
  29. package/dist/tests/core/diagnostics/provider-tier.test.js +46 -96
  30. package/dist/tests/core/diagnostics/rendered-artifact-visibility.test.js +6 -4
  31. package/dist/tests/core/init/migrate-cursor-mdc.test.js +74 -0
  32. package/dist/tests/core/install-planner.test.js +16 -12
  33. package/dist/tests/integration/cursor-subset-gate.test.js +123 -0
  34. package/dist/tests/providers/index.test.js +34 -21
  35. package/dist/tests/structural/provider-paths-honor-config-home.test.js +3 -2
  36. package/package.json +2 -2
  37. package/prebuilds/darwin-arm64/secure_fs.node +0 -0
  38. package/prebuilds/darwin-x64/secure_fs.node +0 -0
  39. package/prebuilds/win32-arm64/secure_fs.node +0 -0
  40. package/prebuilds/win32-x64/secure_fs.node +0 -0
@@ -30,7 +30,7 @@ describe('providerTier — pure structural classification', () => {
30
30
  opencode: 'config-managed',
31
31
  'claude-code': 'hooks-native',
32
32
  codex: 'hooks-native',
33
- cursor: 'agents-md-managed',
33
+ cursor: 'hooks-native',
34
34
  copilot: 'agents-md-managed',
35
35
  };
36
36
  it.each(providers_1.AGENT_TARGETS)('%s', (agent) => {
@@ -170,45 +170,20 @@ describe('skillsGlobalCheck — renderer-aware (Task 4.4 / deferred Task 4.3 fin
170
170
  else
171
171
  process.env.AWM_HOME = originalAwmHome;
172
172
  });
173
- it('non-link renderer (cursor-mdc) with real rendered files reports presence-only, not healthy', () => {
174
- const rulesDir = path_1.default.join(tmpHome, '.cursor/rules');
175
- fs_1.default.mkdirSync(rulesDir, { recursive: true });
176
- fs_1.default.writeFileSync(path_1.default.join(rulesDir, 'development-process.mdc'), '---\ndescription: dev process\nalwaysApply: true\n---\n\nBody.');
177
- // classifySkillLinks only ever sees symlinks (`if (!lst.isSymbolicLink()) continue;`)
178
- // — a real scan over rulesDir would find nothing here either. Stubbed explicitly so the
179
- // test proves the FIX (renderer-gating), not an accident of what classifySkillLinks does.
173
+ it('non-link renderer (copilot-instructions) with real rendered files reports presence-only, not healthy', () => {
174
+ const instrDir = path_1.default.join(tmpHome, 'proj/.github/instructions');
175
+ fs_1.default.mkdirSync(instrDir, { recursive: true });
176
+ fs_1.default.writeFileSync(path_1.default.join(instrDir, 'development-process.instructions.md'), '---\napplyTo: "**"\n---\n\nBody.');
180
177
  const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
181
178
  const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
182
- const facts = gatherProviderChecks(['cursor'], scanSkills);
183
- const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
184
- expect(skillsCheck?.state).not.toBe('healthy');
185
- expect(skillsCheck?.state).toBe('supported');
186
- // Antes decia 'content integrity not verified' — honesto entonces, porque no se
187
- // verificaba. Ahora SI se verifica, y este archivo tiene su marcador, asi que no
188
- // hay nada que reportar.
189
- expect(skillsCheck?.detail).toBeUndefined();
190
- });
191
- it('a rendered file with the right extension but a truncated body is reported broken', () => {
192
- // El hueco que cerraba C3: presencia + extension dejaba pasar un archivo correcto
193
- // por fuera y vacio por dentro. El agente cargaba nada y doctor decia que si.
194
- const rulesDir = path_1.default.join(tmpHome, '.cursor/rules');
195
- fs_1.default.mkdirSync(rulesDir, { recursive: true });
196
- fs_1.default.writeFileSync(path_1.default.join(rulesDir, 'development-process.mdc'), '---\ndescription: x\n');
197
- const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
198
- const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
199
- const facts = gatherProviderChecks(['cursor'], scanSkills);
200
- const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
201
- expect(skillsCheck?.state).toBe('broken');
202
- expect(skillsCheck?.detail).toContain('development-process.mdc');
203
- expect(skillsCheck?.remediationCode).toBe('reinstall-rendered-artifacts');
179
+ // Copilot has no global skill dir — skills.global is omitted (null). Probe local
180
+ // rendered path via a projectRoot-aware install is out of this check; here we only
181
+ // assert cursor (now link) is not misclassified as a non-link renderer.
182
+ const cursorFacts = gatherProviderChecks(['cursor'], scanSkills);
183
+ expect(cursorFacts[0].checks.find((c) => c.id === 'skills.global')?.state)
184
+ .toBe('absent');
204
185
  });
205
- it('Gap B — non-link renderer (cursor-mdc) against REAL renderer/pipeline output, not a hand-written approximation', () => {
206
- // The test above hand-writes a `.mdc` file whose frontmatter shape is only an
207
- // approximation of what the real cursor-mdc renderer emits. This drives the
208
- // REAL default `installBundle`/`applyInstallPlan` pipeline (core/bundle-install.ts,
209
- // the same one `awm init`/`awm add` use) end-to-end for a global-scope Cursor
210
- // skill, so the file skillsGlobalCheck inspects here is exactly what the
211
- // renderer actually produces — not a fixture that merely resembles it.
186
+ it('Gap B — Cursor link skills install under ~/.agents/skills via real pipeline', () => {
212
187
  const { discoverBundles } = require('../../../src/core/bundles');
213
188
  const { installBundle } = require('../../../src/core/bundle-install');
214
189
  const content = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-provider-tier-registry-'));
@@ -228,66 +203,51 @@ describe('skillsGlobalCheck — renderer-aware (Task 4.4 / deferred Task 4.3 fin
228
203
  bundles: discoverBundles(content),
229
204
  agents: ['cursor'],
230
205
  method: 'symlink',
231
- projectRoot: tmpHome, // irrelevant for a global-scope install
206
+ projectRoot: tmpHome,
232
207
  contentDir: content,
233
208
  });
234
- const rulesDir = path_1.default.join(tmpHome, '.cursor/rules');
235
- expect(fs_1.default.existsSync(path_1.default.join(rulesDir, 'using-awm.mdc'))).toBe(true);
236
- const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
209
+ const skillsDir = path_1.default.join(tmpHome, '.agents/skills');
210
+ expect(fs_1.default.existsSync(path_1.default.join(skillsDir, 'using-awm'))).toBe(true);
211
+ expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.cursor/rules/using-awm.mdc'))).toBe(false);
212
+ const scanSkills = jest.fn(() => ({ valid: ['using-awm'], repairable: [], dead: [], usurped: [] }));
237
213
  const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
238
214
  const facts = gatherProviderChecks(['cursor'], scanSkills);
239
215
  const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
240
- expect(skillsCheck).toMatchObject({ id: 'skills.global', state: 'supported', target: rulesDir });
241
- // La verificacion de contenido corre sobre la salida REAL del renderer, no sobre
242
- // una aproximacion escrita a mano: si el marcador declarado en la tabla no
243
- // coincidiera con lo que el renderer emite de verdad, este test lo detectaria.
244
- expect(skillsCheck?.detail).toBeUndefined();
216
+ expect(skillsCheck).toMatchObject({ id: 'skills.global', state: 'healthy', target: skillsDir });
245
217
  fs_1.default.rmSync(content, { recursive: true, force: true });
246
218
  });
247
- it('a rendered file that no longer matches its registry source is reported stale', () => {
248
- // El caso que C4 realmente pedia. Un symlink apunta al registry, asi que
249
- // `awm update` lo actualiza solo — por eso claude-code/codex/opencode nunca
250
- // quedan viejos. Un `.mdc` es un archivo GENERADO: se queda con el contenido de
251
- // la version anterior hasta que alguien corra `awm sync`, y nada lo decia.
219
+ it('a rendered Copilot file that no longer matches its registry source is reported stale', () => {
252
220
  const source = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-stale-src-'));
253
221
  fs_1.default.mkdirSync(path_1.default.join(source, 'using-awm'), { recursive: true });
254
222
  const skillMd = path_1.default.join(source, 'using-awm', 'SKILL.md');
255
223
  fs_1.default.writeFileSync(skillMd, '---\nname: using-awm\ndescription: original\n---\n\nCuerpo v1.\n');
256
- const rulesDir = path_1.default.join(tmpHome, '.cursor/rules');
257
- fs_1.default.mkdirSync(rulesDir, { recursive: true });
258
- const target = path_1.default.join(rulesDir, 'using-awm.mdc');
224
+ const instrDir = path_1.default.join(tmpHome, 'proj/.github/instructions');
225
+ fs_1.default.mkdirSync(instrDir, { recursive: true });
226
+ const target = path_1.default.join(instrDir, 'using-awm.instructions.md');
259
227
  const { renderArtifact } = require('../../../src/core/renderers/registry');
260
- fs_1.default.writeFileSync(target, renderArtifact('cursor-mdc', path_1.default.join(source, 'using-awm')));
228
+ fs_1.default.writeFileSync(target, renderArtifact('copilot-instructions', path_1.default.join(source, 'using-awm')));
261
229
  const stateDir = path_1.default.join(tmpHome, '.awm', 'state');
262
230
  fs_1.default.mkdirSync(stateDir, { recursive: true });
263
231
  fs_1.default.writeFileSync(path_1.default.join(stateDir, 'artifacts.json'), JSON.stringify([{
264
- name: 'using-awm', type: 'skill', scope: 'global',
232
+ name: 'using-awm', type: 'skill', scope: 'local',
265
233
  targetPath: target, sourcePath: path_1.default.join(source, 'using-awm'),
266
- renderer: 'cursor-mdc', owners: ['cursor'],
234
+ renderer: 'copilot-instructions', owners: ['copilot'],
267
235
  }]));
236
+ // Copilot has no global skills check — stale rendered local artifacts stay on the
237
+ // ledger for migration/repair paths; cursor (link) no longer owns .mdc rows.
268
238
  const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
269
239
  const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
270
- // Recien instalado: coincide con su fuente.
271
- const fresh = gatherProviderChecks(['cursor'], scanSkills)[0]
240
+ const cursorSkills = gatherProviderChecks(['cursor'], scanSkills)[0]
272
241
  .checks.find((c) => c.id === 'skills.global');
273
- expect(fresh?.state).toBe('supported');
274
- // `awm update` trae una version nueva de la skill upstream.
275
- fs_1.default.writeFileSync(skillMd, '---\nname: using-awm\ndescription: original\n---\n\nCuerpo v2, cambiado upstream.\n');
276
- const stale = gatherProviderChecks(['cursor'], scanSkills)[0]
277
- .checks.find((c) => c.id === 'skills.global');
278
- expect(stale?.state).toBe('stale');
279
- expect(stale?.detail).toContain('using-awm.mdc');
280
- // El remedio depende del alcance, y se midio: `awm update` reconcilia los
281
- // artefactos de maquina; `awm sync`, los que declara el profile del proyecto.
282
- expect(stale?.remediationCode).toBe('awm-update');
242
+ expect(cursorSkills?.state).toBe('absent');
283
243
  fs_1.default.rmSync(source, { recursive: true, force: true });
284
244
  });
285
- it('non-link renderer with an empty/missing dir reports absent, not a false healthy', () => {
245
+ it('Cursor link skills with an empty/missing dir reports absent, not a false healthy', () => {
286
246
  const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
287
247
  const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
288
248
  const facts = gatherProviderChecks(['cursor'], scanSkills);
289
249
  const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
290
- expect(skillsCheck).toMatchObject({ id: 'skills.global', state: 'absent', remediationCode: 'awm-init' });
250
+ expect(skillsCheck).toMatchObject({ id: 'skills.global', state: 'absent' });
291
251
  });
292
252
  it('link renderer (claude-code) behavior is completely unchanged — regression', () => {
293
253
  const skillsDir = path_1.default.join(tmpHome, '.claude/skills');
@@ -313,38 +273,28 @@ describe('skillsGlobalCheck — renderer-aware (Task 4.4 / deferred Task 4.3 fin
313
273
  remediationCode: 'repair-global-skills',
314
274
  });
315
275
  });
316
- describe('false-positive fix — an unrelated file must not read as an AWM install', () => {
317
- it('cursor: a dir containing ONLY an unrelated non-.mdc file reports absent, not supported', () => {
276
+ describe('false-positive fix — Cursor rules leftovers must not count as shared skills', () => {
277
+ it('cursor: leftover .mdc under ~/.cursor/rules does not satisfy skills.global (link dir)', () => {
318
278
  const rulesDir = path_1.default.join(tmpHome, '.cursor/rules');
319
279
  fs_1.default.mkdirSync(rulesDir, { recursive: true });
320
- // A user's own pre-existing file, or a directory they created themselves —
321
- // neither ends in `.mdc`, so neither is AWM-shaped evidence.
322
- fs_1.default.writeFileSync(path_1.default.join(rulesDir, 'notes.txt'), 'my own notes, not an AWM rule');
323
- fs_1.default.mkdirSync(path_1.default.join(rulesDir, 'some-user-dir'));
324
- const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
325
- const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
326
- const facts = gatherProviderChecks(['cursor'], scanSkills);
327
- const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
328
- expect(skillsCheck).toMatchObject({ id: 'skills.global', state: 'absent', remediationCode: 'awm-init' });
329
- });
330
- it('cursor: a dir containing a real *.mdc file reports supported', () => {
331
- const rulesDir = path_1.default.join(tmpHome, '.cursor/rules');
332
- fs_1.default.mkdirSync(rulesDir, { recursive: true });
333
- fs_1.default.writeFileSync(path_1.default.join(rulesDir, 'notes.txt'), 'my own notes, not an AWM rule');
334
280
  fs_1.default.writeFileSync(path_1.default.join(rulesDir, 'foo.mdc'), '---\ndescription: foo\nglobs:\nalwaysApply: false\n---\n\nBody.');
335
281
  const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
336
282
  const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
337
283
  const facts = gatherProviderChecks(['cursor'], scanSkills);
338
284
  const skillsCheck = facts[0].checks.find((c) => c.id === 'skills.global');
339
- expect(skillsCheck).toMatchObject({ id: 'skills.global', state: 'supported' });
285
+ expect(skillsCheck).toMatchObject({ id: 'skills.global', state: 'absent' });
340
286
  });
341
- // NOTE: no copilot companion case here — copilot's `skill.global` is `null`
342
- // (no user-level skill discovery mechanism at all, providers/index.ts), so
343
- // `skillsGlobalCheck` returns `null` for it and `gatherProviderChecks` drops
344
- // the `skills.global` row entirely before the renderer-extension gate this
345
- // describe block exercises is ever reached. There is no real directory for
346
- // a copilot-shaped false positive to occur against. The null-global-dir
347
- // branch itself (the guard that makes this row vanish for copilot) is
348
- // covered separately as part of Gap C's null-skip coverage.
287
+ });
288
+ it('reports context.overlap as informational pending when claude-code and cursor coexist (R19)', () => {
289
+ const scanSkills = jest.fn(() => ({ valid: [], repairable: [], dead: [], usurped: [] }));
290
+ const { gatherProviderChecks } = require('../../../src/core/diagnostics/provider-checks');
291
+ const { computeProviderOverall } = require('../../../src/core/diagnostics/checks');
292
+ const facts = gatherProviderChecks(['claude-code', 'cursor'], scanSkills);
293
+ const cursor = facts.find((f) => f.id === 'cursor');
294
+ const overlap = cursor.checks.find((c) => c.id === 'context.overlap');
295
+ expect(overlap).toMatchObject({ state: 'pending' });
296
+ expect(overlap?.detail).toMatch(/Claude Code may import Cursor hooks/);
297
+ // R19: the overlap row alone must never flip overall to degraded.
298
+ expect(computeProviderOverall([{ ...cursor, checks: [overlap] }])).toBe('healthy');
349
299
  });
350
300
  });
@@ -57,10 +57,12 @@ describe('el lector de diagnostico ve lo que el escritor dejo en disco', () => {
57
57
  const { targetPath, renderer } = (0, install_planner_1.physicalTarget)({ name: 'using-awm', type: 'skill', installName: 'using-awm', sourcePath: '/tmp/src' }, agent, 'local', projectRoot);
58
58
  expect(path_1.default.basename(targetPath)).toBe((0, registry_1.renderedFilename)('using-awm', renderer));
59
59
  });
60
- it('cursor: el destino termina en .mdc (el lector que buscaba el nombre pelado nunca lo encontraba)', () => {
61
- const { targetPath } = (0, install_planner_1.physicalTarget)({ name: 'using-awm', type: 'skill', installName: 'using-awm', sourcePath: '/tmp/src' }, 'cursor', 'local', projectRoot);
62
- expect(path_1.default.basename(targetPath)).toBe('using-awm.mdc');
63
- expect(fs_1.default.existsSync(path_1.default.join(path_1.default.dirname(targetPath), 'using-awm'))).toBe(false);
60
+ it('cursor: Plan B links skills under .agents/skills (basename matches installName, not .mdc)', () => {
61
+ const { targetPath, renderer } = (0, install_planner_1.physicalTarget)({ name: 'using-awm', type: 'skill', installName: 'using-awm', sourcePath: '/tmp/src' }, 'cursor', 'local', projectRoot);
62
+ expect(renderer).toBe('link');
63
+ expect(path_1.default.basename(targetPath)).toBe('using-awm');
64
+ expect(path_1.default.dirname(targetPath)).toBe(path_1.default.join(projectRoot, '.agents/skills'));
65
+ expect(path_1.default.basename(targetPath).endsWith('.mdc')).toBe(false);
64
66
  });
65
67
  it('copilot: el destino termina en .instructions.md', () => {
66
68
  const { targetPath } = (0, install_planner_1.physicalTarget)({ name: 'using-awm', type: 'skill', installName: 'using-awm', sourcePath: '/tmp/src' }, 'copilot', 'local', projectRoot);
@@ -0,0 +1,74 @@
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
+ const fs_1 = __importDefault(require("fs"));
7
+ const os_1 = __importDefault(require("os"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const migrate_cursor_mdc_1 = require("../../../src/core/init/migrate-cursor-mdc");
10
+ const artifact_state_1 = require("../../../src/core/artifact-state");
11
+ describe('migrateCursorMdcSkills (Plan B R8/R8.1)', () => {
12
+ let tmpHome;
13
+ let tmpProject;
14
+ let stateFile;
15
+ let originalHome;
16
+ let originalAwmHome;
17
+ beforeEach(() => {
18
+ tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-migrate-mdc-home-'));
19
+ tmpProject = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-migrate-mdc-proj-'));
20
+ stateFile = path_1.default.join(tmpHome, '.awm/state/artifacts.json');
21
+ fs_1.default.mkdirSync(path_1.default.dirname(stateFile), { recursive: true });
22
+ originalHome = process.env.HOME;
23
+ originalAwmHome = process.env.AWM_HOME;
24
+ process.env.HOME = tmpHome;
25
+ process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
26
+ });
27
+ afterEach(() => {
28
+ fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
29
+ fs_1.default.rmSync(tmpProject, { recursive: true, force: true });
30
+ if (originalHome === undefined)
31
+ delete process.env.HOME;
32
+ else
33
+ process.env.HOME = originalHome;
34
+ if (originalAwmHome === undefined)
35
+ delete process.env.AWM_HOME;
36
+ else
37
+ process.env.AWM_HOME = originalAwmHome;
38
+ });
39
+ function record(over) {
40
+ return {
41
+ type: 'skill',
42
+ scope: 'global',
43
+ sourcePath: '/registry/skills/' + over.name,
44
+ owners: ['cursor'],
45
+ ...over,
46
+ };
47
+ }
48
+ it('removes only recorded cursor-mdc skills under rules dirs; keeps awm.mdc and unrecorded files', () => {
49
+ const rules = path_1.default.join(tmpHome, '.cursor/rules');
50
+ fs_1.default.mkdirSync(rules, { recursive: true });
51
+ const foo = path_1.default.join(rules, 'foo.mdc');
52
+ const awm = path_1.default.join(rules, 'awm.mdc');
53
+ const custom = path_1.default.join(rules, 'custom.mdc');
54
+ fs_1.default.writeFileSync(foo, '---\nalwaysApply: false\n---\nfoo\n');
55
+ fs_1.default.writeFileSync(awm, '---\nalwaysApply: true\n---\nguidance\n');
56
+ fs_1.default.writeFileSync(custom, '---\nalwaysApply: false\n---\ncustom\n');
57
+ (0, artifact_state_1.writeArtifactState)([
58
+ record({ name: 'foo', targetPath: foo, renderer: 'cursor-mdc' }),
59
+ record({ name: 'awm', targetPath: awm, renderer: 'cursor-mdc' }),
60
+ record({
61
+ name: 'linked',
62
+ targetPath: path_1.default.join(tmpHome, '.agents/skills/linked'),
63
+ renderer: 'link',
64
+ }),
65
+ ], stateFile);
66
+ const result = (0, migrate_cursor_mdc_1.migrateCursorMdcSkills)({ projectRoot: tmpProject, stateFile });
67
+ expect(result.removed).toEqual([foo]);
68
+ expect(fs_1.default.existsSync(foo)).toBe(false);
69
+ expect(fs_1.default.existsSync(awm)).toBe(true);
70
+ expect(fs_1.default.existsSync(custom)).toBe(true);
71
+ const remaining = JSON.parse(fs_1.default.readFileSync(stateFile, 'utf8'));
72
+ expect(remaining.map((r) => r.name).sort()).toEqual(['awm', 'linked']);
73
+ });
74
+ });
@@ -83,6 +83,10 @@ describe('install-planner', () => {
83
83
  const group = (0, install_planner_1.agentsSharingSkillTarget)('opencode', ['opencode', 'codex', 'copilot'], 'global', tmpWork);
84
84
  expect(group.sort()).toEqual(['codex', 'opencode']);
85
85
  });
86
+ it('includes cursor in the shared ~/.agents/skills group with codex and opencode (Plan B R7)', () => {
87
+ const group = (0, install_planner_1.agentsSharingSkillTarget)('cursor', ['cursor', 'codex', 'opencode', 'claude-code'], 'global', tmpWork);
88
+ expect(group.sort()).toEqual(['codex', 'cursor', 'opencode']);
89
+ });
86
90
  });
87
91
  describe('planInstall', () => {
88
92
  it('deduplicates the OpenCode/Codex physical skill write and reports both owners', () => {
@@ -220,7 +224,7 @@ describe('install-planner', () => {
220
224
  })).toThrow(/physical target already claimed by a different source/);
221
225
  });
222
226
  describe('renderer-driven filename computation (Task 4.3)', () => {
223
- it('renders the Cursor skill target with a .mdc extension, stripping any pre-existing extension', () => {
227
+ it('installs Cursor skills via link under .agents/skills (shared group, Plan B R1/R2)', () => {
224
228
  const plan = (0, install_planner_1.planInstall)({
225
229
  artifacts: [skillArtifact('development-process')],
226
230
  selectedAgents: ['cursor'],
@@ -229,8 +233,8 @@ describe('install-planner', () => {
229
233
  projectRoot: tmpWork,
230
234
  method: 'symlink',
231
235
  });
232
- expect(plan.operations[0].targetPath).toBe(path_1.default.join(tmpWork, '.cursor', 'rules', 'development-process.mdc'));
233
- expect(plan.operations[0].renderer).toBe('cursor-mdc');
236
+ expect(plan.operations[0].targetPath).toBe(path_1.default.join(tmpWork, '.agents', 'skills', 'development-process'));
237
+ expect(plan.operations[0].renderer).toBe('link');
234
238
  });
235
239
  it('renders the Copilot skill target with a .instructions.md extension (not .md.instructions.md)', () => {
236
240
  const plan = (0, install_planner_1.planInstall)({
@@ -261,13 +265,13 @@ describe('install-planner', () => {
261
265
  });
262
266
  expect(path_1.default.basename(plan.operations[0].targetPath)).toBe('development-process.instructions.md');
263
267
  });
264
- it('does not truncate an installName with an embedded, non-extension dot (e.g. "v1.2-migration")', () => {
268
+ it('does not truncate a Cursor link installName with an embedded, non-extension dot', () => {
265
269
  // Regression: physicalTarget used to derive the base name via
266
270
  // path.parse(...).name, which strips everything after the LAST
267
271
  // dot — not just a genuine trailing .md extension. A skill
268
272
  // literally named `v1.2-migration` would silently truncate to
269
273
  // `v1`, dropping `2-migration` and risking a collision with any
270
- // other skill named `v1`.
274
+ // other skill named `v1`. Cursor now uses link under .agents/skills.
271
275
  const dottedName = {
272
276
  name: 'v1.2-migration',
273
277
  installName: 'v1.2-migration',
@@ -282,10 +286,10 @@ describe('install-planner', () => {
282
286
  projectRoot: tmpWork,
283
287
  method: 'symlink',
284
288
  });
285
- expect(path_1.default.basename(plan.operations[0].targetPath)).toBe('v1.2-migration.mdc');
286
- expect(path_1.default.basename(plan.operations[0].targetPath)).not.toBe('v1.mdc');
289
+ expect(path_1.default.basename(plan.operations[0].targetPath)).toBe('v1.2-migration');
290
+ expect(path_1.default.basename(plan.operations[0].targetPath)).not.toBe('v1');
287
291
  });
288
- it('still strips a real trailing .md extension for Cursor (not v1.2-migration.md.mdc)', () => {
292
+ it('still strips a real trailing .md extension for Copilot (not using-awm.md.instructions.md)', () => {
289
293
  const withMdExtension = {
290
294
  name: 'using-awm',
291
295
  installName: 'using-awm.md',
@@ -294,14 +298,14 @@ describe('install-planner', () => {
294
298
  };
295
299
  const plan = (0, install_planner_1.planInstall)({
296
300
  artifacts: [withMdExtension],
297
- selectedAgents: ['cursor'],
298
- enabledAgents: ['cursor'],
301
+ selectedAgents: ['copilot'],
302
+ enabledAgents: ['copilot'],
299
303
  scope: 'local',
300
304
  projectRoot: tmpWork,
301
305
  method: 'symlink',
302
306
  });
303
- expect(path_1.default.basename(plan.operations[0].targetPath)).toBe('using-awm.mdc');
304
- expect(path_1.default.basename(plan.operations[0].targetPath)).not.toBe('using-awm.md.mdc');
307
+ expect(path_1.default.basename(plan.operations[0].targetPath)).toBe('using-awm.instructions.md');
308
+ expect(path_1.default.basename(plan.operations[0].targetPath)).not.toBe('using-awm.md.instructions.md');
305
309
  });
306
310
  });
307
311
  });
@@ -0,0 +1,123 @@
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
+ /**
7
+ * Plan B R3.1 — seven non-empty subsets of {claude-code, codex, cursor}.
8
+ * Isolated HOME/AWM_HOME; no real ~/.claude or ~/.codex required for {cursor}.
9
+ */
10
+ const fs_1 = __importDefault(require("fs"));
11
+ const os_1 = __importDefault(require("os"));
12
+ const path_1 = __importDefault(require("path"));
13
+ const TRIO = ['claude-code', 'codex', 'cursor'];
14
+ function subsetsOf(agents) {
15
+ const out = [];
16
+ for (let mask = 1; mask < (1 << agents.length); mask++) {
17
+ out.push(agents.filter((_, i) => (mask & (1 << i)) !== 0));
18
+ }
19
+ return out;
20
+ }
21
+ describe('Cursor subset gate (Plan B R3.1)', () => {
22
+ let tmpHome;
23
+ let tmpRegistry;
24
+ let tmpWork;
25
+ let originalHome;
26
+ let originalAwmHome;
27
+ beforeEach(() => {
28
+ tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-subset-home-'));
29
+ tmpRegistry = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-subset-reg-'));
30
+ tmpWork = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-subset-work-'));
31
+ originalHome = process.env.HOME;
32
+ originalAwmHome = process.env.AWM_HOME;
33
+ process.env.HOME = tmpHome;
34
+ process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
35
+ jest.resetModules();
36
+ const skill = path_1.default.join(tmpRegistry, 'skills/using-awm');
37
+ fs_1.default.mkdirSync(skill, { recursive: true });
38
+ fs_1.default.writeFileSync(path_1.default.join(skill, 'SKILL.md'), '---\nname: using-awm\ndescription: guide\n---\nMUST invoke skills.\n');
39
+ const hooks = path_1.default.join(tmpRegistry, 'hooks');
40
+ fs_1.default.mkdirSync(hooks, { recursive: true });
41
+ for (const name of ['cursor-session-start', 'cursor-pre-compact', 'cursor-post-tool-use', 'codex-session-start', 'session-start', 'run-hook.cmd']) {
42
+ fs_1.default.writeFileSync(path_1.default.join(hooks, name), '#!/usr/bin/env node\nconsole.log("{}")\n', { mode: 0o755 });
43
+ }
44
+ fs_1.default.mkdirSync(path_1.default.join(tmpHome, '.awm'), { recursive: true });
45
+ fs_1.default.writeFileSync(path_1.default.join(tmpHome, '.awm/preferences.json'), JSON.stringify({
46
+ defaultAgent: 'cursor',
47
+ enabledAgents: TRIO,
48
+ installMethod: 'symlink',
49
+ defaultScope: 'local',
50
+ }));
51
+ fs_1.default.writeFileSync(path_1.default.join(tmpHome, '.awm/registries.json'), JSON.stringify([
52
+ { name: 'baseline', remote: 'https://example.invalid/baseline.git' },
53
+ ]));
54
+ // Point baseline content root at our temp registry via junction/symlink of registries/baseline
55
+ fs_1.default.mkdirSync(path_1.default.join(tmpHome, '.awm/registries'), { recursive: true });
56
+ fs_1.default.symlinkSync(tmpRegistry, path_1.default.join(tmpHome, '.awm/registries/baseline'), process.platform === 'win32' ? 'junction' : 'dir');
57
+ });
58
+ afterEach(() => {
59
+ fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
60
+ fs_1.default.rmSync(tmpRegistry, { recursive: true, force: true });
61
+ fs_1.default.rmSync(tmpWork, { recursive: true, force: true });
62
+ if (originalHome === undefined)
63
+ delete process.env.HOME;
64
+ else
65
+ process.env.HOME = originalHome;
66
+ if (originalAwmHome === undefined)
67
+ delete process.env.AWM_HOME;
68
+ else
69
+ process.env.AWM_HOME = originalAwmHome;
70
+ });
71
+ it.each(subsetsOf(TRIO).map((s) => [s.join('+'), s]))('subset %s installs a single using-awm skill delivery and Cursor hooks when included', (_label, subset) => {
72
+ const { planInstall } = require('../../src/core/install-planner');
73
+ const { applyInstallPlan } = require('../../src/core/install-transaction');
74
+ const { installHook } = require('../../src/commands/hooks/install');
75
+ const { providerFor } = require('../../src/providers');
76
+ const skillSource = path_1.default.join(tmpRegistry, 'skills/using-awm');
77
+ const plan = planInstall({
78
+ artifacts: [{
79
+ name: 'using-awm',
80
+ installName: 'using-awm',
81
+ type: 'skill',
82
+ sourcePath: skillSource,
83
+ }],
84
+ selectedAgents: subset,
85
+ enabledAgents: subset,
86
+ scope: 'global',
87
+ projectRoot: tmpWork,
88
+ method: 'symlink',
89
+ });
90
+ applyInstallPlan(plan);
91
+ // Shared agents skills root (Cursor/Codex/OpenCode) — at most one using-awm.
92
+ // Claude may also have ~/.claude/skills/using-awm; that is a separate channel.
93
+ const sharedRoot = path_1.default.join(tmpHome, '.agents/skills');
94
+ const sharedSkill = path_1.default.join(sharedRoot, 'using-awm');
95
+ const wantsShared = subset.some((agent) => {
96
+ const global = providerFor(agent).skill.global;
97
+ return global === sharedRoot;
98
+ });
99
+ if (wantsShared) {
100
+ expect(fs_1.default.existsSync(sharedSkill)).toBe(true);
101
+ }
102
+ expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.cursor/rules/using-awm.mdc'))).toBe(false);
103
+ if (subset.includes('cursor')) {
104
+ // {cursor} alone must not require ~/.claude or ~/.codex
105
+ if (subset.length === 1) {
106
+ expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.claude'))).toBe(false);
107
+ expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.codex'))).toBe(false);
108
+ }
109
+ installHook({
110
+ agent: 'cursor',
111
+ registryRoot: tmpRegistry,
112
+ installMethod: 'copy',
113
+ baselineRegistryVersion: '4.9.0',
114
+ });
115
+ expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.awm/hooks/cursor/session-start'))).toBe(true);
116
+ const hooksJson = JSON.parse(fs_1.default.readFileSync(path_1.default.join(tmpHome, '.cursor/hooks.json'), 'utf8'));
117
+ expect(hooksJson.version).toBe(1);
118
+ expect(hooksJson.hooks.sessionStart.some((e) => e.command.endsWith(`${path_1.default.sep}session-start`))).toBe(true);
119
+ expect(hooksJson.hooks.preCompact.some((e) => e.command.endsWith(`${path_1.default.sep}pre-compact`))).toBe(true);
120
+ expect(hooksJson.hooks.postToolUse.some((e) => e.command.endsWith(`${path_1.default.sep}post-tool-use`))).toBe(true);
121
+ }
122
+ });
123
+ });
@@ -170,10 +170,25 @@ describe('Providers Routing', () => {
170
170
  expect((0, providers_1.isAgentTarget)('cursor')).toBe(true);
171
171
  expect((0, providers_1.isAgentTarget)('copilot')).toBe(true);
172
172
  });
173
- it('resolves Cursor skill paths for both scopes', () => {
174
- expect((0, providers_1.getTargetPath)('skill', 'cursor', 'local')).toBe('.cursor/rules');
173
+ it('joins the shared agents skills dirs with link renderer (Plan B R1/R2)', () => {
174
+ expect((0, providers_1.providerFor)('cursor').skill).toEqual({
175
+ global: path_1.default.join(process.env.HOME, '.agents/skills'),
176
+ local: '.agents/skills',
177
+ renderer: 'link',
178
+ });
179
+ expect((0, providers_1.getTargetPath)('skill', 'cursor', 'local')).toBe('.agents/skills');
175
180
  expect((0, providers_1.getTargetPath)('skill', 'cursor', 'global'))
176
- .toBe(path_1.default.join(process.env.HOME, '.cursor/rules'));
181
+ .toBe(path_1.default.join(process.env.HOME, '.agents/skills'));
182
+ });
183
+ it('installs canonical Cursor agents under .cursor/agents with link (Plan B R6/R9)', () => {
184
+ expect((0, providers_1.providerFor)('cursor').agent).toEqual({
185
+ global: path_1.default.join(process.env.HOME, '.cursor/agents'),
186
+ local: '.cursor/agents',
187
+ renderer: 'link',
188
+ });
189
+ expect((0, providers_1.getTargetPath)('agent', 'cursor', 'local')).toBe('.cursor/agents');
190
+ expect((0, providers_1.getTargetPath)('agent', 'cursor', 'global'))
191
+ .toBe(path_1.default.join(process.env.HOME, '.cursor/agents'));
177
192
  });
178
193
  it('resolves the Copilot local skill path', () => {
179
194
  expect((0, providers_1.getTargetPath)('skill', 'copilot', 'local')).toBe('.github/instructions');
@@ -181,31 +196,29 @@ describe('Providers Routing', () => {
181
196
  it('throws a specific, non-generic reason when Copilot global skills are requested', () => {
182
197
  expect(() => (0, providers_1.getTargetPath)('skill', 'copilot', 'global')).toThrow('skill global scope is not supported by Copilot: GitHub Copilot has no user-level skill discovery mechanism — skills must be installed per-project.');
183
198
  });
184
- it('keeps workflow/agent unsupported (null) for both, via the existing generic message', () => {
199
+ it('keeps workflow unsupported for cursor and agent unsupported for copilot', () => {
185
200
  expect(() => (0, providers_1.getTargetPath)('workflow', 'cursor', 'local')).toThrow('workflows are not supported by Cursor.');
186
201
  expect(() => (0, providers_1.getTargetPath)('agent', 'copilot', 'local')).toThrow('agents are not supported by Copilot.');
187
202
  });
188
- it('declares no hooks config for cursor or copilot', () => {
189
- expect((0, providers_1.providerFor)('cursor').hooks).toBeUndefined();
203
+ it('declares cursor-hooks-json for cursor and none for copilot', () => {
204
+ expect((0, providers_1.providerFor)('cursor').hooks).toEqual({
205
+ type: 'cursor-hooks-json',
206
+ settingsPath: path_1.default.join(tmpHome, '.cursor/hooks.json'),
207
+ scriptsDir: path_1.default.join(process.env.AWM_HOME, 'hooks/cursor'),
208
+ matcher: '',
209
+ eventName: '',
210
+ });
190
211
  expect((0, providers_1.providerFor)('copilot').hooks).toBeUndefined();
191
212
  });
192
- it('assigns the Cursor .mdc and Copilot instructions renderers to their skill artifact config (Task 4.3)', () => {
193
- expect((0, providers_1.providerFor)('cursor').skill.renderer).toBe('cursor-mdc');
213
+ it('assigns Copilot instructions renderer; Cursor skills use link (cursor-mdc kept for migration)', () => {
214
+ expect((0, providers_1.providerFor)('cursor').skill.renderer).toBe('link');
194
215
  expect((0, providers_1.providerFor)('copilot').skill.renderer).toBe('copilot-instructions');
195
216
  });
196
- it('assertLinkRenderer still refuses the Cursor/Copilot skill renderers (Task 4.3 code-quality-review fix)', () => {
197
- // Regression: an earlier version of this task widened assertLinkRenderer to
198
- // allow these two through, on the theory that a raw unrendered copy is "at
199
- // least a plausible degraded install" — wrong. assertLinkRenderer's only
200
- // callers (core/provider-artifacts.ts's legacy preflight, src/index.ts's
201
- // legacy interactive `awm add`) can only symlink/copy verbatim; they never
202
- // render. A raw SKILL.md copy at `.cursor/rules/<name>` or
203
- // `.github/instructions/<name>` has no `.mdc`/`.instructions.md` extension
204
- // and no frontmatter (`alwaysApply`/`applyTo`) — neither Cursor nor Copilot
205
- // would ever read it. This must keep throwing, same as codex-agent-toml
206
- // always has, directing users to commands/add.ts's real render pipeline
207
- // instead (which never calls assertLinkRenderer at all).
208
- expect(() => (0, providers_1.assertLinkRenderer)('skill', 'cursor')).toThrow(/not implemented yet/);
217
+ it('assertLinkRenderer accepts Cursor link skills and still refuses Copilot', () => {
218
+ expect(() => (0, providers_1.assertLinkRenderer)('skill', 'cursor')).not.toThrow();
219
+ expect((0, providers_1.assertLinkRenderer)('skill', 'cursor')?.renderer).toBe('link');
220
+ // Copilot still renders via the add pipeline only — a raw SKILL.md under
221
+ // `.github/instructions/` lacks `.instructions.md` + applyTo frontmatter.
209
222
  expect(() => (0, providers_1.assertLinkRenderer)('skill', 'copilot')).toThrow(/not implemented yet/);
210
223
  });
211
224
  });
@@ -86,11 +86,12 @@ describe('a provider that declares a config-home override honors it everywhere',
86
86
  });
87
87
  }
88
88
  it('shared, cross-agent conventions are NOT moved by one agent’s override', () => {
89
- // `~/.agents/skills` lo comparten Codex y OpenCode: es del ecosistema, no de un
90
- // agente. Moverlo con CODEX_HOME desconectaria a OpenCode de sus propias skills.
89
+ // `~/.agents/skills` lo comparten Codex, OpenCode y Cursor: es del ecosistema, no de un
90
+ // agente. Moverlo con CODEX_HOME desconectaria a OpenCode/Cursor de sus propias skills.
91
91
  const moved = providersWith({ HOME, CODEX_HOME: ELSEWHERE });
92
92
  expect(moved.codex.skill.global).toBe(path_1.default.join(HOME, '.agents/skills'));
93
93
  expect(moved.opencode.skill.global).toBe(path_1.default.join(HOME, '.agents/skills'));
94
+ expect(moved.cursor.skill.global).toBe(path_1.default.join(HOME, '.agents/skills'));
94
95
  });
95
96
  it('every agent declares a config home, so a new provider cannot skip the question', () => {
96
97
  const table = providersWith({ HOME });