agentic-workflow-manager 3.11.0 → 3.12.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 (39) hide show
  1. package/dist/src/commands/doctor.js +1 -1
  2. package/dist/src/core/context/materializer.js +7 -0
  3. package/dist/src/core/context/orchestrator.js +26 -6
  4. package/dist/src/core/context/strategies/codex-agents.js +69 -15
  5. package/dist/src/core/diagnostics/context.js +11 -6
  6. package/dist/src/core/diagnostics/provider-checks.js +92 -11
  7. package/dist/src/core/init/mutation-targets.js +18 -2
  8. package/dist/src/core/init/provider-facts.js +5 -4
  9. package/dist/src/core/init/steps.js +16 -2
  10. package/dist/src/core/install-planner.js +56 -6
  11. package/dist/src/core/install-transaction.js +55 -6
  12. package/dist/src/core/provider-artifacts.js +1 -1
  13. package/dist/src/core/renderers/copilot-instructions.js +28 -0
  14. package/dist/src/core/renderers/cursor-mdc.js +49 -0
  15. package/dist/src/core/renderers/skill-source.js +50 -0
  16. package/dist/src/core/skill-integrity.js +1 -1
  17. package/dist/src/index.js +8 -0
  18. package/dist/src/providers/index.js +69 -2
  19. package/dist/tests/commands/add.test.js +96 -0
  20. package/dist/tests/commands/doctor.test.js +25 -0
  21. package/dist/tests/commands/init.test.js +56 -0
  22. package/dist/tests/core/bundle-install.test.js +63 -0
  23. package/dist/tests/core/context/materializer.test.js +8 -0
  24. package/dist/tests/core/context/orchestrator.test.js +51 -0
  25. package/dist/tests/core/context/strategies/codex-agents.test.js +157 -20
  26. package/dist/tests/core/diagnostics/checks.test.js +1 -0
  27. package/dist/tests/core/diagnostics/provider-tier.test.js +292 -0
  28. package/dist/tests/core/init/mutation-targets.test.js +63 -0
  29. package/dist/tests/core/init/provider-facts.test.js +16 -0
  30. package/dist/tests/core/init/steps.test.js +37 -0
  31. package/dist/tests/core/install-planner.test.js +118 -0
  32. package/dist/tests/core/install-transaction.test.js +109 -0
  33. package/dist/tests/core/provider-artifacts.test.js +11 -0
  34. package/dist/tests/core/renderers/copilot-instructions.test.js +47 -0
  35. package/dist/tests/core/renderers/cursor-mdc.test.js +137 -0
  36. package/dist/tests/core/skill-integrity.test.js +18 -0
  37. package/dist/tests/providers/index.test.js +45 -1
  38. package/dist/tests/providers/injection-config.test.js +16 -0
  39. package/package.json +1 -1
