agentic-workflow-manager 3.0.1 → 3.2.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 (103) hide show
  1. package/dist/src/commands/add.js +84 -0
  2. package/dist/src/commands/agent.js +95 -0
  3. package/dist/src/commands/backup.js +46 -0
  4. package/dist/src/commands/doctor.js +80 -4
  5. package/dist/src/commands/export.js +48 -0
  6. package/dist/src/commands/hooks/claude.js +192 -0
  7. package/dist/src/commands/hooks/codex.js +191 -0
  8. package/dist/src/commands/hooks/index.js +23 -8
  9. package/dist/src/commands/hooks/install.js +11 -107
  10. package/dist/src/commands/hooks/resync.js +28 -18
  11. package/dist/src/commands/hooks/shared.js +94 -0
  12. package/dist/src/commands/hooks/status.js +13 -64
  13. package/dist/src/commands/hooks/uninstall.js +11 -47
  14. package/dist/src/commands/init.js +90 -28
  15. package/dist/src/commands/registry/index.js +1 -1
  16. package/dist/src/commands/sync.js +113 -0
  17. package/dist/src/commands/update.js +107 -0
  18. package/dist/src/core/agent-targets.js +50 -0
  19. package/dist/src/core/artifact-state.js +65 -0
  20. package/dist/src/core/atomic-file.js +72 -0
  21. package/dist/src/core/bundle-install.js +60 -38
  22. package/dist/src/core/bundles.js +21 -0
  23. package/dist/src/core/context/managed-block.js +190 -0
  24. package/dist/src/core/context/orchestrator.js +3 -1
  25. package/dist/src/core/context/project-constitution-inject.js +1 -1
  26. package/dist/src/core/context/regenerate.js +3 -3
  27. package/dist/src/core/context/strategies/codex-agents.js +100 -0
  28. package/dist/src/core/diagnostics/checks.js +16 -0
  29. package/dist/src/core/diagnostics/context.js +37 -8
  30. package/dist/src/core/diagnostics/provider-checks.js +219 -0
  31. package/dist/src/core/discovery.js +9 -3
  32. package/dist/src/core/executor.js +30 -9
  33. package/dist/src/core/export/index.js +51 -0
  34. package/dist/src/core/export/pack.js +58 -0
  35. package/dist/src/core/export/resolve.js +77 -0
  36. package/dist/src/core/export/transform.js +57 -0
  37. package/dist/src/core/export/types.js +2 -0
  38. package/dist/src/core/init/mutation-targets.js +139 -0
  39. package/dist/src/core/init/provider-facts.js +164 -0
  40. package/dist/src/core/init/steps.js +65 -9
  41. package/dist/src/core/install-planner.js +206 -0
  42. package/dist/src/core/install-transaction.js +405 -0
  43. package/dist/src/core/profile.js +1 -1
  44. package/dist/src/core/provider-artifacts.js +54 -0
  45. package/dist/src/core/provider-version.js +36 -0
  46. package/dist/src/core/reconciliation.js +61 -0
  47. package/dist/src/core/registries.js +10 -0
  48. package/dist/src/core/renderers/canonical-agent.js +27 -0
  49. package/dist/src/core/renderers/codex-agent.js +60 -0
  50. package/dist/src/core/skill-integrity.js +4 -3
  51. package/dist/src/index.js +99 -289
  52. package/dist/src/providers/index.js +143 -39
  53. package/dist/src/ui/provider-preflight.js +26 -0
  54. package/dist/src/utils/config.js +101 -9
  55. package/dist/tests/commands/agent.test.js +103 -0
  56. package/dist/tests/commands/backup.test.js +109 -0
  57. package/dist/tests/commands/doctor.test.js +65 -4
  58. package/dist/tests/commands/export.test.js +64 -0
  59. package/dist/tests/commands/hooks/codex.test.js +230 -0
  60. package/dist/tests/commands/hooks/install.test.js +20 -1
  61. package/dist/tests/commands/hooks/resync.test.js +53 -6
  62. package/dist/tests/commands/hooks/status.test.js +10 -0
  63. package/dist/tests/commands/hooks/uninstall.test.js +12 -0
  64. package/dist/tests/commands/init.test.js +195 -8
  65. package/dist/tests/commands/multi-agent-targeting.test.js +231 -0
  66. package/dist/tests/core/agent-targets.test.js +45 -0
  67. package/dist/tests/core/artifact-state.test.js +144 -0
  68. package/dist/tests/core/atomic-file.test.js +96 -0
  69. package/dist/tests/core/bundle-install.test.js +208 -17
  70. package/dist/tests/core/bundles.test.js +12 -0
  71. package/dist/tests/core/context/managed-block.test.js +90 -0
  72. package/dist/tests/core/context/orchestrator.test.js +48 -3
  73. package/dist/tests/core/context/regenerate.test.js +1 -1
  74. package/dist/tests/core/context/strategies/codex-agents.test.js +185 -0
  75. package/dist/tests/core/context/strategies/config-instructions.test.js +1 -1
  76. package/dist/tests/core/diagnostics/checks.test.js +19 -0
  77. package/dist/tests/core/diagnostics/context.test.js +74 -0
  78. package/dist/tests/core/diagnostics/provider-checks.test.js +252 -0
  79. package/dist/tests/core/executor.test.js +20 -0
  80. package/dist/tests/core/export/engine.test.js +104 -0
  81. package/dist/tests/core/export/pack.test.js +122 -0
  82. package/dist/tests/core/export/resolve.test.js +78 -0
  83. package/dist/tests/core/export/transform.test.js +74 -0
  84. package/dist/tests/core/init/mutation-targets.test.js +235 -0
  85. package/dist/tests/core/init/orchestrator.test.js +1 -1
  86. package/dist/tests/core/init/provider-facts.test.js +129 -0
  87. package/dist/tests/core/init/steps.test.js +106 -2
  88. package/dist/tests/core/install-planner.test.js +224 -0
  89. package/dist/tests/core/install-transaction.test.js +257 -0
  90. package/dist/tests/core/provider-artifacts.test.js +64 -0
  91. package/dist/tests/core/provider-version.test.js +53 -0
  92. package/dist/tests/core/reconciliation.test.js +165 -0
  93. package/dist/tests/core/registries-sync.test.js +4 -4
  94. package/dist/tests/core/renderers/canonical-agent.test.js +58 -0
  95. package/dist/tests/core/renderers/codex-agent.test.js +128 -0
  96. package/dist/tests/core/versioning.test.js +1 -1
  97. package/dist/tests/integration/codex-provider-isolated.test.js +206 -0
  98. package/dist/tests/providers/hooks-config.test.js +43 -25
  99. package/dist/tests/providers/index.test.js +144 -39
  100. package/dist/tests/structural/codex-agent-escaping-completeness.test.js +77 -0
  101. package/dist/tests/ui/provider-preflight.test.js +25 -0
  102. package/dist/tests/utils/config.test.js +178 -18
  103. package/package.json +1 -1
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const fs_1 = __importDefault(require("fs"));
7
+ const os_1 = __importDefault(require("os"));
8
+ const path_1 = __importDefault(require("path"));
9
+ describe('gatherProviderFacts / assertClaudeBaselinePreserved', () => {
10
+ let tmpHome;
11
+ let originalHome;
12
+ let originalAwmHome;
13
+ beforeEach(() => {
14
+ tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-provider-facts-'));
15
+ originalHome = process.env.HOME;
16
+ originalAwmHome = process.env.AWM_HOME;
17
+ process.env.HOME = tmpHome;
18
+ process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
19
+ jest.resetModules();
20
+ });
21
+ afterEach(() => {
22
+ fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
23
+ if (originalHome === undefined)
24
+ delete process.env.HOME;
25
+ else
26
+ process.env.HOME = originalHome;
27
+ if (originalAwmHome === undefined)
28
+ delete process.env.AWM_HOME;
29
+ else
30
+ process.env.AWM_HOME = originalAwmHome;
31
+ });
32
+ it('two snapshots of untouched state are identical (no false positive)', () => {
33
+ const { gatherProviderFacts, assertClaudeBaselinePreserved } = require('../../../src/core/init/provider-facts');
34
+ const before = gatherProviderFacts('claude-code');
35
+ const after = gatherProviderFacts('claude-code');
36
+ expect(() => assertClaudeBaselinePreserved(before, after)).not.toThrow();
37
+ expect(before.hash).toBe(after.hash);
38
+ });
39
+ it('detects a top-level file change under a managed directory', () => {
40
+ const { gatherProviderFacts, assertClaudeBaselinePreserved } = require('../../../src/core/init/provider-facts');
41
+ const skillsDir = path_1.default.join(tmpHome, '.claude', 'skills');
42
+ fs_1.default.mkdirSync(skillsDir, { recursive: true });
43
+ fs_1.default.writeFileSync(path_1.default.join(skillsDir, 'SKILL.md'), 'original');
44
+ const before = gatherProviderFacts('claude-code');
45
+ fs_1.default.writeFileSync(path_1.default.join(skillsDir, 'SKILL.md'), 'mutated');
46
+ const after = gatherProviderFacts('claude-code');
47
+ expect(before.hash).not.toBe(after.hash);
48
+ expect(() => assertClaudeBaselinePreserved(before, after)).toThrow(/must never happen \(R19\)/);
49
+ });
50
+ it('detects a content-only change two levels deep under a managed directory', () => {
51
+ const { gatherProviderFacts, assertClaudeBaselinePreserved } = require('../../../src/core/init/provider-facts');
52
+ const skillsDir = path_1.default.join(tmpHome, '.claude', 'skills');
53
+ // .claude/skills/some-skill/nested/deep.md — two directory levels below skills/
54
+ const nested = path_1.default.join(skillsDir, 'some-skill', 'nested');
55
+ fs_1.default.mkdirSync(nested, { recursive: true });
56
+ const deepFile = path_1.default.join(nested, 'deep.md');
57
+ fs_1.default.writeFileSync(deepFile, 'original deep content');
58
+ const before = gatherProviderFacts('claude-code');
59
+ // Content-only mutation — no rename, no size-preserving trick needed;
60
+ // this alone would have been invisible to a top-level-only stat digest.
61
+ fs_1.default.writeFileSync(deepFile, 'mutated deep content');
62
+ const after = gatherProviderFacts('claude-code');
63
+ expect(before.hash).not.toBe(after.hash);
64
+ expect(() => assertClaudeBaselinePreserved(before, after)).toThrow(/must never happen \(R19\)/);
65
+ });
66
+ it('detects an added file three levels deep without touching any existing file', () => {
67
+ const { gatherProviderFacts } = require('../../../src/core/init/provider-facts');
68
+ const skillsDir = path_1.default.join(tmpHome, '.claude', 'skills');
69
+ const nested = path_1.default.join(skillsDir, 'pkg', 'a', 'b');
70
+ fs_1.default.mkdirSync(nested, { recursive: true });
71
+ fs_1.default.writeFileSync(path_1.default.join(nested, 'existing.md'), 'unchanged');
72
+ const before = gatherProviderFacts('claude-code');
73
+ fs_1.default.writeFileSync(path_1.default.join(nested, 'new-file.md'), 'new');
74
+ const after = gatherProviderFacts('claude-code');
75
+ expect(before.hash).not.toBe(after.hash);
76
+ });
77
+ it('throws a distinct message when comparing facts for mismatched agents', () => {
78
+ const { gatherProviderFacts, assertClaudeBaselinePreserved } = require('../../../src/core/init/provider-facts');
79
+ const claude = gatherProviderFacts('claude-code');
80
+ const codex = gatherProviderFacts('codex');
81
+ expect(() => assertClaudeBaselinePreserved(claude, codex)).toThrow('mismatched agents');
82
+ });
83
+ // -----------------------------------------------------------------------
84
+ // Nested-scriptsDir exclusion (Task 9 fix) — Codex's hook scriptsDir
85
+ // (~/.awm/hooks/codex) is physically nested inside Claude's own hook
86
+ // scriptsDir (~/.awm/hooks — providers/index.ts). A naive recursive hash
87
+ // of Claude's scriptsDir would pick up every Codex hook write as a
88
+ // "Claude baseline changed" false positive. These two tests pin that
89
+ // exclusion directly, rather than relying only on the E2E's indirect
90
+ // before/after tree-snapshot check.
91
+ // -----------------------------------------------------------------------
92
+ it('does NOT flag a write inside Codex\'s nested scriptsDir as a Claude baseline change', () => {
93
+ const { gatherProviderFacts, assertClaudeBaselinePreserved } = require('../../../src/core/init/provider-facts');
94
+ // Seed Claude's own scriptsDir first (mirrors a real prior Claude hook install).
95
+ const claudeScripts = path_1.default.join(tmpHome, '.awm', 'hooks');
96
+ fs_1.default.mkdirSync(claudeScripts, { recursive: true });
97
+ fs_1.default.writeFileSync(path_1.default.join(claudeScripts, 'session-start'), '#!/bin/sh\n');
98
+ fs_1.default.writeFileSync(path_1.default.join(claudeScripts, 'run-hook.cmd'), '#!/bin/sh\n');
99
+ const before = gatherProviderFacts('claude-code');
100
+ // Codex's own scriptsDir is nested one level inside Claude's — simulate a
101
+ // real Codex hook install writing there for the first time.
102
+ const codexScripts = path_1.default.join(tmpHome, '.awm', 'hooks', 'codex');
103
+ fs_1.default.mkdirSync(codexScripts, { recursive: true });
104
+ fs_1.default.writeFileSync(path_1.default.join(codexScripts, 'session-start'), '#!/bin/sh\necho codex\n');
105
+ const afterAdd = gatherProviderFacts('claude-code');
106
+ expect(afterAdd.hash).toBe(before.hash);
107
+ expect(() => assertClaudeBaselinePreserved(before, afterAdd)).not.toThrow();
108
+ // Modifying the Codex-owned nested file's content must also stay invisible.
109
+ fs_1.default.writeFileSync(path_1.default.join(codexScripts, 'session-start'), '#!/bin/sh\necho codex v2\n');
110
+ const afterModify = gatherProviderFacts('claude-code');
111
+ expect(afterModify.hash).toBe(before.hash);
112
+ expect(() => assertClaudeBaselinePreserved(before, afterModify)).not.toThrow();
113
+ });
114
+ it('still flags a genuinely Claude-owned change alongside an untouched Codex nested dir', () => {
115
+ const { gatherProviderFacts, assertClaudeBaselinePreserved } = require('../../../src/core/init/provider-facts');
116
+ const claudeScripts = path_1.default.join(tmpHome, '.awm', 'hooks');
117
+ const codexScripts = path_1.default.join(claudeScripts, 'codex');
118
+ fs_1.default.mkdirSync(codexScripts, { recursive: true });
119
+ fs_1.default.writeFileSync(path_1.default.join(claudeScripts, 'session-start'), '#!/bin/sh\n');
120
+ fs_1.default.writeFileSync(path_1.default.join(codexScripts, 'session-start'), '#!/bin/sh\necho codex\n');
121
+ const before = gatherProviderFacts('claude-code');
122
+ // A genuinely Claude-owned file (a sibling of the codex/ subdir, not inside it)
123
+ // changes — the exclusion must not swallow this.
124
+ fs_1.default.writeFileSync(path_1.default.join(claudeScripts, 'session-start'), '#!/bin/sh\necho mutated claude script\n');
125
+ const after = gatherProviderFacts('claude-code');
126
+ expect(after.hash).not.toBe(before.hash);
127
+ expect(() => assertClaudeBaselinePreserved(before, after)).toThrow(/must never happen \(R19\)/);
128
+ });
129
+ });
@@ -55,7 +55,7 @@ function spies() {
55
55
  function deps(ctx, actions, over = {}) {
56
56
  return {
57
57
  cwd: '/repo', ctx, bundles: [bundle('dev', 'baseline', ['brainstorming'])],
58
- agent: 'claude-code', installMethod: 'symlink',
58
+ agent: 'claude-code', enabledAgents: ['claude-code'], installMethod: 'symlink',
59
59
  registryRoot: '/cache', contentDir: '/cache/registry', sensorPacksRoot: '/cache/registry',
60
60
  confirmExtensions: async (p) => p, actions, ...over,
61
61
  };
@@ -111,6 +111,22 @@ describe('stepHook / stepDevCore / stepAmbient', () => {
111
111
  expect((0, steps_1.stepHook)(deps({ machine: m, project: null }, a)).action).toBe('applied');
112
112
  expect(a.installHook).toHaveBeenCalled();
113
113
  });
114
+ // Regression: a real (unstubbed) `awm init --agent opencode` used to throw
115
+ // "hooks not supported for agent target: opencode" from installHook,
116
+ // because stepHook never checked whether the target agent has a hook
117
+ // mechanism at all before calling installHook (providers/index.ts:
118
+ // OpenCode/Antigravity have no `hooks` config). Found while building the
119
+ // real Codex+OpenCode coexistence E2E test — mirrors provider-checks.ts's
120
+ // `hookTrustCheck`, which already treats a missing `hooks` config as "not
121
+ // applicable" rather than a failure.
122
+ it('hook skips (not calls installHook) for an agent with no hook mechanism', () => {
123
+ const a = spies();
124
+ const m = machine();
125
+ m.hook = { present: false };
126
+ const r = (0, steps_1.stepHook)(deps({ machine: m, project: null }, a, { agent: 'opencode', enabledAgents: ['opencode'] }));
127
+ expect(r.action).toBe('skipped');
128
+ expect(a.installHook).not.toHaveBeenCalled();
129
+ });
114
130
  it('devCore installs baseline when links broken', () => {
115
131
  const a = spies();
116
132
  const m = machine();
@@ -137,6 +153,56 @@ describe('stepHook / stepDevCore / stepAmbient', () => {
137
153
  const a = spies();
138
154
  expect((0, steps_1.stepAmbient)(deps({ machine: machine(), project: null }, a)).action).toBe('skipped');
139
155
  });
156
+ // Regression coverage for the BLOCKER found in post-implementation QA:
157
+ // stepDevCore/stepAmbient used to pass a `[d.agent]` singleton as
158
+ // `agents`, which install-planner.ts's `assertCompleteSharedGroup` (R14)
159
+ // refuses whenever a co-owner sharing the same physical skill directory
160
+ // (OpenCode and Codex both resolve to ~/.agents/skills) is independently
161
+ // enabled — making `awm init --agent codex` structurally fail once
162
+ // OpenCode was already enabled, and vice versa.
163
+ it('devCore includes a co-enabled agent sharing the same skill target (codex + opencode)', () => {
164
+ const a = spies();
165
+ const m = machine();
166
+ m.devCore = { present: false, brokenLinks: [] };
167
+ const r = (0, steps_1.stepDevCore)(deps({ machine: m, project: null }, a, {
168
+ agent: 'codex', enabledAgents: ['claude-code', 'opencode', 'codex'],
169
+ }));
170
+ expect(r.action).toBe('applied');
171
+ expect(a.installBundle).toHaveBeenCalledWith(expect.objectContaining({ agents: expect.arrayContaining(['codex', 'opencode']) }));
172
+ const call = a.installBundle.mock.calls[0][0];
173
+ expect(call.agents).toHaveLength(2); // claude-code is NOT included — it doesn't share the skill target
174
+ });
175
+ it('devCore does not add a co-owner that is not currently enabled', () => {
176
+ const a = spies();
177
+ const m = machine();
178
+ m.devCore = { present: false, brokenLinks: [] };
179
+ const r = (0, steps_1.stepDevCore)(deps({ machine: m, project: null }, a, {
180
+ agent: 'codex', enabledAgents: ['claude-code', 'codex'],
181
+ }));
182
+ expect(r.action).toBe('applied');
183
+ expect(a.installBundle).toHaveBeenCalledWith(expect.objectContaining({ agents: ['codex'] }));
184
+ });
185
+ it('devCore never adds a co-owner for claude-code (its skill dir is never shared)', () => {
186
+ const a = spies();
187
+ const m = machine();
188
+ m.devCore = { present: false, brokenLinks: [] };
189
+ const r = (0, steps_1.stepDevCore)(deps({ machine: m, project: null }, a, {
190
+ agent: 'claude-code', enabledAgents: ['claude-code', 'opencode', 'codex'],
191
+ }));
192
+ expect(r.action).toBe('applied');
193
+ expect(a.installBundle).toHaveBeenCalledWith(expect.objectContaining({ agents: ['claude-code'] }));
194
+ });
195
+ it('ambient includes a co-enabled agent sharing the same skill target (codex + opencode)', () => {
196
+ const a = spies();
197
+ const m = machine();
198
+ m.ambient = { wanted: ['docs'], installed: [] };
199
+ const r = (0, steps_1.stepAmbient)(deps({ machine: m, project: null }, a, {
200
+ agent: 'opencode', enabledAgents: ['claude-code', 'opencode', 'codex'],
201
+ }));
202
+ expect(r.action).toBe('applied');
203
+ const call = a.installBundle.mock.calls[0][0];
204
+ expect(call.agents.sort()).toEqual(['codex', 'opencode']);
205
+ });
140
206
  });
141
207
  describe('stepProfile', () => {
142
208
  it('adds confirmed extension when detector finds a match', async () => {
@@ -237,6 +303,31 @@ describe('stepActivation', () => {
237
303
  (0, steps_1.stepActivation)(deps(ctx, a, { agent: 'opencode' }));
238
304
  expect(a.gatherProject).toHaveBeenCalledWith(expect.any(String), expect.any(Array), 'opencode');
239
305
  });
306
+ // Same class of bug as stepDevCore/stepAmbient's shared-group regression
307
+ // above, in the LOCAL-scope (project) code path: OpenCode and Codex share
308
+ // both their global AND local skill directories, so a project extension
309
+ // with a skill artifact hits the identical R14 refusal whenever
310
+ // syncProfile is called with a `[d.agent]` singleton and a co-owner is
311
+ // independently enabled.
312
+ it('activation includes a co-enabled agent sharing the same LOCAL skill target (codex + opencode)', () => {
313
+ const a = spies();
314
+ a.gatherProject = jest.fn((_cwd, _bundles) => project({ activeBundles: { expected: ['x'], linked: [], broken: [] } }));
315
+ const r = (0, steps_1.stepActivation)(deps({ machine: machine(), project: project() }, a, {
316
+ agent: 'codex', enabledAgents: ['claude-code', 'opencode', 'codex'],
317
+ }));
318
+ expect(r.action).toBe('applied');
319
+ const call = a.syncProfile.mock.calls[0][0];
320
+ expect(call.agents.sort()).toEqual(['codex', 'opencode']);
321
+ });
322
+ it('activation does not add a co-owner that is not currently enabled', () => {
323
+ const a = spies();
324
+ a.gatherProject = jest.fn((_cwd, _bundles) => project({ activeBundles: { expected: ['x'], linked: [], broken: [] } }));
325
+ const r = (0, steps_1.stepActivation)(deps({ machine: machine(), project: project() }, a, {
326
+ agent: 'codex', enabledAgents: ['claude-code', 'codex'],
327
+ }));
328
+ expect(r.action).toBe('applied');
329
+ expect(a.syncProfile).toHaveBeenCalledWith(expect.objectContaining({ agents: ['codex'] }));
330
+ });
240
331
  });
241
332
  describe('stepSensors', () => {
242
333
  it('skips when sensors present', () => {
@@ -296,7 +387,7 @@ describe('stepGlobalSkillsRepair', () => {
296
387
  m.globalSkills = { valid: [], repairable: ['b'], dead: [] };
297
388
  const r = (0, steps_1.stepGlobalSkillsRepair)(deps({ machine: m, project: null }, a, { agent: 'opencode' }));
298
389
  expect(r.action).toBe('applied');
299
- expect(a.repairGlobalSkills).toHaveBeenCalledWith(providers_1.PROVIDERS['opencode'].skill.global, expect.any(Array));
390
+ expect(a.repairGlobalSkills).toHaveBeenCalledWith((0, providers_1.providerFor)('opencode').skill.global, expect.any(Array));
300
391
  });
301
392
  });
302
393
  describe('stepConstitutionInjection (#6)', () => {
@@ -318,6 +409,12 @@ describe('stepConstitutionInjection (#6)', () => {
318
409
  expect(r.action).toBe('skipped');
319
410
  expect(a.injectProjectConstitution).not.toHaveBeenCalled();
320
411
  });
412
+ it('installs Codex project guidance before CONSTITUTION.md exists', () => {
413
+ const a = spies();
414
+ const r = (0, steps_1.stepConstitutionInjection)(deps({ machine: machine(), project: project({ constitution: { present: false } }) }, a, { agent: 'codex' }));
415
+ expect(r.action).toBe('applied');
416
+ expect(a.injectProjectConstitution).toHaveBeenCalledWith({ projectRoot: '/repo', agent: 'codex' });
417
+ });
321
418
  it('maps already → skipped when CONSTITUTION.md was already in opencode.json', () => {
322
419
  const a = spies();
323
420
  a.injectProjectConstitution.mockReturnValue('already');
@@ -360,4 +457,11 @@ describe('stepContextInjection', () => {
360
457
  expect(r.action).toBe('applied');
361
458
  expect(a.installContext).toHaveBeenCalled();
362
459
  });
460
+ it('installs the Codex global managed block when absent', () => {
461
+ const a = spies();
462
+ a.contextStatus.mockReturnValue('absent');
463
+ const r = (0, steps_1.stepContextInjection)(deps({ machine: machine(), project: null }, a, { agent: 'codex' }));
464
+ expect(r.action).toBe('applied');
465
+ expect(a.installContext).toHaveBeenCalledWith(expect.objectContaining({ agent: 'codex', scope: 'global' }));
466
+ });
363
467
  });
@@ -0,0 +1,224 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const fs_1 = __importDefault(require("fs"));
7
+ const os_1 = __importDefault(require("os"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const install_planner_1 = require("../../src/core/install-planner");
10
+ describe('install-planner', () => {
11
+ let tmpHome;
12
+ let tmpWork;
13
+ let originalHome;
14
+ let originalAwmHome;
15
+ beforeEach(() => {
16
+ // Isolate ~ (and thus provider global paths, per CLAUDE.md: no test may
17
+ // touch the real ~/.awm or ~/.agents). HOME also drives providerFor()'s
18
+ // global dirs, which is exactly what makes opencode/codex "share" a
19
+ // physical skills directory in these tests.
20
+ tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-planner-home-'));
21
+ tmpWork = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-planner-work-'));
22
+ originalHome = process.env.HOME;
23
+ originalAwmHome = process.env.AWM_HOME;
24
+ process.env.HOME = tmpHome;
25
+ process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
26
+ });
27
+ afterEach(() => {
28
+ fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
29
+ fs_1.default.rmSync(tmpWork, { recursive: true, force: true });
30
+ if (originalHome === undefined)
31
+ delete process.env.HOME;
32
+ else
33
+ process.env.HOME = originalHome;
34
+ if (originalAwmHome === undefined)
35
+ delete process.env.AWM_HOME;
36
+ else
37
+ process.env.AWM_HOME = originalAwmHome;
38
+ });
39
+ function skillArtifact(name) {
40
+ return {
41
+ name,
42
+ installName: name,
43
+ type: 'skill',
44
+ sourcePath: path_1.default.join(tmpWork, 'registry', 'skills', name),
45
+ };
46
+ }
47
+ function agentArtifact(name) {
48
+ return {
49
+ name,
50
+ installName: `${name}.md`,
51
+ type: 'agent',
52
+ sourcePath: path_1.default.join(tmpWork, 'registry', 'agents', `${name}.md`),
53
+ };
54
+ }
55
+ function workflowArtifact(name) {
56
+ return {
57
+ name,
58
+ installName: `${name}.md`,
59
+ type: 'workflow',
60
+ sourcePath: path_1.default.join(tmpWork, 'registry', 'workflows', `${name}.md`),
61
+ };
62
+ }
63
+ function recordOwnedBy(name, owners) {
64
+ return {
65
+ name,
66
+ type: 'skill',
67
+ scope: 'global',
68
+ targetPath: path_1.default.join(tmpHome, '.agents', 'skills', name),
69
+ sourcePath: path_1.default.join(tmpWork, 'registry', 'skills', name),
70
+ renderer: 'link',
71
+ owners,
72
+ };
73
+ }
74
+ describe('planInstall', () => {
75
+ it('deduplicates the OpenCode/Codex physical skill write and reports both owners', () => {
76
+ const plan = (0, install_planner_1.planInstall)({
77
+ artifacts: [skillArtifact('development-process')],
78
+ selectedAgents: ['opencode', 'codex'],
79
+ enabledAgents: ['claude-code', 'opencode', 'codex'],
80
+ scope: 'global',
81
+ projectRoot: tmpWork,
82
+ method: 'symlink',
83
+ });
84
+ expect(plan.operations).toHaveLength(1); // verifies R15
85
+ expect(plan.operations[0].owners).toEqual(['opencode', 'codex']); // verifies R15.1
86
+ });
87
+ it('aborts a skill change for an incomplete shared target group before writes', () => {
88
+ expect(() => (0, install_planner_1.planInstall)({
89
+ artifacts: [skillArtifact('development-process')],
90
+ selectedAgents: ['codex'],
91
+ enabledAgents: ['opencode', 'codex'],
92
+ scope: 'global',
93
+ projectRoot: tmpWork,
94
+ method: 'symlink',
95
+ })).toThrow('select the complete shared target group: opencode,codex'); // verifies R14
96
+ });
97
+ it('does not apply the shared-domain restriction to independently addressed agents', () => {
98
+ const plan = (0, install_planner_1.planInstall)({
99
+ artifacts: [agentArtifact('development-process')],
100
+ selectedAgents: ['codex'],
101
+ enabledAgents: ['opencode', 'codex'],
102
+ scope: 'global',
103
+ projectRoot: tmpWork,
104
+ method: 'symlink',
105
+ });
106
+ expect(plan.operations[0].owners).toEqual(['codex']); // verifies R13
107
+ });
108
+ it('returns no operations for an empty artifacts array', () => {
109
+ const plan = (0, install_planner_1.planInstall)({
110
+ artifacts: [],
111
+ selectedAgents: ['claude-code'],
112
+ enabledAgents: ['claude-code'],
113
+ scope: 'global',
114
+ projectRoot: tmpWork,
115
+ method: 'symlink',
116
+ });
117
+ expect(plan).toEqual({ operations: [], records: [], reports: [] });
118
+ });
119
+ it('returns no operations for an empty selectedAgents array', () => {
120
+ const plan = (0, install_planner_1.planInstall)({
121
+ artifacts: [skillArtifact('development-process')],
122
+ selectedAgents: [],
123
+ enabledAgents: ['claude-code'],
124
+ scope: 'global',
125
+ projectRoot: tmpWork,
126
+ method: 'symlink',
127
+ });
128
+ expect(plan).toEqual({ operations: [], records: [], reports: [] });
129
+ });
130
+ it('skips an artifact type unsupported by an agent instead of throwing (workflow on claude-code)', () => {
131
+ const plan = (0, install_planner_1.planInstall)({
132
+ artifacts: [workflowArtifact('wf-ext')],
133
+ selectedAgents: ['claude-code'],
134
+ enabledAgents: ['claude-code'],
135
+ scope: 'global',
136
+ projectRoot: tmpWork,
137
+ method: 'symlink',
138
+ });
139
+ expect(plan.operations).toEqual([]);
140
+ });
141
+ it('resolves a local-scope target beneath projectRoot', () => {
142
+ const plan = (0, install_planner_1.planInstall)({
143
+ artifacts: [skillArtifact('development-process')],
144
+ selectedAgents: ['claude-code'],
145
+ enabledAgents: ['claude-code'],
146
+ scope: 'local',
147
+ projectRoot: tmpWork,
148
+ method: 'symlink',
149
+ });
150
+ expect(plan.operations[0].targetPath).toBe(path_1.default.join(tmpWork, '.claude', 'skills', 'development-process'));
151
+ });
152
+ it('dedupes two intents that share both targetPath and sourcePath into one operation', () => {
153
+ // e.g. the same skill pulled in twice via different bundles in a
154
+ // closure — same physical target, same backing source: fine.
155
+ const plan = (0, install_planner_1.planInstall)({
156
+ artifacts: [skillArtifact('development-process'), skillArtifact('development-process')],
157
+ selectedAgents: ['claude-code'],
158
+ enabledAgents: ['claude-code'],
159
+ scope: 'global',
160
+ projectRoot: tmpWork,
161
+ method: 'symlink',
162
+ });
163
+ expect(plan.operations).toHaveLength(1);
164
+ });
165
+ it('throws when two intents share a targetPath but have different sourcePaths', () => {
166
+ // e.g. the same skill name shipped by two different registries —
167
+ // one physical target can't be backed by two different sources.
168
+ const registryA = {
169
+ name: 'development-process',
170
+ installName: 'development-process',
171
+ type: 'skill',
172
+ sourcePath: path_1.default.join(tmpWork, 'registry-a', 'skills', 'development-process'),
173
+ };
174
+ const registryB = {
175
+ name: 'development-process',
176
+ installName: 'development-process',
177
+ type: 'skill',
178
+ sourcePath: path_1.default.join(tmpWork, 'registry-b', 'skills', 'development-process'),
179
+ };
180
+ expect(() => (0, install_planner_1.planInstall)({
181
+ artifacts: [registryA, registryB],
182
+ selectedAgents: ['claude-code'],
183
+ enabledAgents: ['claude-code'],
184
+ scope: 'global',
185
+ projectRoot: tmpWork,
186
+ method: 'symlink',
187
+ })).toThrow(/physical target already claimed by a different source/);
188
+ });
189
+ });
190
+ describe('planRemoval', () => {
191
+ it('retains a target while a non-selected enabled owner remains', () => {
192
+ const result = (0, install_planner_1.planRemoval)({
193
+ records: [recordOwnedBy('development-process', ['opencode', 'codex'])],
194
+ selectedAgents: ['codex'],
195
+ enabledAgents: ['opencode', 'codex'],
196
+ artifactNames: ['development-process'],
197
+ });
198
+ expect(result.operations).toEqual([]);
199
+ expect(result.records[0].owners).toEqual(['opencode']); // verifies R16
200
+ });
201
+ it('produces an unlink once the last enabled owner is removed', () => {
202
+ const result = (0, install_planner_1.planRemoval)({
203
+ records: [recordOwnedBy('development-process', ['codex'])],
204
+ selectedAgents: ['codex'],
205
+ enabledAgents: ['opencode', 'codex'],
206
+ artifactNames: ['development-process'],
207
+ });
208
+ expect(result.records).toEqual([]);
209
+ expect(result.operations).toHaveLength(1);
210
+ expect(result.operations[0].action).toBe('unlink');
211
+ });
212
+ it('leaves records for artifacts not named in artifactNames untouched', () => {
213
+ const other = recordOwnedBy('other-skill', ['codex']);
214
+ const result = (0, install_planner_1.planRemoval)({
215
+ records: [other],
216
+ selectedAgents: ['codex'],
217
+ enabledAgents: ['opencode', 'codex'],
218
+ artifactNames: ['development-process'],
219
+ });
220
+ expect(result.records).toEqual([other]);
221
+ expect(result.operations).toEqual([]);
222
+ });
223
+ });
224
+ });