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
@@ -0,0 +1,96 @@
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
+ // tests/commands/add.test.ts
7
+ //
8
+ // Real, unstubbed `awm add <bundle>` end-to-end for the Task 4.3 renderers:
9
+ // runAddBundleCore → (real) addBundle → installBundle → planInstall
10
+ // (install-planner.ts) → applyInstallPlan (install-transaction.ts) →
11
+ // renderCursorMdc/renderCopilotInstructions — proving the full pipeline
12
+ // wires together at the actual CLI command entrypoint, not just at the
13
+ // bundle-install.ts layer (see tests/core/bundle-install.test.ts for the
14
+ // lower-level equivalent). Per CLAUDE.md: isolated HOME/AWM_HOME tmpdirs,
15
+ // never the real ~/.awm.
16
+ const fs_1 = __importDefault(require("fs"));
17
+ const os_1 = __importDefault(require("os"));
18
+ const path_1 = __importDefault(require("path"));
19
+ const add_1 = require("../../src/commands/add");
20
+ const bundles_1 = require("../../src/core/bundles");
21
+ let tmpHome;
22
+ let originalHome;
23
+ let originalAwmHome;
24
+ beforeEach(() => {
25
+ tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-add-e2e-home-'));
26
+ originalHome = process.env.HOME;
27
+ originalAwmHome = process.env.AWM_HOME;
28
+ process.env.HOME = tmpHome;
29
+ process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
30
+ });
31
+ afterEach(() => {
32
+ fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
33
+ if (originalHome === undefined)
34
+ delete process.env.HOME;
35
+ else
36
+ process.env.HOME = originalHome;
37
+ if (originalAwmHome === undefined)
38
+ delete process.env.AWM_HOME;
39
+ else
40
+ process.env.AWM_HOME = originalAwmHome;
41
+ });
42
+ /** A single-bundle content registry: one skill with real description+body. */
43
+ function makeContentFixture() {
44
+ const tmp = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-add-e2e-registry-'));
45
+ fs_1.default.mkdirSync(path_1.default.join(tmp, 'bundles', 'demo'), { recursive: true });
46
+ fs_1.default.mkdirSync(path_1.default.join(tmp, 'skills', 'demo-skill'), { recursive: true });
47
+ fs_1.default.writeFileSync(path_1.default.join(tmp, 'skills', 'demo-skill', 'SKILL.md'), '---\nname: demo-skill\ndescription: A demo skill for add.test.ts e2e\n---\n\nFollow the demo skill body.\n');
48
+ fs_1.default.writeFileSync(path_1.default.join(tmp, 'catalog.json'), JSON.stringify({
49
+ version: 1,
50
+ bundles: [{ name: 'demo', source: './bundles/demo', version: '1.0.0', scope: 'project' }],
51
+ }));
52
+ fs_1.default.writeFileSync(path_1.default.join(tmp, 'bundles', 'demo', 'bundle.json'), JSON.stringify({
53
+ name: 'demo', version: '1.0.0', description: 'Demo', scope: 'project', dependsOn: [],
54
+ skills: ['demo-skill'], workflows: [], agents: [],
55
+ }));
56
+ return tmp;
57
+ }
58
+ function prefsFor(agent) {
59
+ return { defaultAgent: agent, enabledAgents: [agent], installMethod: 'symlink', defaultScope: 'local' };
60
+ }
61
+ /** findProjectRoot (core/profile.ts) needs a recognizable project marker
62
+ * (.git/, package.json, or .awm/profile.json) — a bare tmpdir isn't one. */
63
+ function makeProjectRoot() {
64
+ const projectRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-add-e2e-project-'));
65
+ fs_1.default.writeFileSync(path_1.default.join(projectRoot, 'package.json'), JSON.stringify({ name: 'fixture' }));
66
+ return projectRoot;
67
+ }
68
+ it('awm add demo materializes a real Cursor .mdc file end-to-end', () => {
69
+ const content = makeContentFixture();
70
+ const projectRoot = makeProjectRoot();
71
+ const bundles = (0, bundles_1.discoverBundles)(content);
72
+ const outcome = (0, add_1.runAddBundleCore)({ name: 'demo', agent: 'cursor', cwd: projectRoot }, prefsFor('cursor'), bundles);
73
+ expect(outcome.code).toBe(0);
74
+ const mdcPath = path_1.default.join(projectRoot, '.cursor/rules/demo-skill.mdc');
75
+ expect(fs_1.default.existsSync(mdcPath)).toBe(true);
76
+ const rendered = fs_1.default.readFileSync(mdcPath, 'utf8');
77
+ expect(rendered).toContain('description: A demo skill for add.test.ts e2e');
78
+ expect(rendered).toContain('alwaysApply: false');
79
+ expect(rendered).toContain('Follow the demo skill body.');
80
+ fs_1.default.rmSync(content, { recursive: true, force: true });
81
+ fs_1.default.rmSync(projectRoot, { recursive: true, force: true });
82
+ });
83
+ it('awm add demo materializes a real Copilot .instructions.md file end-to-end', () => {
84
+ const content = makeContentFixture();
85
+ const projectRoot = makeProjectRoot();
86
+ const bundles = (0, bundles_1.discoverBundles)(content);
87
+ const outcome = (0, add_1.runAddBundleCore)({ name: 'demo', agent: 'copilot', cwd: projectRoot }, prefsFor('copilot'), bundles);
88
+ expect(outcome.code).toBe(0);
89
+ const instructionsPath = path_1.default.join(projectRoot, '.github/instructions/demo-skill.instructions.md');
90
+ expect(fs_1.default.existsSync(instructionsPath)).toBe(true);
91
+ const rendered = fs_1.default.readFileSync(instructionsPath, 'utf8');
92
+ expect(rendered).toContain('applyTo: "**"');
93
+ expect(rendered).toContain('Follow the demo skill body.');
94
+ fs_1.default.rmSync(content, { recursive: true, force: true });
95
+ fs_1.default.rmSync(projectRoot, { recursive: true, force: true });
96
+ });
@@ -58,6 +58,22 @@ describe('renderReport', () => {
58
58
  expect(out).toContain('→ skill: project-constitution');
59
59
  });
