agentic-workflow-manager 3.11.0 → 3.13.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 (43) hide show
  1. package/dist/src/commands/doctor.js +1 -1
  2. package/dist/src/commands/preflight/checks.js +63 -5
  3. package/dist/src/commands/preflight/index.js +6 -1
  4. package/dist/src/commands/sensors/baseline.js +4 -3
  5. package/dist/src/core/context/materializer.js +7 -0
  6. package/dist/src/core/context/orchestrator.js +26 -6
  7. package/dist/src/core/context/strategies/codex-agents.js +69 -15
  8. package/dist/src/core/diagnostics/context.js +11 -6
  9. package/dist/src/core/diagnostics/provider-checks.js +92 -11
  10. package/dist/src/core/init/mutation-targets.js +18 -2
  11. package/dist/src/core/init/provider-facts.js +5 -4
  12. package/dist/src/core/init/steps.js +16 -2
  13. package/dist/src/core/install-planner.js +56 -6
  14. package/dist/src/core/install-transaction.js +55 -6
  15. package/dist/src/core/provider-artifacts.js +1 -1
  16. package/dist/src/core/renderers/copilot-instructions.js +28 -0
  17. package/dist/src/core/renderers/cursor-mdc.js +49 -0
  18. package/dist/src/core/renderers/skill-source.js +50 -0
  19. package/dist/src/core/skill-integrity.js +1 -1
  20. package/dist/src/index.js +8 -0
  21. package/dist/src/providers/index.js +69 -2
  22. package/dist/tests/commands/add.test.js +96 -0
  23. package/dist/tests/commands/doctor.test.js +25 -0
  24. package/dist/tests/commands/init.test.js +56 -0
  25. package/dist/tests/commands/preflight/preflight.test.js +95 -0
  26. package/dist/tests/core/bundle-install.test.js +63 -0
  27. package/dist/tests/core/context/materializer.test.js +8 -0
  28. package/dist/tests/core/context/orchestrator.test.js +51 -0
  29. package/dist/tests/core/context/strategies/codex-agents.test.js +157 -20
  30. package/dist/tests/core/diagnostics/checks.test.js +1 -0
  31. package/dist/tests/core/diagnostics/provider-tier.test.js +292 -0
  32. package/dist/tests/core/init/mutation-targets.test.js +63 -0
  33. package/dist/tests/core/init/provider-facts.test.js +16 -0
  34. package/dist/tests/core/init/steps.test.js +37 -0
  35. package/dist/tests/core/install-planner.test.js +118 -0
  36. package/dist/tests/core/install-transaction.test.js +109 -0
  37. package/dist/tests/core/provider-artifacts.test.js +11 -0
  38. package/dist/tests/core/renderers/copilot-instructions.test.js +47 -0
  39. package/dist/tests/core/renderers/cursor-mdc.test.js +137 -0
  40. package/dist/tests/core/skill-integrity.test.js +18 -0
  41. package/dist/tests/providers/index.test.js +45 -1
  42. package/dist/tests/providers/injection-config.test.js +16 -0
  43. package/package.json +1 -1
@@ -19,6 +19,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
19
19
  const fs_1 = __importDefault(require("fs"));
20
20
  const os_1 = __importDefault(require("os"));
21
21
  const path_1 = __importDefault(require("path"));
