agentic-workflow-manager 9.14.1 → 9.15.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.
Files changed (41) 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/commands/watch/index.js +10 -0
  10. package/dist/src/core/admission/index.js +8 -3
  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/core/journal/durable-custody.js +19 -0
  18. package/dist/src/providers/index.js +18 -4
  19. package/dist/tests/commands/add.test.js +5 -7
  20. package/dist/tests/commands/context-budget/budget.test.js +68 -0
  21. package/dist/tests/commands/doctor.test.js +2 -2
  22. package/dist/tests/commands/hooks/cursor.test.js +257 -0
  23. package/dist/tests/commands/hooks/resync.test.js +8 -0
  24. package/dist/tests/commands/init.test.js +23 -22
  25. package/dist/tests/commands/plan/index.test.js +56 -2
  26. package/dist/tests/core/admission/index.test.js +19 -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/core/journal/durable-custody.test.js +44 -0
  34. package/dist/tests/integration/cursor-subset-gate.test.js +123 -0
  35. package/dist/tests/providers/index.test.js +34 -21
  36. package/dist/tests/structural/provider-paths-honor-config-home.test.js +3 -2
  37. package/package.json +1 -1
  38. package/prebuilds/darwin-arm64/secure_fs.node +0 -0
  39. package/prebuilds/darwin-x64/secure_fs.node +0 -0
  40. package/prebuilds/win32-arm64/secure_fs.node +0 -0
  41. package/prebuilds/win32-x64/secure_fs.node +0 -0
@@ -395,20 +395,22 @@ describe('runInit', () => {
395
395
  expect(JSON.parse(fs_1.default.readFileSync(settingsPath, 'utf8'))).toEqual({ pristine: true, unrelated: 'keep' });
396
396
  });
397
397
  // -----------------------------------------------------------------------
398
- // Gap A (QA panel) — GLOBAL-scope render pipeline for Cursor/Copilot was
399
- // only ever exercised via LOCAL-scope `awm add`/bundle-install tests
400
- // (tests/core/bundle-install.test.ts). This drives a real `awm init
401
- // --agent cursor` through the REAL installBundle/applyInstallPlan path
402
- // (no installBundle override — only syncCache is stubbed, to avoid a
403
- // real network clone) and asserts a real rendered .mdc file lands in the
404
- // GLOBAL ~/.cursor/rules directory with the expected frontmatter shape.
398
+ // Gap A (QA panel) — GLOBAL-scope Cursor skill delivery. Plan B installs
399
+ // Cursor skills as links under ~/.agents/skills (shared with Codex/
400
+ // OpenCode) plus native hooks.json entries (registry floor >= 4.9.0).
401
+ // Drives a real `awm init --agent cursor` through installBundle/
402
+ // applyInstallPlan (only syncCache stubbed) and asserts the link + hooks.
405
403
  // -----------------------------------------------------------------------