60
60
  });
61
+ describe('renderProviderReport — capability tier (Task 4.4)', () => {
62
+ it('shows the tier next to each provider label', () => {
63
+ const report = {
64
+ overall: 'healthy',
65
+ providers: [
66
+ { id: 'claude-code', label: 'Claude Code', tier: 'hooks-native', checks: [] },
67
+ { id: 'cursor', label: 'Cursor', tier: 'agents-md-managed', checks: [] },
68
+ { id: 'antigravity', label: 'Antigravity', tier: 'context-only', checks: [] },
69
+ ],
70
+ };
71
+ const out = (0, doctor_1.renderProviderReport)(report);
72
+ expect(out).toContain('Provider: Claude Code (hooks-native)');
73
+ expect(out).toContain('Provider: Cursor (agents-md-managed)');
74
+ expect(out).toContain('Provider: Antigravity (context-only)');
75
+ });
76
+ });
61
77
  describe('runDoctor', () => {
62
78
  let tmpHome;
63
79
  let tmpWork;
@@ -120,6 +136,7 @@ describe('runDoctor', () => {
120
136
  .toEqual(['claude-code', 'opencode', 'codex']); // verifies R12
121
137
  expect(report.providers.find((provider) => provider.id === 'codex'))
122
138
  .toMatchObject({
139
+ tier: 'hooks-native', // Task 4.4
123
140
  checks: expect.arrayContaining([
124
141
  expect.objectContaining({ id: 'binary.version' }),
125
142
  expect.objectContaining({ id: 'skills.global' }),
@@ -129,6 +146,14 @@ describe('runDoctor', () => {
129
146
  }); // verifies R2, R7, R8, R18
130
147
  expect(code).toBe(1);
131
148
  });
149
+ it('includes tier for every provider in JSON output (Task 4.4)', () => {
150
+ writePrefs(prefsWith(['claude-code', 'opencode', 'codex']));
151
+ const code = (0, doctor_1.runDoctor)({ cwd: tmpWork, json: true });
152
+ const report = JSON.parse(stdout());
153
+ expect(report.providers.map((provider) => provider.tier))
154
+ .toEqual(['hooks-native', 'config-managed', 'hooks-native']);
155
+ expect(code).toBe(1);
156
+ });
132
157
  it('reports a clear, specific error for an invalid --agent value, not a generic "internal error"', () => {
133
158
  const errSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
134
159
  try {
@@ -293,6 +293,62 @@ describe('runInit', () => {
293
293
  expect(JSON.parse(fs_1.default.readFileSync(settingsPath, 'utf8'))).toEqual({ pristine: true, unrelated: 'keep' });
294
294
  });
295
295
  // -----------------------------------------------------------------------
296
+ // Gap A (QA panel) — GLOBAL-scope render pipeline for Cursor/Copilot was
297
+ // only ever exercised via LOCAL-scope `awm add`/bundle-install tests
298
+ // (tests/core/bundle-install.test.ts). This drives a real `awm init
299
+ // --agent cursor` through the REAL installBundle/applyInstallPlan path
300
+ // (no installBundle override — only syncCache is stubbed, to avoid a
301
+ // real network clone) and asserts a real rendered .mdc file lands in the
302
+ // GLOBAL ~/.cursor/rules directory with the expected frontmatter shape.
303
+ // -----------------------------------------------------------------------
304
+ it('Gap A — real global-scope render: awm init --agent cursor renders a real .mdc into ~/.cursor/rules', async () => {
305
+ const contentRoot = path_1.default.join(process.env.AWM_HOME, 'registries', 'baseline');
306
+ // A 'hooks' dir (even empty) is what makes capabilityRoot('hooks') resolve to
307
+ // this content root — that's the registryRoot stepContextInjection uses to
308
+ // find skills/using-awm/SKILL.md for the (unrelated) context-injection step.
309
+ fs_1.default.mkdirSync(path_1.default.join(contentRoot, 'hooks'), { recursive: true });
310
+ fs_1.default.mkdirSync(path_1.default.join(contentRoot, 'skills', 'using-awm'), { recursive: true });
311
+ 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');
312
+ fs_1.default.mkdirSync(path_1.default.join(contentRoot, 'bundles', 'dev-core'), { recursive: true });
313
+ fs_1.default.writeFileSync(path_1.default.join(contentRoot, 'catalog.json'), JSON.stringify({
314
+ version: 1,
315
+ bundles: [{ name: 'dev-core', source: './bundles/dev-core', version: '1.0.0', scope: 'baseline' }],
316
+ }));
317
+ fs_1.default.writeFileSync(path_1.default.join(contentRoot, 'bundles', 'dev-core', 'bundle.json'), JSON.stringify({
318
+ name: 'dev-core', version: '1.0.0', description: 'Baseline', scope: 'baseline',
319
+ dependsOn: [], skills: ['using-awm'], workflows: [], agents: [],
320
+ }));
321
+ const projectCwd = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-init-cursor-cwd-'));
322
+ try {
323
+ const { runInit } = require('../../src/commands/init');
324
+ const code = await runInit({
325
+ cwd: projectCwd,
326
+ agent: 'cursor',
327
+ yes: true,
328
+ machineOnly: true,
329
+ // seedBaselineRegistry() writes registries.json pointing at 'baseline'
330
+ // (our manually-seeded content root above) since none exists yet — no
331
+ // real network clone happens because that content root already exists
332
+ // on disk. Only syncCache is stubbed, so stepCache's own sync (the
333
+ // content root above has no `.git`, so it reads as "not yet cloned")
334
+ // doesn't try to shell out to git either.
335
+ actions: { syncCache: async () => { } },
336
+ });
337
+ expect(code).toBeLessThanOrEqual(1);
338
+ const mdcPath = path_1.default.join(tmpHome, '.cursor', 'rules', 'using-awm.mdc');
339
+ expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.cursor', 'rules', 'using-awm'))).toBe(false);
340
+ expect(fs_1.default.existsSync(mdcPath)).toBe(true);
341
+ const rendered = fs_1.default.readFileSync(mdcPath, 'utf8');
342
+ expect(rendered).toContain('description: Use when starting any development conversation');
343
+ expect(rendered).toContain('globs:');
344
+ expect(rendered).toContain('alwaysApply: false');
345
+ expect(rendered).toContain('MUST invoke skills per the tiered policy.');
346
+ }
347
+ finally {
348
+ fs_1.default.rmSync(projectCwd, { recursive: true, force: true });
349
+ }
350
+ });
351
+ // -----------------------------------------------------------------------
296
352
  // Failure evidence — `--json` must honour its contract on the ERROR path
297
353
  // -----------------------------------------------------------------------
298
354
  describe('failure evidence', () => {
@@ -312,6 +312,81 @@ describe('preflight', () => {
312
312
  expect(check(report, 'host').detail).toContain('gitlab detected');
313
313
  });
314
314
  });
315
+ describe('sensors-baseline check (advisory — never changes the exit code)', () => {
316
+ it('nudges toward `awm sensors baseline` when sensors are configured but no baseline exists', () => {
317
+ // The team-rollout gap this addresses: a legacy repo adopts AWM, sensors get
318
+ // configured, and the ratchet mechanism exists to snapshot pre-existing debt —
319
+ // but nothing tells the operator it's there until they hit a wall of red
320
+ // findings and go looking for it.
321
+ const dir = make({
322
+ manifest: { pack: 'js-ts', sensors: { lint: { cmd: 'npx eslint .' } } },
323
+ bins: ['eslint'],
324
+ files: ['package.json'],
325
+ });
326
+ const report = (0, checks_1.preflight)(dir);
327
+ expect(check(report, 'sensors-baseline').ok).toBe(true);
328
+ expect(check(report, 'sensors-baseline').detail).toContain('no baseline yet');
329
+ expect(check(report, 'sensors-baseline').remedy).toContain('awm sensors baseline');
330
+ expect(report.status).toBe('ready');
331
+ });
332
+ it('reports the no-advisory-needed state when a baseline already exists, without nudging', () => {
333
+ const dir = make({
334
+ manifest: { pack: 'js-ts', sensors: { lint: { cmd: 'npx eslint .' } } },
335
+ bins: ['eslint'],
336
+ files: ['package.json'],
337
+ });
338
+ fs_1.default.mkdirSync(path_1.default.join(dir, '.awm'), { recursive: true });
339
+ fs_1.default.writeFileSync(path_1.default.join(dir, '.awm', 'sensors.baseline.json'), JSON.stringify({ lint: [] }));
340
+ const report = (0, checks_1.preflight)(dir);
341
+ expect(check(report, 'sensors-baseline').ok).toBe(true);
342
+ expect(check(report, 'sensors-baseline').detail).toBe('baseline present');
343
+ expect(check(report, 'sensors-baseline').remedy).toBeUndefined();
344
+ expect(report.status).toBe('ready');
345
+ });
346
+ it('is omitted entirely when there is no manifest at all — nothing to baseline without sensors', () => {
347
+ const dir = make();
348
+ const report = (0, checks_1.preflight)(dir);
349
+ expect(report.checks.find(c => c.id === 'sensors-baseline')).toBeUndefined();
350
+ expect(report.status).toBe('not_configured');
351
+ });
352
+ it('does not nudge on a deliberate opt-out (every sensor disabled) — nothing to baseline', () => {
353
+ // Regression: the trigger condition originally checked only manifestExists, so a
354
+ // repo that deliberately opted out (checkManifest's own documented pattern: every
355
+ // sensor `enabled: false`) still got told to run `awm sensors baseline` — nothing
356
+ // to baseline when there's nothing enabled to have findings in the first place.
357
+ const dir = make({
358
+ manifest: { pack: 'js-ts', sensors: { lint: { cmd: 'npx eslint .', enabled: false } } },
359
+ });
360
+ const report = (0, checks_1.preflight)(dir);
361
+ expect(check(report, 'sensors-baseline').ok).toBe(true);
362
+ expect(check(report, 'sensors-baseline').detail).toBe('no enabled sensors — nothing to baseline');
363
+ expect(check(report, 'sensors-baseline').remedy).toBeUndefined();
364
+ });
365
+ it('does not nudge on an unparseable manifest — nothing to baseline', () => {
366
+ const dir = make({ manifest: '{not valid json' });
367
+ const report = (0, checks_1.preflight)(dir);
368
+ expect(check(report, 'sensors-baseline').ok).toBe(true);
369
+ expect(check(report, 'sensors-baseline').detail).toBe('no enabled sensors — nothing to baseline');
370
+ expect(check(report, 'sensors-baseline').remedy).toBeUndefined();
371
+ });
372
+ it('still nudges when the baseline path exists but is not a readable file (e.g. a stray directory)', () => {
373
+ // Regression: checking presence via `fs.existsSync` alone would have reported
374
+ // "baseline present" here, reassuring the operator that debt is suppressed —
375
+ // but the real gate (`readBaseline`, used by `partition()`) treats an unreadable
376
+ // baseline path as "no baseline, nothing suppressed". The advisory must track
377
+ // what the runtime actually does, not just whether something exists at the path.
378
+ const dir = make({
379
+ manifest: { pack: 'js-ts', sensors: { lint: { cmd: 'npx eslint .' } } },
380
+ bins: ['eslint'],
381
+ files: ['package.json'],
382
+ });
383
+ fs_1.default.mkdirSync(path_1.default.join(dir, '.awm', 'sensors.baseline.json'), { recursive: true });
384
+ const report = (0, checks_1.preflight)(dir);
385
+ expect(check(report, 'sensors-baseline').ok).toBe(true);
386
+ expect(check(report, 'sensors-baseline').detail).toContain('no baseline yet');
387
+ expect(check(report, 'sensors-baseline').remedy).toContain('awm sensors baseline');
388
+ });
389
+ });
315
390
  it('tells the operator not to hand a broken harness to an unattended run', () => {
316
391
  const out = (0, preflight_1.formatReport)({
317
392
  status: 'not_configured',
@@ -320,4 +395,24 @@ describe('preflight', () => {
320
395
  expect(out).toContain('unattended');
321
396
  expect(out).toContain('awm sensors init');
322
397
  });
398
+ it('pads the id column to the widest id actually present, not a hardcoded width', () => {
399
+ // Regression: a literal `.padEnd(9)` silently misaligned once `sensors-baseline`
400
+ // (16 chars) was added as a check id — every detail column shifted left of where
401
+ // shorter ids' details landed. The width must be derived from the report itself.
402
+ // Marker prefixes (@@) pin exactly where each detail column starts, independent
403
+ // of the detail text's own content.
404
+ const out = (0, preflight_1.formatReport)({
405
+ status: 'ready',
406
+ checks: [
407
+ { id: 'host', ok: true, detail: '@@marker' },
408
+ { id: 'sensors-baseline', ok: true, detail: '@@marker' },
409
+ ],
410
+ });
411
+ const lines = out.split('\n').filter(l => l.includes('@@marker'));
412
+ expect(lines).toHaveLength(2);
413
+ expect(lines[0].indexOf('@@marker')).toBe(lines[1].indexOf('@@marker'));
414
+ // And the column is genuinely sized to the longest id (16, 'sensors-baseline'),
415
+ // not the old hardcoded 9 — the shorter id's row must carry visible padding.
416
+ expect(lines[0]).toMatch(/host {12,}@@marker/);
417
+ });
323
418
  });
@@ -248,6 +248,69 @@ describe('installBundle', () => {
248
248
  expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.awm/profile.json'))).toBe(true);
249
249
  expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.gitignore'))).toBe(true);
250
250
  });
251
+ it('materializes a skill as a rendered Cursor .mdc via the real default applyInstallPlan (Task 4.3 e2e)', () => {
252
+ const { content, projectRoot, bundles } = makeFixture();
253
+ // s-base's fixture SKILL.md (makeFixture, above) has no `description`
254
+ // field — real for a bare skill fixture, but cursor-mdc.ts's renderer
255
+ // requires one (parseSkillSource). Seed a real description here so
256
+ // this is a genuine end-to-end render, not just a source-existence check.
257
+ 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');
258
+ const base = bundles.find((bundle) => bundle.name === 'base');
259
+ const result = (0, bundle_install_1.installBundle)({
260
+ bundleName: 'base',
261
+ bundles: [base],
262
+ agents: ['cursor'],
263
+ method: 'symlink',
264
+ projectRoot,
265
+ contentDir: content,
266
+ });
267
+ const mdcPath = path_1.default.join(projectRoot, '.cursor/rules/s-base.mdc');
268
+ expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.cursor/rules/s-base'))).toBe(false);
269
+ expect(fs_1.default.existsSync(mdcPath)).toBe(true);
270
+ const rendered = fs_1.default.readFileSync(mdcPath, 'utf8');
271
+ expect(rendered).toContain('description: Base skill for bundle-install fixtures');
272
+ expect(rendered).toContain('alwaysApply: false');
273
+ expect(rendered).toContain('Do the base thing.');
274
+ expect(result.transactionId).toBeTruthy();
275
+ expect(result.modifiedFiles).toContain(mdcPath);
276
+ });
277
+ it('materializes a skill as rendered Copilot .instructions.md via the real default applyInstallPlan (Task 4.3 e2e)', () => {
278
+ const { content, projectRoot, bundles } = makeFixture();
279
+ 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');
280
+ const base = bundles.find((bundle) => bundle.name === 'base');
281
+ const result = (0, bundle_install_1.installBundle)({
282
+ bundleName: 'base',
283
+ bundles: [base],
284
+ agents: ['copilot'],
285
+ method: 'symlink',
286
+ projectRoot,
287
+ contentDir: content,
288
+ });
289
+ const instructionsPath = path_1.default.join(projectRoot, '.github/instructions/s-base.instructions.md');
290
+ expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.github/instructions/s-base'))).toBe(false);
291
+ expect(fs_1.default.existsSync(instructionsPath)).toBe(true);
292
+ const rendered = fs_1.default.readFileSync(instructionsPath, 'utf8');
293
+ expect(rendered).toContain('applyTo: "**"');
294
+ expect(rendered).toContain('Do the base thing.');
295
+ expect(result.transactionId).toBeTruthy();
296
+ expect(result.modifiedFiles).toContain(instructionsPath);
297
+ });
298
+ it('applies real filesystem changes for Cursor + Copilot via addBundle (awm add e2e, no applyPlan override)', () => {
299
+ const { content, projectRoot, bundles } = makeFixture();
300
+ 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');
301
+ const cursorResult = (0, bundle_install_1.addBundle)({
302
+ bundleName: 'base', bundles, agents: ['cursor'],
303
+ method: 'symlink', projectRoot, contentDir: content,
304
+ });
305
+ expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.cursor/rules/s-base.mdc'))).toBe(true);
306
+ expect(cursorResult.recordedExtension).toBe('base');
307
+ const copilotResult = (0, bundle_install_1.addBundle)({
308
+ bundleName: 'base', bundles, agents: ['copilot'],
309
+ method: 'symlink', projectRoot, contentDir: content,
310
+ });
311
+ expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.github/instructions/s-base.instructions.md'))).toBe(true);
312
+ expect(copilotResult.recordedExtension).toBe('base');
313
+ });
251
314
  it('installs a skill shared by two agents (OpenCode + Codex) with exactly one replaceArtifact call, but the summary contains both providers', () => {
252
315
  const { content, projectRoot, bundles } = makeFixture();
253
316
  const base = bundles.find((bundle) => bundle.name === 'base');
@@ -17,6 +17,14 @@ describe('globalContextPath', () => {
17
17
  expect((0, materializer_1.globalContextPath)()).toContain(path_1.default.join('context', 'awm-context.md'));
18
18
  });
19
19
  });
20
+ describe('projectContextPath', () => {
21
+ it('points under <projectRoot>/.awm/context, mirroring globalContextPath\'s shape', () => {
22
+ expect((0, materializer_1.projectContextPath)('/repo')).toBe(path_1.default.join('/repo', '.awm', 'context', 'awm-context.md'));
23
+ });
24
+ it('differs from globalContextPath (distinct materialized-source paths per scope)', () => {
25
+ expect((0, materializer_1.projectContextPath)('/repo')).not.toBe((0, materializer_1.globalContextPath)());
26
+ });
27
+ });
20
28
  describe('materialize', () => {
21
29
  it('writes the content and returns a ref with the matching hash', () => {
22
30
  const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-mat-'));
@@ -8,6 +8,7 @@ const fs_1 = __importDefault(require("fs"));
8
8
  const os_1 = __importDefault(require("os"));
9
9
  const path_1 = __importDefault(require("path"));
10
10
  const orchestrator_1 = require("../../../src/core/context/orchestrator");
11
+ const materializer_1 = require("../../../src/core/context/materializer");
11
12
  jest.mock('../../../src/commands/hooks/install', () => ({ installHook: jest.fn() }));
12
13
  jest.mock('../../../src/commands/hooks/uninstall', () => ({ uninstallHook: jest.fn() }));
13
14
  jest.mock('../../../src/commands/hooks/status', () => ({
@@ -170,3 +171,53 @@ describe('InjectionOrchestrator (codex, managed AGENTS.md strategy)', () => {
170
171
  expect(fs_1.default.readFileSync(agentsPath, 'utf8')).toBe('# User rules\n');
171
172
  });
172
173
  });
174
+ describe('InjectionOrchestrator (local scope materializes under the project, not ~/.awm)', () => {
175
+ let tmpHome;
176
+ let projectRoot;
177
+ let registryRoot;
178
+ let originalHome;
179
+ let originalAwmHome;
180
+ beforeEach(() => {
181
+ tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-orch-home-'));
182
+ projectRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-orch-proj-'));
183
+ registryRoot = tmpRegistry();
184
+ originalHome = process.env.HOME;
185
+ originalAwmHome = process.env.AWM_HOME;
186
+ process.env.HOME = tmpHome;
187
+ process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
188
+ });
189
+ afterEach(() => {
190
+ if (originalHome === undefined)
191
+ delete process.env.HOME;
192
+ else
193
+ process.env.HOME = originalHome;
194
+ if (originalAwmHome === undefined)
195
+ delete process.env.AWM_HOME;
196
+ else
197
+ process.env.AWM_HOME = originalAwmHome;
198
+ fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
199
+ fs_1.default.rmSync(projectRoot, { recursive: true, force: true });
200
+ fs_1.default.rmSync(registryRoot, { recursive: true, force: true });
201
+ });
202
+ it('materializes to projectContextPath(projectRoot) for scope local, never to globalContextPath()', () => {
203
+ const orch = new orchestrator_1.InjectionOrchestrator({
204
+ providerOverride: {
205
+ label: 'Copilot', skill: { global: null, local: '.github/instructions', renderer: 'link' }, workflow: null, agent: null,
206
+ injection: { type: 'managed-agents-md', globalPath: null, localFile: 'AGENTS.md' },
207
+ },
208
+ });
209
+ const op = {
210
+ agent: 'copilot',
211
+ scope: 'local',
212
+ registryRoot,
213
+ installMethod: 'copy',
214
+ profileExtensions: [],
215
+ projectRoot,
216
+ };
217
+ orch.installContext(op);
218
+ expect(fs_1.default.existsSync((0, materializer_1.projectContextPath)(projectRoot))).toBe(true);
219
+ expect(fs_1.default.existsSync((0, materializer_1.globalContextPath)())).toBe(false);
220
+ expect(fs_1.default.existsSync(path_1.default.join(projectRoot, 'AGENTS.md'))).toBe(true);
221
+ expect(orch.contextStatus(op)).toBe('injected');
222
+ });
223
+ });