@@ -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', () => {
@@ -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
+ });
@@ -38,9 +38,7 @@ describe('CodexAgentsStrategy', () => {
38
38
  fs_1.default.mkdirSync(path_1.default.join(tmpHome, '.codex'), { recursive: true });
39
39
  const file = path_1.default.join(tmpHome, '.codex/AGENTS.md');
40
40
  fs_1.default.writeFileSync(file, '# Personal\n\nDo not delete.\n');
41
- const result = new codex_agents_1.CodexAgentsStrategy().injectGlobal({
42
- markdown: '# AWM\n\nUse `development-process`.',
43
- });
41
+ const result = new codex_agents_1.CodexAgentsStrategy().injectGlobal({ markdown: '# AWM\n\nUse `development-process`.' }, codexProvider(file));
44
42
  expect(result).toBe('injected');
45
43
  const written = fs_1.default.readFileSync(file, 'utf8');
46
44
  expect(written).toContain('# Personal\n\nDo not delete.\n');
@@ -48,18 +46,20 @@ describe('CodexAgentsStrategy', () => {
48
46
  });
49
47
  it('creates the global file and returns unchanged on an idempotent repeat', () => {
50
48
  const strategy = new codex_agents_1.CodexAgentsStrategy();
49
+ const file = path_1.default.join(tmpHome, '.codex/AGENTS.md');
50
+ const provider = codexProvider(file);
51
51
  const context = { markdown: '# AWM\n\nBootstrap.' };
52
- expect(strategy.injectGlobal(context)).toBe('injected');
53
- expect(strategy.injectGlobal(context)).toBe('unchanged');
54
- expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.codex/AGENTS.md'))).toBe(true);
52
+ expect(strategy.injectGlobal(context, provider)).toBe('injected');
53
+ expect(strategy.injectGlobal(context, provider)).toBe('unchanged');
54
+ expect(fs_1.default.existsSync(file)).toBe(true);
55
55
  });
56
56
  it('uses HOME at call time', () => {
57
57
  const firstHome = tmpHome;
58
58
  const secondHome = path_1.default.join(tmpWork, 'second-home');
59
59
  const strategy = new codex_agents_1.CodexAgentsStrategy();
60
- strategy.injectGlobal({ markdown: 'first' });
60
+ strategy.injectGlobal({ markdown: 'first' }, codexProvider(path_1.default.join(firstHome, '.codex/AGENTS.md')));
61
61
  process.env.HOME = secondHome;
62
- strategy.injectGlobal({ markdown: 'second' });
62
+ strategy.injectGlobal({ markdown: 'second' }, codexProvider(path_1.default.join(secondHome, '.codex/AGENTS.md')));
63
63
  expect(fs_1.default.readFileSync(path_1.default.join(firstHome, '.codex/AGENTS.md'), 'utf8')).toContain('first');
64
64
  expect(fs_1.default.readFileSync(path_1.default.join(secondHome, '.codex/AGENTS.md'), 'utf8')).toContain('second');
65
65
  });
@@ -68,9 +68,10 @@ describe('CodexAgentsStrategy', () => {
68
68
  fs_1.default.mkdirSync(path_1.default.dirname(file), { recursive: true });
69
69
  fs_1.default.writeFileSync(file, 'before\n');
70
70
  const strategy = new codex_agents_1.CodexAgentsStrategy();
71
- strategy.injectGlobal({ markdown: 'old' });
71
+ const provider = codexProvider(file);
72
+ strategy.injectGlobal({ markdown: 'old' }, provider);
72
73
  fs_1.default.appendFileSync(file, 'after\n');
73
- strategy.injectGlobal({ markdown: 'new' });
74
+ strategy.injectGlobal({ markdown: 'new' }, provider);
74
75
  expect(fs_1.default.readFileSync(file, 'utf8')).toBe('before\n\n<!-- AWM:START -->\n<!-- AWM:BOUNDARY prefix=1 suffix=1 -->\nnew\n<!-- AWM:END -->\nafter\n');
75
76
  });
76
77
  it('fails on ambiguous global markers without changing the file', () => {
@@ -78,7 +79,7 @@ describe('CodexAgentsStrategy', () => {
78
79
  fs_1.default.mkdirSync(path_1.default.dirname(file), { recursive: true });
79
80
  const ambiguous = '<!-- AWM:START -->\nuser';
80
81
  fs_1.default.writeFileSync(file, ambiguous);
81
- expect(() => new codex_agents_1.CodexAgentsStrategy().injectGlobal({ markdown: 'new' })).toThrow('unmatched');
82
+ expect(() => new codex_agents_1.CodexAgentsStrategy().injectGlobal({ markdown: 'new' }, codexProvider(file))).toThrow('unmatched');
82
83
  expect(fs_1.default.readFileSync(file, 'utf8')).toBe(ambiguous);
83
84
  });
84
85
  it('rejects inline marker examples without changing the global file', () => {
@@ -86,7 +87,7 @@ describe('CodexAgentsStrategy', () => {
86
87
  fs_1.default.mkdirSync(path_1.default.dirname(file), { recursive: true });
87
88
  const ambiguous = '`<!-- AWM:START -->` example and `<!-- AWM:END -->` example';
88
89
  fs_1.default.writeFileSync(file, ambiguous);
89
- expect(() => new codex_agents_1.CodexAgentsStrategy().injectGlobal({ markdown: 'new' })).toThrow('standalone');
90
+ expect(() => new codex_agents_1.CodexAgentsStrategy().injectGlobal({ markdown: 'new' }, codexProvider(file))).toThrow('standalone');
90
91
  expect(fs_1.default.readFileSync(file, 'utf8')).toBe(ambiguous);
91
92
  });
92
93
  it.each([
@@ -117,7 +118,7 @@ describe('CodexAgentsStrategy', () => {
117
118
  fs_1.default.mkdirSync(project, { recursive: true });
118
119
  fs_1.default.writeFileSync(path_1.default.join(project, 'CONSTITUTION.md'), '# Rules\n');
119
120
  fs_1.default.writeFileSync(path_1.default.join(project, 'AGENTS.md'), '# Repo-owned rules\n');
120
- const result = new codex_agents_1.CodexAgentsStrategy().injectProject(project);
121
+ const result = new codex_agents_1.CodexAgentsStrategy().injectProject(project, codexProvider(path_1.default.join(tmpHome, '.codex/AGENTS.md')));
121
122
  expect(result).toBe('injected');
122
123
  const written = fs_1.default.readFileSync(path_1.default.join(project, 'AGENTS.md'), 'utf8');
123
124
  expect(written).toContain('# Repo-owned rules');
@@ -127,11 +128,74 @@ describe('CodexAgentsStrategy', () => {
127
128
  const project = path_1.default.join(tmpWork, 'repo');
128
129
  fs_1.default.mkdirSync(project, { recursive: true });
129
130
  const strategy = new codex_agents_1.CodexAgentsStrategy();
130
- expect(strategy.injectProject(project)).toBe('injected');
131
- expect(strategy.injectProject(project)).toBe('unchanged');
131
+ const provider = codexProvider(path_1.default.join(tmpHome, '.codex/AGENTS.md'));
132
+ expect(strategy.injectProject(project, provider)).toBe('injected');
133
+ expect(strategy.injectProject(project, provider)).toBe('unchanged');
132
134
  expect(fs_1.default.readFileSync(path_1.default.join(project, 'AGENTS.md'), 'utf8'))
133
135
  .toContain('when that file exists');
134
136
  });
137
+ it('injectProject writes a redundant .cursor/rules/awm.mdc carrier (alwaysApply: true) for Cursor, and skips its own AGENTS.md write (owned by inject(), see collision regression below)', () => {
138
+ const project = path_1.default.join(tmpWork, 'cursor-repo');
139
+ fs_1.default.mkdirSync(project, { recursive: true });
140
+ const strategy = new codex_agents_1.CodexAgentsStrategy();
141
+ const result = strategy.injectProject(project, cursorProvider(), 'cursor');
142
+ expect(result).toBe('injected');
143
+ expect(fs_1.default.existsSync(path_1.default.join(project, 'AGENTS.md'))).toBe(false);
144
+ const mdc = fs_1.default.readFileSync(path_1.default.join(project, '.cursor/rules/awm.mdc'), 'utf8');
145
+ expect(mdc).toContain('alwaysApply: true');
146
+ expect(mdc).toContain('Read and obey `CONSTITUTION.md` before work');
147
+ });
148
+ it('injectProject writes only AGENTS.md (no .cursor/rules) for a provider whose context injection is a SEPARATE (global) file', () => {
149
+ const project = path_1.default.join(tmpWork, 'codex-project-repo');
150
+ fs_1.default.mkdirSync(project, { recursive: true });
151
+ const strategy = new codex_agents_1.CodexAgentsStrategy();
152
+ strategy.injectProject(project, codexProvider(path_1.default.join(tmpHome, '.codex/AGENTS.md')));
153
+ expect(fs_1.default.existsSync(path_1.default.join(project, 'AGENTS.md'))).toBe(true);
154
+ expect(fs_1.default.existsSync(path_1.default.join(project, '.cursor'))).toBe(false);
155
+ });
156
+ it('injectProject writes NEITHER AGENTS.md NOR a carrier for Copilot (local-context provider, no carrier mechanism)', () => {
157
+ const project = path_1.default.join(tmpWork, 'copilot-repo');
158
+ fs_1.default.mkdirSync(project, { recursive: true });
159
+ const strategy = new codex_agents_1.CodexAgentsStrategy();
160
+ const result = strategy.injectProject(project, copilotProvider(), 'copilot');
161
+ expect(result).toBe('unchanged');
162
+ expect(fs_1.default.existsSync(path_1.default.join(project, 'AGENTS.md'))).toBe(false);
163
+ expect(fs_1.default.existsSync(path_1.default.join(project, '.cursor'))).toBe(false);
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');
173
+ fs_1.default.mkdirSync(project, { recursive: true });
174
+ const materialized = path_1.default.join(project, '.awm/context/awm-context.md');
175
+ const contextMarkdown = '# AWM\n\nUse `development-process`. MUST invoke skills per policy.';
176
+ fs_1.default.mkdirSync(path_1.default.dirname(materialized), { recursive: true });
177
+ fs_1.default.writeFileSync(materialized, contextMarkdown);
178
+ const provider = cursorProvider();
179
+ const strategy = new codex_agents_1.CodexAgentsStrategy();
180
+ const input = {
181
+ ref: { absPath: materialized, scope: 'local', contentHash: (0, provider_1.sha256)(contextMarkdown) },
182
+ registryRoot: '/registry',
183
+ installMethod: 'copy',
184
+ agent: 'cursor',
185
+ scope: 'local',
186
+ projectRoot: project,
187
+ };
188
+ expect(strategy.inject(input, provider)).toBe('injected');
189
+ expect(strategy.injectProject(project, provider, 'cursor')).toBe('injected'); // carrier only
190
+ const written = fs_1.default.readFileSync(path_1.default.join(project, 'AGENTS.md'), 'utf8');
191
+ expect(written).toContain('MUST invoke skills per policy');
192
+ expect(written).toContain('Read and obey `CONSTITUTION.md` before work');
193
+ expect(strategy.status(input, provider)).toBe('injected');
194
+ // Idempotent: a second full pass (context re-inject, then constitution/carrier) changes nothing.
195
+ expect(strategy.inject(input, provider)).toBe('unchanged');
196
+ expect(strategy.injectProject(project, provider, 'cursor')).toBe('unchanged');
197
+ expect(fs_1.default.readFileSync(path_1.default.join(project, 'AGENTS.md'), 'utf8')).toBe(written);
198
+ });
135
199
  it('implements global status and remove while preserving user bytes', () => {
136
200
  const file = path_1.default.join(tmpHome, '.codex/AGENTS.md');
137
201
  const materialized = path_1.default.join(tmpWork, 'awm-context.md');
@@ -155,15 +219,70 @@ describe('CodexAgentsStrategy', () => {
155
219
  });
156
220
  it('validates public inputs and never writes outside the configured roots', () => {
157
221
  const strategy = new codex_agents_1.CodexAgentsStrategy();
158
- expect(() => strategy.injectGlobal({ markdown: '' })).toThrow('markdown');
159
- expect(() => strategy.injectGlobal(null)).toThrow('context');
160
- expect(() => strategy.injectProject('')).toThrow('projectRoot');
222
+ const provider = codexProvider(path_1.default.join(tmpHome, '.codex/AGENTS.md'));
223
+ expect(() => strategy.injectGlobal({ markdown: '' }, provider)).toThrow('markdown');
224
+ expect(() => strategy.injectGlobal(null, provider)).toThrow('context');
225
+ expect(() => strategy.injectProject('', provider)).toThrow('projectRoot');
161
226
  const open = jest.spyOn(fs_1.default, 'openSync');
162
- strategy.injectGlobal({ markdown: 'safe' });
163
- strategy.injectProject(path_1.default.join(tmpWork, 'safe-project'));
227
+ strategy.injectGlobal({ markdown: 'safe' }, provider);
228
+ strategy.injectProject(path_1.default.join(tmpWork, 'safe-project'), provider);
164
229
  const opened = open.mock.calls.map((call) => String(call[0]));
165
230
  expect(opened.every((file) => file.startsWith(tmpHome) || file.startsWith(tmpWork))).toBe(true);
166
231
  });
232
+ it('regression: Codex (non-null globalPath) still requires global scope — unchanged behavior', () => {
233
+ const file = path_1.default.join(tmpHome, '.codex/AGENTS.md');
234
+ const materialized = path_1.default.join(tmpWork, 'awm-context.md');
235
+ const markdown = '# AWM\n\nExpected.';
236
+ fs_1.default.writeFileSync(materialized, markdown);
237
+ const provider = codexProvider(file);
238
+ const strategy = new codex_agents_1.CodexAgentsStrategy();
239
+ const localInput = {
240
+ ref: { absPath: materialized, scope: 'local', contentHash: (0, provider_1.sha256)(markdown) },
241
+ registryRoot: '/registry',
242
+ installMethod: 'copy',
243
+ agent: 'codex',
244
+ scope: 'local',
245
+ projectRoot: tmpWork,
246
+ };
247
+ expect(() => strategy.inject(localInput, provider)).toThrow('supports only global injection');
248
+ expect(fs_1.default.existsSync(file)).toBe(false);
249
+ });
250
+ it('Copilot-shaped provider (null globalPath): inject() at scope local writes <projectRoot>/AGENTS.md, not any global path', () => {
251
+ const project = path_1.default.join(tmpWork, 'copilot-local-repo');
252
+ fs_1.default.mkdirSync(project, { recursive: true });
253
+ const materialized = path_1.default.join(tmpWork, 'awm-context.md');
254
+ const markdown = '# AWM\n\nCopilot body.';
255
+ fs_1.default.writeFileSync(materialized, markdown);
256
+ const provider = copilotProvider();
257
+ const strategy = new codex_agents_1.CodexAgentsStrategy();
258
+ const input = {
259
+ ref: { absPath: materialized, scope: 'local', contentHash: (0, provider_1.sha256)(markdown) },
260
+ registryRoot: '/registry',
261
+ installMethod: 'copy',
262
+ agent: 'copilot',
263
+ scope: 'local',
264
+ projectRoot: project,
265
+ };
266
+ expect(strategy.inject(input, provider)).toBe('injected');
267
+ const written = fs_1.default.readFileSync(path_1.default.join(project, 'AGENTS.md'), 'utf8');
268
+ expect(written).toContain('Copilot body.');
269
+ expect(strategy.status(input, provider)).toBe('injected');
270
+ });
271
+ it('Copilot-shaped provider (null globalPath): calling at scope global throws (required scope is local)', () => {
272
+ const materialized = path_1.default.join(tmpWork, 'awm-context.md');
273
+ const markdown = '# AWM\n\nBody.';
274
+ fs_1.default.writeFileSync(materialized, markdown);
275
+ const provider = copilotProvider();
276
+ const strategy = new codex_agents_1.CodexAgentsStrategy();
277
+ const globalInput = {
278
+ ref: { absPath: materialized, scope: 'global', contentHash: (0, provider_1.sha256)(markdown) },
279
+ registryRoot: '/registry',
280
+ installMethod: 'copy',
281
+ agent: 'copilot',
282
+ scope: 'global',
283
+ };
284
+ expect(() => strategy.inject(globalInput, provider)).toThrow('supports only local injection');
285
+ });
167
286
  });
168
287
  function codexProvider(globalPath) {
169
288
  return {
@@ -174,6 +293,24 @@ function codexProvider(globalPath) {
174
293
  injection: { type: 'managed-agents-md', globalPath, localFile: 'AGENTS.md' },
175
294
  };
176
295
  }
296
+ function copilotProvider() {
297
+ return {
298
+ label: 'Copilot',
299
+ skill: { global: null, local: '.github/instructions', renderer: 'link' },
300
+ workflow: null,
301
+ agent: null,
302
+ injection: { type: 'managed-agents-md', globalPath: null, localFile: 'AGENTS.md' },
303
+ };
304
+ }
305
+ function cursorProvider() {
306
+ return {
307
+ label: 'Cursor',
308
+ skill: { global: '', local: '.cursor/rules', renderer: 'link' },
309
+ workflow: null,
310
+ agent: null,
311
+ injection: { type: 'managed-agents-md', globalPath: null, localFile: 'AGENTS.md' },
312
+ };
313
+ }
177
314
  function injectionInput(absPath, markdown) {
178
315
  return {
179
316
  ref: { absPath, scope: 'global', contentHash: (0, provider_1.sha256)(markdown) },
@@ -190,6 +190,7 @@ describe('computeProviderOverall (Task 9)', () => {
190
190
  return {
191
191
  id: 'codex',
192
192
  label: 'Codex',
193
+ tier: 'hooks-native',
193
194
  checks: states.map((state, i) => ({ id: CHECK_IDS[i % CHECK_IDS.length], state })),
194
195
  };
195
196
  }