406
- it('Gap A — real global-scope render: awm init --agent cursor renders a real .mdc into ~/.cursor/rules', async () => {
404
+ it('Gap A — real global-scope link: awm init --agent cursor links using-awm into ~/.agents/skills', async () => {
405
+ const { spawnSync } = require('child_process');
407
406
  const contentRoot = path_1.default.join(process.env.AWM_HOME, 'registries', 'baseline');
408
- // A 'hooks' dir (even empty) is what makes capabilityRoot('hooks') resolve to
409
- // this content root — that's the registryRoot stepContextInjection uses to
410
- // find skills/using-awm/SKILL.md for the (unrelated) context-injection step.
407
+ // A 'hooks' dir is what makes capabilityRoot('hooks') resolve to this content
408
+ // root — registryRoot for stepContextInjection / stepHook. Plan B also needs
409
+ // the three Cursor hook scripts and an exact v4.9.0+ tag for the registry floor.
411
410
  fs_1.default.mkdirSync(path_1.default.join(contentRoot, 'hooks'), { recursive: true });
411
+ for (const name of ['cursor-session-start', 'cursor-pre-compact', 'cursor-post-tool-use']) {
412
+ fs_1.default.writeFileSync(path_1.default.join(contentRoot, 'hooks', name), '#!/usr/bin/env node\nconsole.log("{}")\n', { mode: 0o755 });
413
+ }
412
414
  fs_1.default.mkdirSync(path_1.default.join(contentRoot, 'skills', 'using-awm'), { recursive: true });
413
415
  fs_1.default.writeFileSync(path_1.default.join(contentRoot, 'skills', 'using-awm', 'SKILL.md'), '---\nname: using-awm\ndescription: Use when starting any development conversation\n---\n\nMUST invoke skills per the tiered policy.\n');
414
416
  fs_1.default.mkdirSync(path_1.default.join(contentRoot, 'bundles', 'dev-core'), { recursive: true });
@@ -420,6 +422,10 @@ describe('runInit', () => {
420
422
  name: 'dev-core', version: '1.0.0', description: 'Baseline', scope: 'baseline',
421
423
  dependsOn: [], skills: ['using-awm'], workflows: [], agents: [],
422
424
  }));
425
+ spawnSync('git', ['init', '-q', '-b', 'main'], { cwd: contentRoot, stdio: 'ignore' });
426
+ spawnSync('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'add', '.'], { cwd: contentRoot, stdio: 'ignore' });
427
+ spawnSync('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'fixture'], { cwd: contentRoot, stdio: 'ignore' });
428
+ spawnSync('git', ['tag', 'v4.9.0'], { cwd: contentRoot, stdio: 'ignore' });
423
429
  const projectCwd = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-init-cursor-cwd-'));
424
430
  try {
425
431
  const { runInit } = require('../../src/commands/init');
@@ -431,20 +437,15 @@ describe('runInit', () => {
431
437
  // seedBaselineRegistry() writes registries.json pointing at 'baseline'
432
438
  // (our manually-seeded content root above) since none exists yet — no
433
439
  // real network clone happens because that content root already exists
434
- // on disk. Only syncCache is stubbed, so stepCache's own sync (the
435
- // content root above has no `.git`, so it reads as "not yet cloned")
436
- // doesn't try to shell out to git either.
440
+ // on disk. Only syncCache is stubbed.
437
441
  actions: { syncCache: async () => { } },
438
442
  });
439
443
  expect(code).toBeLessThanOrEqual(1);
440
- const mdcPath = path_1.default.join(tmpHome, '.cursor', 'rules', 'using-awm.mdc');
441
- expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.cursor', 'rules', 'using-awm'))).toBe(false);
442
- expect(fs_1.default.existsSync(mdcPath)).toBe(true);
443
- const rendered = fs_1.default.readFileSync(mdcPath, 'utf8');
444
- expect(rendered).toContain('description: Use when starting any development conversation');
445
- expect(rendered).toContain('globs:');
446
- expect(rendered).toContain('alwaysApply: false');
447
- expect(rendered).toContain('MUST invoke skills per the tiered policy.');
444
+ const linked = path_1.default.join(tmpHome, '.agents', 'skills', 'using-awm');
445
+ expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.cursor', 'rules', 'using-awm.mdc'))).toBe(false);
446
+ expect(fs_1.default.lstatSync(linked).isSymbolicLink() || fs_1.default.existsSync(path_1.default.join(linked, 'SKILL.md'))).toBe(true);
447
+ expect(fs_1.default.readFileSync(path_1.default.join(linked, 'SKILL.md'), 'utf8')).toContain('MUST invoke skills per the tiered policy.');
448
+ expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.cursor', 'hooks.json'))).toBe(true);
448
449
  }
449
450
  finally {
450
451
  fs_1.default.rmSync(projectCwd, { recursive: true, force: true });
@@ -488,7 +488,11 @@ describe('plan admit Commander wiring', () => {
488
488
  process.exitCode = undefined;
489
489
  }
490
490
  });
491
- it.each([['cursor', 'global'], ['cursor', 'local'], ['copilot', 'local']])('does not prove absence for actual unowned rendered %s %s runtime contracts', async (target, scope) => {
491
+ // Cursor skills are link-installed under ~/.agents/skills (same as Codex); only
492
+ // content renderers still need this "rendered copy ≠ ownership" proof. Link
493
+ // unowned cases are covered by the runtime-unowned scenarios above.
494
+ it('does not prove absence for actual unowned rendered copilot local runtime contracts', async () => {
495
+ const target = 'copilot';
492
496
  const root = fs_1.default.realpathSync(fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-rendered-admission-')));
493
497
  const oldHome = process.env.HOME;
494
498
  process.env.HOME = path_1.default.join(root, 'home');
@@ -500,7 +504,7 @@ describe('plan admit Commander wiring', () => {
500
504
  fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'awm-registry.json'), JSON.stringify({ minCliVersion: '1.0.0' }));
501
505
  fs_1.default.writeFileSync(path_1.default.join(root, 'source.md'), 'project source');
502
506
  const provider = (0, providers_1.providerFor)(target);
503
- const directory = scope === 'global' ? provider.skill.global : path_1.default.resolve(root, provider.skill.local);
507
+ const directory = path_1.default.resolve(root, provider.skill.local);
504
508
  fs_1.default.mkdirSync(directory, { recursive: true });
505
509
  const installedFile = path_1.default.join(directory, (0, registry_1.renderedFilename)('using-awm', provider.skill.renderer));
506
510
  fs_1.default.writeFileSync(installedFile, (0, registry_1.renderArtifact)(provider.skill.renderer, skillSource));
@@ -540,6 +544,56 @@ describe('plan admit Commander wiring', () => {
540
544
  process.exitCode = undefined;
541
545
  }
542
546
  });
547
+ it.each([['cursor', 'global'], ['cursor', 'local']])('does not prove absence for actual unowned linked %s %s runtime contracts', async (target, scope) => {
548
+ const root = fs_1.default.realpathSync(fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-linked-admission-')));
549
+ const oldHome = process.env.HOME;
550
+ process.env.HOME = path_1.default.join(root, 'home');
551
+ const outputSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
552
+ const registryRoot = path_1.default.join(root, 'registry');
553
+ fs_1.default.mkdirSync(path_1.default.join(registryRoot, 'skills'), { recursive: true });
554
+ fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'awm-registry.json'), JSON.stringify({ minCliVersion: '1.0.0' }));
555
+ fs_1.default.writeFileSync(path_1.default.join(root, 'source.md'), 'project source');
556
+ const unowned = path_1.default.join(root, 'unowned', 'using-awm');
557
+ fs_1.default.mkdirSync(unowned, { recursive: true });
558
+ fs_1.default.writeFileSync(path_1.default.join(unowned, 'SKILL.md'), '---\nname: using-awm\ndescription: contract\n---\nRuntime contract.\n');
559
+ const provider = (0, providers_1.providerFor)(target);
560
+ const directory = scope === 'global' ? provider.skill.global : path_1.default.resolve(root, provider.skill.local);
561
+ fs_1.default.mkdirSync(directory, { recursive: true });
562
+ const artifact = path_1.default.join(directory, (0, registry_1.renderedFilename)('using-awm', provider.skill.renderer));
563
+ fs_1.default.symlinkSync(unowned, artifact, process.platform === 'win32' ? 'junction' : 'dir');
564
+ const sensors = jest.fn();
565
+ const program = new commander_1.Command();
566
+ (0, plan_1.registerPlanCommand)(program, {
567
+ validatePlanFile: () => ({ ...valid, manifest: { ...valid.manifest, sources: [{ id: 'source', path: 'source.md', locator: 'contract', fact: 'contract' }] } }),
568
+ listRegistries: () => [{ name: 'fixture', remote: 'https://example.invalid/fixture.git', contentRoot: registryRoot }],
569
+ readPreferences: () => ({ defaultAgent: target, enabledAgents: [target], installMethod: 'symlink', defaultScope: 'local' }),
570
+ checkCurrentness: async () => ({ checkedAt: 'x', compatibility: { status: 'not-checked' }, components: [{ component: 'cli', installed: '1.0.0', latest: '1.0.0', channel: 'stable', source: 'fixture', checkedAt: 'x', status: 'current', detail: 'ok', remedy: 'none' }] }),
571
+ runSensors: sensors,
572
+ });
573
+ try {
574
+ await program.parseAsync(['node', 'awm', 'plan', 'admit', 'plan.md', '--provider', target, '--cwd', root, '--execution-mode', 'interactivo', '--require-current', '--verify-sensors', '--json']);
575
+ const output = JSON.parse(String(outputSpy.mock.calls.at(-1)[0]));
576
+ expect(output.diagnostics[0].code).toBe('ADMISSION_CURRENTNESS_PROVENANCE_REQUIRED');
577
+ expect(output.state).toBe('blocked');
578
+ const reported = /^Runtime artifact (.+) has no unique physical registry owner/.exec(output.diagnostics[0].message);
579
+ expect(reported).not.toBeNull();
580
+ const reportedIdentity = fs_1.default.lstatSync(reported[1], { bigint: true });
581
+ const installedIdentity = fs_1.default.lstatSync(artifact, { bigint: true });
582
+ expect(reportedIdentity.isSymbolicLink() || reportedIdentity.isDirectory()).toBe(true);
583
+ expect(reportedIdentity.ino).not.toBe(0n);
584
+ expect([reportedIdentity.dev, reportedIdentity.ino]).toEqual([installedIdentity.dev, installedIdentity.ino]);
585
+ expect(sensors).not.toHaveBeenCalled();
586
+ }
587
+ finally {
588
+ if (oldHome === undefined)
589
+ delete process.env.HOME;
590
+ else
591
+ process.env.HOME = oldHome;
592
+ outputSpy.mockRestore();
593
+ fs_1.default.rmSync(root, { recursive: true, force: true });
594
+ process.exitCode = undefined;
595
+ }
596
+ });
543
597
  it.each(['symlink', 'directory', 'oversized', 'duplicate-floor', 'unsafe-floor', 'stale-before-compatibility'])('keeps registry manifest %s noncertifying before sensors', async (scenario) => {
544
598
  const root = fs_1.default.realpathSync(fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-manifest-admission-')));
545
599
  const registryRoot = path_1.default.join(root, 'registry');
@@ -177,6 +177,25 @@ describe('admitPlan', () => {
177
177
  expect(report.state).toBe('blocked');
178
178
  expect(report.diagnostics[0]).toMatchObject({ code: 'ADMISSION_CONTROLLER_AUTONOMY_REQUIRED' });
179
179
  });
180
+ // While Camino B is suspended, a leftover journal on the branch must not
181
+ // trap native v1 unattended admission. Jest sets AWM_ALLOW_DURABLE_CUSTODY=1
182
+ // for the rest of the suite; this case restores the operator default.
183
+ it('ignores leftover journals for native v1 while durable custody is suspended', async () => {
184
+ const previous = process.env.AWM_ALLOW_DURABLE_CUSTODY;
185
+ delete process.env.AWM_ALLOW_DURABLE_CUSTODY;
186
+ try {
187
+ const corrupt = await (0, admission_1.admitPlan)({ ...base(), journalCorrupt: true });
188
+ expect(corrupt).toMatchObject({ state: 'admitted', executionMode: 'desatendido', journal: 'not-required' });
189
+ const stale = await (0, admission_1.admitPlan)({ ...base(), journalState: { ...(0, types_1.emptyState)('main'), schema: 2, planBinding: { path: 'docs/plan.md', digest: 'b'.repeat(64), schema: 'compact-slices/v1', executionMode: 'desatendido', boundAt: '2026-09-15T00:00:00.000Z' } } });
190
+ expect(stale).toMatchObject({ state: 'admitted', journal: 'not-required' });
191
+ }
192
+ finally {
193
+ if (previous === undefined)
194
+ delete process.env.AWM_ALLOW_DURABLE_CUSTODY;
195
+ else
196
+ process.env.AWM_ALLOW_DURABLE_CUSTODY = previous;
197
+ }
198
+ });
180
199
  });
181
200
  // R8: a content registry whose sensors are ALL deliberately disabled may close
182
201
  // work; its release proof lives in CI. The local verdict stays not-certified —
@@ -284,12 +284,8 @@ describe('installBundle', () => {
284
284
  expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.awm/profile.json'))).toBe(true);
285
285
  expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.gitignore'))).toBe(true);
286
286
  });
287
- it('materializes a skill as a rendered Cursor .mdc via the real default applyInstallPlan (Task 4.3 e2e)', () => {
287
+ it('materializes a Cursor skill as a link under .agents/skills via the real default applyInstallPlan (Plan B e2e)', () => {
288
288
  const { content, projectRoot, bundles } = makeFixture();
289
- // s-base's fixture SKILL.md (makeFixture, above) has no `description`
290
- // field — real for a bare skill fixture, but cursor-mdc.ts's renderer
291
- // requires one (parseSkillSource). Seed a real description here so
292
- // this is a genuine end-to-end render, not just a source-existence check.
293
289
  fs_1.default.writeFileSync(path_1.default.join(content, 'skills', 's-base', 'SKILL.md'), '---\nname: s-base\ndescription: Base skill for bundle-install fixtures\n---\n\nDo the base thing.\n');
294
290
  const base = bundles.find((bundle) => bundle.name === 'base');
295
291
  const result = (0, bundle_install_1.installBundle)({
@@ -300,15 +296,12 @@ describe('installBundle', () => {
300
296
  projectRoot,
301
297
  contentDir: content,
302
298
  });
303
- const mdcPath = path_1.default.join(projectRoot, '.cursor/rules/s-base.mdc');
304
- expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.cursor/rules/s-base'))).toBe(false);
305
- expect(fs_1.default.existsSync(mdcPath)).toBe(true);
306
- const rendered = fs_1.default.readFileSync(mdcPath, 'utf8');
307
- expect(rendered).toContain('description: Base skill for bundle-install fixtures');
308
- expect(rendered).toContain('alwaysApply: false');
309
- expect(rendered).toContain('Do the base thing.');
299
+ const linked = path_1.default.join(projectRoot, '.agents/skills/s-base');
300
+ expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.cursor/rules/s-base.mdc'))).toBe(false);
301
+ expect(fs_1.default.lstatSync(linked).isSymbolicLink() || fs_1.default.existsSync(path_1.default.join(linked, 'SKILL.md'))).toBe(true);
302
+ expect(fs_1.default.readFileSync(path_1.default.join(linked, 'SKILL.md'), 'utf8')).toContain('Do the base thing.');
310
303
  expect(result.transactionId).toBeTruthy();
311
- expect(result.modifiedFiles).toContain(mdcPath);
304
+ expect(result.modifiedFiles).toContain(linked);
312
305
  });
313
306
  it('materializes a skill as rendered Copilot .instructions.md via the real default applyInstallPlan (Task 4.3 e2e)', () => {
314
307
  const { content, projectRoot, bundles } = makeFixture();
@@ -338,7 +331,8 @@ describe('installBundle', () => {
338
331
  bundleName: 'base', bundles, agents: ['cursor'],
339
332
  method: 'symlink', projectRoot, contentDir: content,
340
333
  });
341
- expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.cursor/rules/s-base.mdc'))).toBe(true);
334
+ expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.agents/skills/s-base'))).toBe(true);
335
+ expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.cursor/rules/s-base.mdc'))).toBe(false);
342
336
  expect(cursorResult.recordedExtension).toBe('base');
343
337
  const copilotResult = (0, bundle_install_1.addBundle)({
344
338
  bundleName: 'base', bundles, agents: ['copilot'],
@@ -162,14 +162,8 @@ describe('CodexAgentsStrategy', () => {
162
162
  expect(fs_1.default.existsSync(path_1.default.join(project, 'AGENTS.md'))).toBe(false);
163
163
  expect(fs_1.default.existsSync(path_1.default.join(project, '.cursor'))).toBe(false);
164
164
  });
165
- it('regression: local-scope context injection + project constitution injection no longer collide on the same AGENTS.md managed block (R4 QA blocker)', () => {
166
- // Before the fix: inject() (context) and injectProject() (constitution) both wrote
167
- // the SAME single-slot managed block in <projectRoot>/AGENTS.md for a local-scope
168
- // provider (Cursor/Copilot) — whichever ran second silently discarded the other's
169
- // content. stepContextInjection runs before stepConstitutionInjection in the real
170
- // init orchestrator (init/orchestrator.ts), so constitution always won, and the real
171
- // AWM skill/context guidance never survived a real `awm init --agent cursor` run.
172
- const project = path_1.default.join(tmpWork, 'collision-repo');
165
+ it('Cursor inject writes PROJECT_GUIDANCE only (using-awm lives in the hook — Plan B R4/R10)', () => {
166
+ const project = path_1.default.join(tmpWork, 'cursor-guidance-only');
173
167
  fs_1.default.mkdirSync(project, { recursive: true });
174
168
  const materialized = path_1.default.join(project, '.awm/context/awm-context.md');
175
169
  const contextMarkdown = '# AWM\n\nUse `development-process`. MUST invoke skills per policy.';
@@ -186,15 +180,40 @@ describe('CodexAgentsStrategy', () => {
186
180
  projectRoot: project,
187
181
  };
188
182
  expect(strategy.inject(input, provider)).toBe('injected');
189
- expect(strategy.injectProject(project, provider, 'cursor')).toBe('injected'); // carrier only
183
+ expect(strategy.injectProject(project, provider, 'cursor')).toBe('injected'); // awm.mdc carrier
190
184
  const written = fs_1.default.readFileSync(path_1.default.join(project, 'AGENTS.md'), 'utf8');
191
- expect(written).toContain('MUST invoke skills per policy');
192
185
  expect(written).toContain('Read and obey `CONSTITUTION.md` before work');
186
+ expect(written).not.toContain('MUST invoke skills');
187
+ expect(written).not.toContain('# AWM');
193
188
  expect(strategy.status(input, provider)).toBe('injected');
194
- // Idempotent: a second full pass (context re-inject, then constitution/carrier) changes nothing.
189
+ const mdc = fs_1.default.readFileSync(path_1.default.join(project, '.cursor/rules/awm.mdc'), 'utf8');
190
+ expect(mdc).toContain('alwaysApply: true');
191
+ expect(mdc).toContain('Read and obey `CONSTITUTION.md` before work');
195
192
  expect(strategy.inject(input, provider)).toBe('unchanged');
196
193
  expect(strategy.injectProject(project, provider, 'cursor')).toBe('unchanged');
197
- expect(fs_1.default.readFileSync(path_1.default.join(project, 'AGENTS.md'), 'utf8')).toBe(written);
194
+ });
195
+ it('Copilot inject still writes full using-awm plus PROJECT_GUIDANCE (sole carrier)', () => {
196
+ const project = path_1.default.join(tmpWork, 'copilot-full-context');
197
+ fs_1.default.mkdirSync(project, { recursive: true });
198
+ const materialized = path_1.default.join(project, '.awm/context/awm-context.md');
199
+ const contextMarkdown = '# AWM\n\nUse `development-process`. MUST invoke skills per policy.';
200
+ fs_1.default.mkdirSync(path_1.default.dirname(materialized), { recursive: true });
201
+ fs_1.default.writeFileSync(materialized, contextMarkdown);
202
+ const provider = copilotProvider();
203
+ const strategy = new codex_agents_1.CodexAgentsStrategy();
204
+ const input = {
205
+ ref: { absPath: materialized, scope: 'local', contentHash: (0, provider_1.sha256)(contextMarkdown) },
206
+ registryRoot: '/registry',
207
+ installMethod: 'copy',
208
+ agent: 'copilot',
209
+ scope: 'local',
210
+ projectRoot: project,
211
+ };
212
+ expect(strategy.inject(input, provider)).toBe('injected');
213
+ const written = fs_1.default.readFileSync(path_1.default.join(project, 'AGENTS.md'), 'utf8');
214
+ expect(written).toContain('MUST invoke skills per policy');
215
+ expect(written).toContain('Read and obey `CONSTITUTION.md` before work');
216
+ expect(strategy.status(input, provider)).toBe('injected');
198
217
  });
199
218
  it('implements global status and remove while preserving user bytes', () => {
200
219
  const file = path_1.default.join(tmpHome, '.codex/AGENTS.md');
@@ -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
+ });