22
+ const materializer_1 = require("../../../src/core/context/materializer");
22
23
  function bundle(name, scope, skills) {
23
24
  return {
24
25
  name, description: '', version: '1.0.0', scope, visibility: 'public',
@@ -146,6 +147,8 @@ describe('planInitMutationTargets', () => {
146
147
  ];
147
148
  const targets = planInitMutationTargets({ cwd: bareCwd(), agent: 'claude-code', bundles });
148
149
  const skillsDir = providerFor('claude-code').skill.global;
150
+ if (skillsDir === null)
151
+ throw new Error('claude-code skill.global must not be null');
149
152
  expect(targets).toContain(path_1.default.join(skillsDir, 'using-awm'));
150
153
  expect(targets).toContain(path_1.default.join(skillsDir, 'ambient-skill'));
151
154
  // project-scope bundles are only enumerated via .awm/profile.json
@@ -192,6 +195,55 @@ describe('planInitMutationTargets', () => {
192
195
  expect(targets.some((t) => t.endsWith(path_1.default.join('.awm', 'profile.json')))).toBe(false);
193
196
  expect(targets.some((t) => t.endsWith(path_1.default.join('.awm', 'sensors.json')))).toBe(false);
194
197
  });
198
+ it('includes the materialized .awm/context/awm-context.md path for a local-scope-context provider (cursor)', () => {
199
+ // Finding: stepContextInjection (steps.ts) materializes its source content
200
+ // under <projectRoot>/.awm/context/awm-context.md BEFORE injecting it into
201
+ // the local AGENTS.md/.mdc target, for any provider whose managed-agents-md
202
+ // injection has no global path (globalPath === null). That materialized
203
+ // write is a real one this run can make and must be covered by the backup
204
+ // session, same reasoning as the target file itself just below.
205
+ const { planInitMutationTargets, providerFor } = load();
206
+ const projectRoot = makeProjectRoot();
207
+ const provider = providerFor('cursor');
208
+ expect(provider.injection?.type).toBe('managed-agents-md');
209
+ expect(provider.injection.globalPath).toBeNull();
210
+ const targets = planInitMutationTargets({ cwd: projectRoot, agent: 'cursor', bundles: [] });
211
+ expect(targets).toContain((0, materializer_1.projectContextPath)(projectRoot));
212
+ });
213
+ it('includes the materialized .awm/context/awm-context.md path for a local-scope-context provider (copilot)', () => {
214
+ const { planInitMutationTargets, providerFor } = load();
215
+ const projectRoot = makeProjectRoot();
216
+ const provider = providerFor('copilot');
217
+ expect(provider.injection?.type).toBe('managed-agents-md');
218
+ expect(provider.injection.globalPath).toBeNull();
219
+ const targets = planInitMutationTargets({ cwd: projectRoot, agent: 'copilot', bundles: [] });
220
+ expect(targets).toContain((0, materializer_1.projectContextPath)(projectRoot));
221
+ });
222
+ it('does NOT include the materialized .awm/context/awm-context.md path for a global-scope-context provider (codex)', () => {
223
+ // Codex's managed-agents-md injection has a non-null globalPath, so its
224
+ // context is delivered at GLOBAL scope — the local materialized-source
225
+ // path is never written for it and must not be enumerated.
226
+ const { planInitMutationTargets, providerFor } = load();
227
+ const projectRoot = makeProjectRoot();
228
+ const provider = providerFor('codex');
229
+ expect(provider.injection.globalPath).not.toBeNull();
230
+ const targets = planInitMutationTargets({ cwd: projectRoot, agent: 'codex', bundles: [] });
231
+ expect(targets).not.toContain((0, materializer_1.projectContextPath)(projectRoot));
232
+ });
233
+ it('includes .cursor/rules/awm.mdc (the redundant carrier) only for agent === cursor', () => {
234
+ const { planInitMutationTargets } = load();
235
+ const projectRoot = makeProjectRoot();
236
+ const cursorTargets = planInitMutationTargets({ cwd: projectRoot, agent: 'cursor', bundles: [] });
237
+ expect(cursorTargets).toContain(path_1.default.join(projectRoot, '.cursor', 'rules', 'awm.mdc'));
238
+ });
239
+ it('does NOT include .cursor/rules/awm.mdc for copilot or other agents', () => {
240
+ const { planInitMutationTargets } = load();
241
+ const projectRoot = makeProjectRoot();
242
+ for (const agent of ['copilot', 'codex', 'claude-code', 'opencode', 'antigravity']) {
243
+ const targets = planInitMutationTargets({ cwd: projectRoot, agent, bundles: [] });
244
+ expect(targets).not.toContain(path_1.default.join(projectRoot, '.cursor', 'rules', 'awm.mdc'));
245
+ }
246
+ });
195
247
  it('ignores an extension named in the profile that no longer resolves to a known bundle', () => {
196
248
  const { planInitMutationTargets, providerFor } = load();
197
249
  const projectRoot = makeProjectRoot();
@@ -212,6 +264,15 @@ describe('planInitMutationTargets', () => {
212
264
  expect(targets).toContain(providerFor(agent).skill.global);
213
265
  }
214
266
  });
267
+ it('Gap C — adds no global skills-directory target for an agent whose skill.global is null (copilot), and does not crash', () => {
268
+ const { planInitMutationTargets, providerFor } = load();
269
+ expect(providerFor('copilot').skill.global).toBeNull();
270
+ const targets = planInitMutationTargets({ cwd: bareCwd(), agent: 'copilot', bundles: [] });
271
+ // No null ever lands in the returned target list, and nothing crashes
272
+ // trying to path.join/backup a null skills directory.
273
+ expect(targets).not.toContain(null);
274
+ expect(targets.every((t) => typeof t === 'string')).toBe(true);
275
+ });
215
276
  it('the skills-directory target covers orphaned entries repairGlobalSkills would mutate, not just bundle-derived subpaths', () => {
216
277
  // Regression guard for the logic gap: before the fix, only
217
278
  // bundle-DERIVED skill paths (e.g. .../skills/using-awm) were
@@ -225,6 +286,8 @@ describe('planInitMutationTargets', () => {
225
286
  const bundles = [bundle('dev-core', 'baseline', ['using-awm'])];
226
287
  const targets = planInitMutationTargets({ cwd: bareCwd(), agent: 'claude-code', bundles });
227
288
  const skillsDir = providerFor('claude-code').skill.global;
289
+ if (skillsDir === null)
290
+ throw new Error('claude-code skill.global must not be null');
228
291
  expect(targets).toContain(skillsDir);
229
292
  // Sanity: the orphan path itself is a child of the now-covered dir,
230
293
  // even though it is never separately enumerated.
@@ -74,6 +74,22 @@ describe('gatherProviderFacts / assertClaudeBaselinePreserved', () => {
74
74
  const after = gatherProviderFacts('claude-code');
75
75
  expect(before.hash).not.toBe(after.hash);
76
76
  });
77
+ it('Gap C — gatherProviderFacts skips a null skill.global (copilot) cleanly instead of crashing', () => {
78
+ const { gatherProviderFacts } = require('../../../src/core/init/provider-facts');
79
+ const { providerFor } = require('../../../src/providers');
80
+ expect(providerFor('copilot').skill.global).toBeNull();
81
+ const { assertClaudeBaselinePreserved } = require('../../../src/core/init/provider-facts');
82
+ const facts = gatherProviderFacts('copilot');
83
+ // providerManagedPaths guards `provider.skill.global !== null` (and every
84
+ // other managed path copilot lacks: no hooks, no global injection, no
85
+ // workflow/agent config) before adding anything — copilot manages NOTHING
86
+ // at the machine level, so the inspected path list is empty, and the call
87
+ // itself must not throw.
88
+ expect(facts.paths).toEqual([]);
89
+ expect(typeof facts.hash).toBe('string');
90
+ // Two snapshots of the same (empty) state are still identical/no-throw.
91
+ expect(() => assertClaudeBaselinePreserved(facts, facts)).not.toThrow();
92
+ });
77
93
  it('throws a distinct message when comparing facts for mismatched agents', () => {
78
94
  const { gatherProviderFacts, assertClaudeBaselinePreserved } = require('../../../src/core/init/provider-facts');
79
95
  const claude = gatherProviderFacts('claude-code');
@@ -389,6 +389,17 @@ describe('stepGlobalSkillsRepair', () => {
389
389
  expect(r.action).toBe('applied');
390
390
  expect(a.repairGlobalSkills).toHaveBeenCalledWith((0, providers_1.providerFor)('opencode').skill.global, expect.any(Array));
391
391
  });
392
+ it('Gap C — skips cleanly for an agent whose skill.global is null (copilot), even with broken links reported', () => {
393
+ const a = spies();
394
+ expect((0, providers_1.providerFor)('copilot').skill.global).toBeNull();
395
+ const m = machine();
396
+ // Broken-count is nonzero, so the ONLY thing that can make this skip is the
397
+ // null-global-dir guard itself, not the "nothing broken" early return above.
398
+ m.globalSkills = { valid: [], repairable: ['b'], dead: ['c'] };
399
+ const r = (0, steps_1.stepGlobalSkillsRepair)(deps({ machine: m, project: null }, a, { agent: 'copilot' }));
400
+ expect(r.action).toBe('skipped');
401
+ expect(a.repairGlobalSkills).not.toHaveBeenCalled();
402
+ });
392
403
  });
393
404
  describe('stepConstitutionInjection (#6)', () => {
394
405
  it('injects for a config-instructions agent when CONSTITUTION.md is present', () => {
@@ -464,4 +475,30 @@ describe('stepContextInjection', () => {
464
475
  expect(r.action).toBe('applied');
465
476
  expect(a.installContext).toHaveBeenCalledWith(expect.objectContaining({ agent: 'codex', scope: 'global' }));
466
477
  });
478
+ it('installs Copilot context at local scope with projectRoot (no global AGENTS.md-equivalent)', () => {
479
+ const a = spies();
480
+ a.contextStatus.mockReturnValue('absent');
481
+ const r = (0, steps_1.stepContextInjection)(deps({ machine: machine(), project: null }, a, { agent: 'copilot', cwd: '/repo' }));
482
+ expect(r.action).toBe('applied');
483
+ expect(a.installContext).toHaveBeenCalledWith(expect.objectContaining({ agent: 'copilot', scope: 'local', projectRoot: '/repo' }));
484
+ });
485
+ it('regression: uses the discovered project root, not raw cwd, when awm init runs from a subdirectory (R4 QA blocker 2b)', () => {
486
+ // mutation-targets.ts's planInitMutationTargets computes its local-scope backup
487
+ // target via findProjectRoot(cwd), which walks UP from cwd to the real project
488
+ // root. If this step passed raw d.cwd as projectRoot instead, a run from a
489
+ // subdirectory would write to <cwd>/AGENTS.md while the backup session snapshotted
490
+ // <root>/AGENTS.md — a failed init's rollback would miss the real write entirely.
491
+ const a = spies();
492
+ a.contextStatus.mockReturnValue('absent');
493
+ const r = (0, steps_1.stepContextInjection)(deps({ machine: machine(), project: project({ root: '/repo' }) }, a, { agent: 'copilot', cwd: '/repo/packages/sub' }));
494
+ expect(r.action).toBe('applied');
495
+ expect(a.installContext).toHaveBeenCalledWith(expect.objectContaining({ agent: 'copilot', scope: 'local', projectRoot: '/repo' }));
496
+ });
497
+ it('falls back to raw cwd when no project was discovered', () => {
498
+ const a = spies();
499
+ a.contextStatus.mockReturnValue('absent');
500
+ const r = (0, steps_1.stepContextInjection)(deps({ machine: machine(), project: null }, a, { agent: 'copilot', cwd: '/nowhere' }));
501
+ expect(r.action).toBe('applied');
502
+ expect(a.installContext).toHaveBeenCalledWith(expect.objectContaining({ agent: 'copilot', scope: 'local', projectRoot: '/nowhere' }));
503
+ });
467
504
  });
@@ -71,6 +71,19 @@ describe('install-planner', () => {
71
71
  owners,
72
72
  };
73
73
  }
74
+ describe('agentsSharingSkillTarget', () => {
75
+ it('excludes a candidate that does not support this scope at all, instead of throwing', () => {
76
+ // Regression: used to call skillTargetDir(candidate, ...) unguarded
77
+ // inside .filter() — Copilot at 'global' throws (no global skill
78
+ // dir), which crashed the whole call for every OTHER candidate too.
79
+ const group = (0, install_planner_1.agentsSharingSkillTarget)('claude-code', ['claude-code', 'copilot'], 'global', tmpWork);
80
+ expect(group).toEqual(['claude-code']);
81
+ });
82
+ it('still finds real shared targets when a non-sharing, scope-unsupported candidate is also present', () => {
83
+ const group = (0, install_planner_1.agentsSharingSkillTarget)('opencode', ['opencode', 'codex', 'copilot'], 'global', tmpWork);
84
+ expect(group.sort()).toEqual(['codex', 'opencode']);
85
+ });
86
+ });
74
87
  describe('planInstall', () => {
75
88
  it('deduplicates the OpenCode/Codex physical skill write and reports both owners', () => {
76
89
  const plan = (0, install_planner_1.planInstall)({
@@ -105,6 +118,26 @@ describe('install-planner', () => {
105
118
  });
106
119
  expect(plan.operations[0].owners).toEqual(['codex']); // verifies R13
107
120
  });
121
+ it('does not crash when a provider with no global skill support (Copilot) is merely enabled, not selected', () => {
122
+ // Regression: assertCompleteSharedGroup's inner `enabled.filter(...)`
123
+ // used to call physicalTarget() unguarded for every enabled agent,
124
+ // including ones that don't support this scope at all (Copilot has
125
+ // no global skill directory — skill.global is null). That threw
126
+ // inside the filter callback, uncaught, crashing this ENTIRE
127
+ // install for claude-code even though Copilot has nothing to do
128
+ // with it — just having Copilot in enabledAgents (e.g. from an
129
+ // earlier local-scope install) broke every subsequent global
130
+ // install for every other agent.
131
+ const plan = (0, install_planner_1.planInstall)({
132
+ artifacts: [skillArtifact('development-process')],
133
+ selectedAgents: ['claude-code'],
134
+ enabledAgents: ['claude-code', 'copilot'],
135
+ scope: 'global',
136
+ projectRoot: tmpWork,
137
+ method: 'symlink',
138
+ });
139
+ expect(plan.operations[0].owners).toEqual(['claude-code']);
140
+ });
108
141
  it('returns no operations for an empty artifacts array', () => {
109
142
  const plan = (0, install_planner_1.planInstall)({
110
143
  artifacts: [],
@@ -186,6 +219,91 @@ describe('install-planner', () => {
186
219
  method: 'symlink',
187
220
  })).toThrow(/physical target already claimed by a different source/);
188
221
  });
222
+ describe('renderer-driven filename computation (Task 4.3)', () => {
223
+ it('renders the Cursor skill target with a .mdc extension, stripping any pre-existing extension', () => {
224
+ const plan = (0, install_planner_1.planInstall)({
225
+ artifacts: [skillArtifact('development-process')],
226
+ selectedAgents: ['cursor'],
227
+ enabledAgents: ['cursor'],
228
+ scope: 'local',
229
+ projectRoot: tmpWork,
230
+ method: 'symlink',
231
+ });
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');
234
+ });
235
+ it('renders the Copilot skill target with a .instructions.md extension (not .md.instructions.md)', () => {
236
+ const plan = (0, install_planner_1.planInstall)({
237
+ artifacts: [skillArtifact('development-process')],
238
+ selectedAgents: ['copilot'],
239
+ enabledAgents: ['copilot'],
240
+ scope: 'local',
241
+ projectRoot: tmpWork,
242
+ method: 'symlink',
243
+ });
244
+ expect(plan.operations[0].targetPath).toBe(path_1.default.join(tmpWork, '.github', 'instructions', 'development-process.instructions.md'));
245
+ expect(plan.operations[0].renderer).toBe('copilot-instructions');
246
+ });
247
+ it('strips an installName that already carries an extension before appending .instructions.md', () => {
248
+ const withExtension = {
249
+ name: 'development-process',
250
+ installName: 'development-process.md',
251
+ type: 'skill',
252
+ sourcePath: path_1.default.join(tmpWork, 'registry', 'skills', 'development-process'),
253
+ };
254
+ const plan = (0, install_planner_1.planInstall)({
255
+ artifacts: [withExtension],
256
+ selectedAgents: ['copilot'],
257
+ enabledAgents: ['copilot'],
258
+ scope: 'local',
259
+ projectRoot: tmpWork,
260
+ method: 'symlink',
261
+ });
262
+ expect(path_1.default.basename(plan.operations[0].targetPath)).toBe('development-process.instructions.md');
263
+ });
264
+ it('does not truncate an installName with an embedded, non-extension dot (e.g. "v1.2-migration")', () => {
265
+ // Regression: physicalTarget used to derive the base name via
266
+ // path.parse(...).name, which strips everything after the LAST
267
+ // dot — not just a genuine trailing .md extension. A skill
268
+ // literally named `v1.2-migration` would silently truncate to
269
+ // `v1`, dropping `2-migration` and risking a collision with any
270
+ // other skill named `v1`.
271
+ const dottedName = {
272
+ name: 'v1.2-migration',
273
+ installName: 'v1.2-migration',
274
+ type: 'skill',
275
+ sourcePath: path_1.default.join(tmpWork, 'registry', 'skills', 'v1.2-migration'),
276
+ };
277
+ const plan = (0, install_planner_1.planInstall)({
278
+ artifacts: [dottedName],
279
+ selectedAgents: ['cursor'],
280
+ enabledAgents: ['cursor'],
281
+ scope: 'local',
282
+ projectRoot: tmpWork,
283
+ method: 'symlink',
284
+ });
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');
287
+ });
288
+ it('still strips a real trailing .md extension for Cursor (not v1.2-migration.md.mdc)', () => {
289
+ const withMdExtension = {
290
+ name: 'using-awm',
291
+ installName: 'using-awm.md',
292
+ type: 'skill',
293
+ sourcePath: path_1.default.join(tmpWork, 'registry', 'skills', 'using-awm'),
294
+ };
295
+ const plan = (0, install_planner_1.planInstall)({
296
+ artifacts: [withMdExtension],
297
+ selectedAgents: ['cursor'],
298
+ enabledAgents: ['cursor'],
299
+ scope: 'local',
300
+ projectRoot: tmpWork,
301
+ method: 'symlink',
302
+ });
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');
305
+ });
306
+ });
189
307
  });
190
308
  describe('planRemoval', () => {
191
309
  it('retains a target while a non-selected enabled owner remains', () => {
@@ -198,6 +198,115 @@ describe('applyInstallPlan', () => {
198
198
  });
199
199
  });
200
200
  });
201
+ describe('defaultTransactionDeps — cursor-mdc / copilot-instructions renderers (Task 4.3)', () => {
202
+ // Both renderers are always 'skill'-type: sourcePath is the skill's
203
+ // DIRECTORY (install-transaction.ts's readSkillMdSource), matching how
204
+ // discovery.ts/bundle-install.ts set ArtifactIntent.sourcePath for
205
+ // skills — mirrors the shape makeOp() already assumes for 'link' skills.
206
+ function makeSkillSource(description, body) {
207
+ const dir = fs_1.default.mkdtempSync(path_1.default.join(tmpWork, 'skill-source-'));
208
+ fs_1.default.writeFileSync(path_1.default.join(dir, 'SKILL.md'), `---\nname: sample-skill\ndescription: ${description}\n---\n\n${body}\n`);
209
+ return dir;
210
+ }
211
+ it('renders a real Cursor .mdc file through the full validate/stage/replace/verify pipeline', () => {
212
+ const sourceDir = makeSkillSource('A sample skill', 'Body content for the skill.');
213
+ const targetPath = path_1.default.join(tmpWork, 'sample-skill.mdc');
214
+ const plan = {
215
+ operations: [makeOp('sample-skill', {
216
+ type: 'skill', renderer: 'cursor-mdc', output: 'cursor-mdc',
217
+ sourcePath: sourceDir, targetPath,
218
+ })],
219
+ records: [],
220
+ reports: [{ owner: 'cursor', targetPath, action: 'install' }],
221
+ };
222
+ const summary = (0, install_transaction_1.applyInstallPlan)(plan);
223
+ expect(summary.modifiedFiles).toEqual([targetPath]);
224
+ const content = fs_1.default.readFileSync(targetPath, 'utf8');
225
+ expect(content).toContain('description: A sample skill');
226
+ expect(content).toContain('alwaysApply: false');
227
+ expect(content).toContain('Body content for the skill.');
228
+ });
229
+ it('renders a real Copilot .instructions.md file through the full validate/stage/replace/verify pipeline', () => {
230
+ const sourceDir = makeSkillSource('A sample skill', 'Body content for the skill.');
231
+ const targetPath = path_1.default.join(tmpWork, 'sample-skill.instructions.md');
232
+ const plan = {
233
+ operations: [makeOp('sample-skill', {
234
+ type: 'skill', renderer: 'copilot-instructions', output: 'copilot-instructions',
235
+ sourcePath: sourceDir, targetPath,
236
+ })],
237
+ records: [],
238
+ reports: [{ owner: 'copilot', targetPath, action: 'install' }],
239
+ };
240
+ const summary = (0, install_transaction_1.applyInstallPlan)(plan);
241
+ expect(summary.modifiedFiles).toEqual([targetPath]);
242
+ const content = fs_1.default.readFileSync(targetPath, 'utf8');
243
+ expect(content).toContain('applyTo: "**"');
244
+ expect(content).toContain('Body content for the skill.');
245
+ });
246
+ it('validate rejects a malformed skill source (missing description) before any backup/replace happens', () => {
247
+ const dir = fs_1.default.mkdtempSync(path_1.default.join(tmpWork, 'skill-source-'));
248
+ fs_1.default.writeFileSync(path_1.default.join(dir, 'SKILL.md'), '---\nname: broken\n---\nBody with no description field.');
249
+ const targetPath = path_1.default.join(tmpWork, 'broken.mdc');
250
+ fs_1.default.writeFileSync(targetPath, 'pre-existing content');
251
+ const plan = {
252
+ operations: [makeOp('broken', {
253
+ type: 'skill', renderer: 'cursor-mdc', output: 'cursor-mdc',
254
+ sourcePath: dir, targetPath,
255
+ })],
256
+ records: [],
257
+ reports: [{ owner: 'cursor', targetPath, action: 'install' }],
258
+ };
259
+ expect(() => (0, install_transaction_1.applyInstallPlan)(plan)).toThrow();
260
+ // No backup/replace should have touched the pre-existing target.
261
+ expect(fs_1.default.readFileSync(targetPath, 'utf8')).toBe('pre-existing content');
262
+ });
263
+ it('verify rejects a corrupt/malformed staged .mdc (mirrors the codex-agent-toml malformed-verify case)', () => {
264
+ const sourceDir = makeSkillSource('A sample skill', 'Body content.');
265
+ const targetPath = path_1.default.join(tmpWork, 'corrupt.mdc');
266
+ const plan = {
267
+ operations: [makeOp('corrupt', {
268
+ type: 'skill', renderer: 'cursor-mdc', output: 'cursor-mdc',
269
+ sourcePath: sourceDir, targetPath,
270
+ })],
271
+ records: [],
272
+ reports: [{ owner: 'cursor', targetPath, action: 'install' }],
273
+ };
274
+ const deps = {
275
+ ...(0, install_transaction_1.defaultTransactionDeps)(),
276
+ stage(op) {
277
+ const parent = path_1.default.dirname(op.targetPath);
278
+ fs_1.default.mkdirSync(parent, { recursive: true });
279
+ const staged = path_1.default.join(parent, `.${path_1.default.basename(op.targetPath)}.corrupt-test.staged`);
280
+ fs_1.default.writeFileSync(staged, 'not a valid rendered .mdc file at all');
281
+ return staged;
282
+ },
283
+ };
284
+ expect(() => (0, install_transaction_1.applyInstallPlan)(plan, deps)).toThrow('does not look like rendered Cursor .mdc');
285
+ });
286
+ it('verify rejects a corrupt/malformed staged .instructions.md', () => {
287
+ const sourceDir = makeSkillSource('A sample skill', 'Body content.');
288
+ const targetPath = path_1.default.join(tmpWork, 'corrupt.instructions.md');
289
+ const plan = {
290
+ operations: [makeOp('corrupt', {
291
+ type: 'skill', renderer: 'copilot-instructions', output: 'copilot-instructions',
292
+ sourcePath: sourceDir, targetPath,
293
+ })],
294
+ records: [],
295
+ reports: [{ owner: 'copilot', targetPath, action: 'install' }],
296
+ };
297
+ const deps = {
298
+ ...(0, install_transaction_1.defaultTransactionDeps)(),
299
+ stage(op) {
300
+ const parent = path_1.default.dirname(op.targetPath);
301
+ fs_1.default.mkdirSync(parent, { recursive: true });
302
+ const staged = path_1.default.join(parent, `.${path_1.default.basename(op.targetPath)}.corrupt-test.staged`);
303
+ fs_1.default.writeFileSync(staged, 'not a valid rendered instructions file at all');
304
+ return staged;
305
+ },
306
+ };
307
+ expect(() => (0, install_transaction_1.applyInstallPlan)(plan, deps)).toThrow('does not look like rendered Copilot instructions');
308
+ });
309
+ });
201
310
  describe('beginBackupSession / restoreBackup', () => {
202
311
  it('backs up existing targets before mutation and restores them on rollback', () => {
203
312
  const fileA = path_1.default.join(tmpWork, 'a.json');
@@ -61,4 +61,15 @@ describe('scanLegacyArtifacts', () => {
61
61
  expect(legacyRemove).not.toHaveBeenCalledWith(agentPath);
62
62
  expect(fs_1.default.existsSync(agentPath)).toBe(true);
63
63
  });
64
+ it('Gap C — an agent with a null global skill dir (copilot) at global scope skips cleanly instead of crashing', () => {
65
+ // Copilot's skill.global is null (no user-level skill discovery mechanism —
66
+ // providers/index.ts) and its skill renderer is 'copilot-instructions', not
67
+ // 'link', so scanLegacyArtifacts's `dir === null` guard (config[scope]) is
68
+ // the same defensive shape this task's null-skip audit covers for the other
69
+ // 4 files. Proves the call is a clean no-op for copilot at 'global' scope —
70
+ // no crash, nothing listed — rather than throwing on a null target dir.
71
+ expect(() => (0, provider_artifacts_1.scanLegacyArtifacts)(['copilot'], 'global')).not.toThrow();
72
+ const listed = (0, provider_artifacts_1.scanLegacyArtifacts)(['copilot'], 'global');
73
+ expect(listed).toEqual([]);
74
+ });
64
75
  });
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const copilot_instructions_1 = require("../../../src/core/renderers/copilot-instructions");
4
+ // Real SKILL.md frontmatter shape (mirrors skills/using-awm/SKILL.md).
5
+ const skill = `---
6
+ name: using-awm
7
+ version: "1.2.3"
8
+ description: Use when starting any development conversation
9
+ ---
10
+
11
+ # Using AWM
12
+
13
+ MUST invoke skills per the tiered policy.
14
+ `;
15
+ it('renders Copilot .instructions.md frontmatter with applyTo: "**" and the body verbatim', () => {
16
+ expect((0, copilot_instructions_1.renderCopilotInstructions)(skill)).toBe(`---
17
+ applyTo: "**"
18
+ ---
19
+
20
+ # Using AWM
21
+
22
+ MUST invoke skills per the tiered policy.
23
+ `);
24
+ });
25
+ it('embeds the body content even when the description contains a colon (description is not itself rendered)', () => {
26
+ const source = `---
27
+ name: colon-skill
28
+ description: Use when starting: development conversation
29
+ ---
30
+
31
+ Body content survives.
32
+ `;
33
+ const rendered = (0, copilot_instructions_1.renderCopilotInstructions)(source);
34
+ expect(rendered).toBe(`---
35
+ applyTo: "**"
36
+ ---
37
+
38
+ Body content survives.
39
+ `);
40
+ });
41
+ it.each([
42
+ ['no frontmatter at all', 'just a plain markdown body'],
43
+ ['missing description', '---\nname: ok\n---\nBody.'],
44
+ ['empty body', '---\nname: ok\ndescription: fine\n---\n'],
45
+ ])('rejects invalid skill sources before rendering (%s)', (_label, source) => {
46
+ expect(() => (0, copilot_instructions_1.renderCopilotInstructions)(source)).toThrow();
47
+ });
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const cursor_mdc_1 = require("../../../src/core/renderers/cursor-mdc");
4
+ // Real SKILL.md frontmatter shape (mirrors skills/using-awm/SKILL.md).
5
+ const skill = `---
6
+ name: using-awm
7
+ version: "1.2.3"
8
+ description: Use when starting any development conversation
9
+ ---
10
+
11
+ # Using AWM
12
+
13
+ MUST invoke skills per the tiered policy.
14
+ `;
15
+ it('renders Cursor .mdc frontmatter as an Agent Requested rule (description + blank globs + alwaysApply: false)', () => {
16
+ expect((0, cursor_mdc_1.renderCursorMdc)(skill)).toBe(`---
17
+ description: Use when starting any development conversation
18
+ globs:
19
+ alwaysApply: false
20
+ ---
21
+
22
+ # Using AWM
23
+
24
+ MUST invoke skills per the tiered policy.
25
+ `);
26
+ });
27
+ it('quotes a description containing a colon so it does not break YAML parsing', () => {
28
+ const source = `---
29
+ name: colon-skill
30
+ description: Use when starting: development conversation
31
+ ---
32
+
33
+ Body content.
34
+ `;
35
+ const rendered = (0, cursor_mdc_1.renderCursorMdc)(source);
36
+ expect(rendered).toContain('description: "Use when starting: development conversation"');
37
+ // Sanity: the naive unquoted form would be invalid YAML (parsed as a
38
+ // nested mapping key) — this asserts we never emit it.
39
+ expect(rendered).not.toContain('description: Use when starting: development conversation');
40
+ });
41
+ it('quotes a description starting with a YAML-special character', () => {
42
+ const source = `---
43
+ name: special-skill
44
+ description: "*starred description"
45
+ ---
46
+
47
+ Body content.
48
+ `;
49
+ const rendered = (0, cursor_mdc_1.renderCursorMdc)(source);
50
+ expect(rendered).toContain('description: "*starred description"');
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.
57
+ const source = `---
58
+ name: block-skill
59
+ description: >-
60
+ This description spans
61
+ multiple lines.
62
+ ---
63
+
64
+ Body content.
65
+ `;
66
+ expect(() => (0, cursor_mdc_1.renderCursorMdc)(source)).toThrow('non-empty description');
67
+ });
68
+ it('quotes a description containing a mid-string " #" (starts a YAML comment, truncating the rest)', () => {
69
+ // Regression: the original YAML_UNSAFE regex only caught `#` at the START
70
+ // of the string — a `#` preceded by whitespace ANYWHERE in a plain scalar
71
+ // also starts a comment. Unquoted, "Use this #important skill" would
72
+ // render as YAML that silently truncates to "Use this".
73
+ const source = `---
74
+ name: hash-skill
75
+ description: Use this #important skill
76
+ ---
77
+
78
+ Body content.
79
+ `;
80
+ const rendered = (0, cursor_mdc_1.renderCursorMdc)(source);
81
+ expect(rendered).toContain('description: "Use this #important skill"');
82
+ expect(rendered).not.toContain('description: Use this #important skill');
83
+ });
84
+ it('quotes a description containing an embedded null byte / control character instead of emitting it raw', () => {
85
+ // Regression: an embedded control/null byte is invalid in a YAML plain
86
+ // scalar regardless of position — the original code's YAML_UNSAFE regex
87
+ // needed the [\x00-\x1f\x7f] class to catch this; without it, a null byte
88
+ // would have been emitted unquoted straight into the frontmatter.
89
+ const nul = String.fromCharCode(0);
90
+ const source = `---
91
+ name: nul-skill
92
+ description: Use this${nul}description
93
+ ---
94
+
95
+ Body content.
96
+ `;
97
+ const rendered = (0, cursor_mdc_1.renderCursorMdc)(source);
98
+ // JSON.stringify \u-escapes control bytes — the rendered frontmatter must
99
+ // carry the escaped, quoted form, never the literal raw byte.
100
+ expect(rendered).toContain(`description: ${JSON.stringify(`Use this${nul}description`)}`);
101
+ expect(rendered).not.toContain(`description: Use this${nul}description\n`);
102
+ });
103
+ it('escapes an embedded DEL (0x7F) byte, which JSON.stringify alone does not escape', () => {
104
+ // Regression: JSON.stringify \u-escapes \x00-\x1F but NOT \x7F (DEL isn't in
105
+ // JSON's own required-escape set), so quoting via JSON.stringify alone would
106
+ // leave a raw, non-conformant DEL byte inside the YAML double-quoted scalar.
107
+ // yamlString must escape it itself after JSON.stringify runs.
108
+ const del = String.fromCharCode(0x7f);
109
+ const source = `---
110
+ name: del-skill
111
+ description: Use this${del}description
112
+ ---
113
+
114
+ Body content.
115
+ `;
116
+ const rendered = (0, cursor_mdc_1.renderCursorMdc)(source);
117
+ expect(rendered).toContain('description: "Use this\\u007fdescription"');
118
+ expect(rendered).not.toContain(`description: Use this${del}description\n`);
119
+ expect(rendered).not.toMatch(new RegExp(`description: "[^"]*${del}`));
120
+ });
121
+ it('leaves a plain description unquoted', () => {
122
+ const source = `---
123
+ name: plain-skill
124
+ description: A perfectly ordinary description
125
+ ---
126
+
127
+ Body content.
128
+ `;
129
+ expect((0, cursor_mdc_1.renderCursorMdc)(source)).toContain('description: A perfectly ordinary description');
130
+ });
131
+ it.each([
132
+ ['no frontmatter at all', 'just a plain markdown body'],
133
+ ['missing description', '---\nname: ok\n---\nBody.'],
134
+ ['empty body', '---\nname: ok\ndescription: fine\n---\n'],
135
+ ])('rejects invalid skill sources before rendering (%s)', (_label, source) => {
136
+ expect(() => (0, cursor_mdc_1.renderCursorMdc)(source)).toThrow();
137
+ });
@@ -98,6 +98,24 @@ describe('reconcileAllSkillLinks (#4 — awm update, all providers)', () => {
98
98
  fs_1.default.rmSync(home, { recursive: true, force: true });
99
99
  }
100
100
  });
101
+ it('Gap C — skips an agent whose skill.global is null (copilot) cleanly instead of crashing', () => {
102
+ const home = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-home-'));
103
+ const prevHome = process.env.HOME;
104
+ process.env.HOME = home;
105
+ try {
106
+ jest.resetModules();
107
+ const { reconcileAllSkillLinks } = require('../../src/core/skill-integrity');
108
+ const { providerFor } = require('../../src/providers');
109
+ expect(providerFor('copilot').skill.global).toBeNull();
110
+ const res = reconcileAllSkillLinks([path_1.default.join(home, 'no-registry')]);
111
+ expect(() => reconcileAllSkillLinks([path_1.default.join(home, 'no-registry')])).not.toThrow();
112
+ expect(res.find((r) => r.agent === 'copilot')).toBeFalsy();
113
+ }
114
+ finally {
115
+ process.env.HOME = prevHome;
116
+ fs_1.default.rmSync(home, { recursive: true, force: true });
117
+ }
118
+ });
101
119
  });
102
120
  describe('repairGlobalSkills', () => {
103
121
  it('re-links repairable to registry content dir and prunes dead; valid untouched; idempotent', () => {