agentic-workflow-manager 3.1.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) 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/hooks/claude.js +192 -0
  6. package/dist/src/commands/hooks/codex.js +191 -0
  7. package/dist/src/commands/hooks/index.js +23 -8
  8. package/dist/src/commands/hooks/install.js +11 -107
  9. package/dist/src/commands/hooks/resync.js +28 -18
  10. package/dist/src/commands/hooks/shared.js +94 -0
  11. package/dist/src/commands/hooks/status.js +13 -64
  12. package/dist/src/commands/hooks/uninstall.js +11 -47
  13. package/dist/src/commands/init.js +90 -28
  14. package/dist/src/commands/registry/index.js +1 -1
  15. package/dist/src/commands/sync.js +113 -0
  16. package/dist/src/commands/update.js +107 -0
  17. package/dist/src/core/agent-targets.js +50 -0
  18. package/dist/src/core/artifact-state.js +65 -0
  19. package/dist/src/core/atomic-file.js +72 -0
  20. package/dist/src/core/bundle-install.js +60 -38
  21. package/dist/src/core/bundles.js +21 -0
  22. package/dist/src/core/context/managed-block.js +190 -0
  23. package/dist/src/core/context/orchestrator.js +3 -1
  24. package/dist/src/core/context/project-constitution-inject.js +1 -1
  25. package/dist/src/core/context/regenerate.js +3 -3
  26. package/dist/src/core/context/strategies/codex-agents.js +100 -0
  27. package/dist/src/core/diagnostics/checks.js +16 -0
  28. package/dist/src/core/diagnostics/context.js +37 -8
  29. package/dist/src/core/diagnostics/provider-checks.js +219 -0
  30. package/dist/src/core/executor.js +30 -9
  31. package/dist/src/core/init/mutation-targets.js +139 -0
  32. package/dist/src/core/init/provider-facts.js +179 -0
  33. package/dist/src/core/init/steps.js +65 -9
  34. package/dist/src/core/install-planner.js +206 -0
  35. package/dist/src/core/install-transaction.js +405 -0
  36. package/dist/src/core/profile.js +1 -1
  37. package/dist/src/core/provider-artifacts.js +54 -0
  38. package/dist/src/core/provider-version.js +36 -0
  39. package/dist/src/core/reconciliation.js +61 -0
  40. package/dist/src/core/registries.js +10 -0
  41. package/dist/src/core/renderers/canonical-agent.js +27 -0
  42. package/dist/src/core/renderers/codex-agent.js +60 -0
  43. package/dist/src/core/skill-integrity.js +4 -3
  44. package/dist/src/index.js +97 -289
  45. package/dist/src/providers/index.js +143 -39
  46. package/dist/src/ui/provider-preflight.js +26 -0
  47. package/dist/src/utils/config.js +101 -9
  48. package/dist/tests/commands/agent.test.js +103 -0
  49. package/dist/tests/commands/backup.test.js +109 -0
  50. package/dist/tests/commands/doctor.test.js +65 -4
  51. package/dist/tests/commands/hooks/codex.test.js +230 -0
  52. package/dist/tests/commands/hooks/install.test.js +20 -1
  53. package/dist/tests/commands/hooks/resync.test.js +53 -6
  54. package/dist/tests/commands/hooks/status.test.js +10 -0
  55. package/dist/tests/commands/hooks/uninstall.test.js +12 -0
  56. package/dist/tests/commands/init.test.js +195 -8
  57. package/dist/tests/commands/multi-agent-targeting.test.js +231 -0
  58. package/dist/tests/core/agent-targets.test.js +45 -0
  59. package/dist/tests/core/artifact-state.test.js +144 -0
  60. package/dist/tests/core/atomic-file.test.js +96 -0
  61. package/dist/tests/core/bundle-install.test.js +208 -17
  62. package/dist/tests/core/bundles.test.js +12 -0
  63. package/dist/tests/core/context/managed-block.test.js +90 -0
  64. package/dist/tests/core/context/orchestrator.test.js +48 -3
  65. package/dist/tests/core/context/regenerate.test.js +1 -1
  66. package/dist/tests/core/context/strategies/codex-agents.test.js +185 -0
  67. package/dist/tests/core/context/strategies/config-instructions.test.js +1 -1
  68. package/dist/tests/core/diagnostics/checks.test.js +19 -0
  69. package/dist/tests/core/diagnostics/context.test.js +74 -0
  70. package/dist/tests/core/diagnostics/provider-checks.test.js +252 -0
  71. package/dist/tests/core/executor.test.js +20 -0
  72. package/dist/tests/core/export/engine.test.js +3 -2
  73. package/dist/tests/core/init/mutation-targets.test.js +235 -0
  74. package/dist/tests/core/init/orchestrator.test.js +1 -1
  75. package/dist/tests/core/init/provider-facts.test.js +177 -0
  76. package/dist/tests/core/init/steps.test.js +106 -2
  77. package/dist/tests/core/install-planner.test.js +224 -0
  78. package/dist/tests/core/install-transaction.test.js +257 -0
  79. package/dist/tests/core/provider-artifacts.test.js +64 -0
  80. package/dist/tests/core/provider-version.test.js +53 -0
  81. package/dist/tests/core/reconciliation.test.js +165 -0
  82. package/dist/tests/core/registries-sync.test.js +4 -4
  83. package/dist/tests/core/renderers/canonical-agent.test.js +58 -0
  84. package/dist/tests/core/renderers/codex-agent.test.js +128 -0
  85. package/dist/tests/core/versioning.test.js +1 -1
  86. package/dist/tests/integration/codex-provider-isolated.test.js +235 -0
  87. package/dist/tests/providers/hooks-config.test.js +43 -25
  88. package/dist/tests/providers/index.test.js +144 -39
  89. package/dist/tests/structural/codex-agent-escaping-completeness.test.js +77 -0
  90. package/dist/tests/ui/provider-preflight.test.js +25 -0
  91. package/dist/tests/utils/config.test.js +178 -18
  92. package/package.json +1 -1
@@ -0,0 +1,235 @@
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
+ // Direct, focused tests for planInitMutationTargets (mutation-targets.ts).
7
+ //
8
+ // Previously this module was only exercised indirectly via
9
+ // tests/commands/init.test.ts's broader init-flow tests, which happen to hit
10
+ // the hooks.settingsPath branch but not the ambient/baseline/project-extension
11
+ // branches, nor the Finding-1 fix (global skills directory) directly.
12
+ //
13
+ // registries.ts computes AWM_HOME at module-require time (not call time), so
14
+ // every test here sets HOME/AWM_HOME BEFORE requiring
15
+ // core/init/mutation-targets.ts, and calls jest.resetModules() first — the
16
+ // same pattern used throughout tests/commands/hooks/install.test.ts and
17
+ // tests/core/skill-integrity.test.ts. Per CLAUDE.md, no test may touch the
18
+ // real ~/.awm.
19
+ const fs_1 = __importDefault(require("fs"));
20
+ const os_1 = __importDefault(require("os"));
21
+ const path_1 = __importDefault(require("path"));
22
+ function bundle(name, scope, skills) {
23
+ return {
24
+ name, description: '', version: '1.0.0', scope, visibility: 'public',
25
+ dependsOn: [], skills: skills.map((s) => ({ name: s, onSignal: false })),
26
+ workflows: [], agents: [],
27
+ };
28
+ }
29
+ let tmpHome;
30
+ let originalHome;
31
+ let originalAwmHome;
32
+ let extraDirs;
33
+ beforeEach(() => {
34
+ tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-mutation-targets-'));
35
+ originalHome = process.env.HOME;
36
+ originalAwmHome = process.env.AWM_HOME;
37
+ process.env.HOME = tmpHome;
38
+ process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
39
+ jest.resetModules();
40
+ extraDirs = [];
41
+ });
42
+ afterEach(() => {
43
+ fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
44
+ for (const dir of extraDirs)
45
+ fs_1.default.rmSync(dir, { recursive: true, force: true });
46
+ if (originalHome === undefined)
47
+ delete process.env.HOME;
48
+ else
49
+ process.env.HOME = originalHome;
50
+ if (originalAwmHome === undefined)
51
+ delete process.env.AWM_HOME;
52
+ else
53
+ process.env.AWM_HOME = originalAwmHome;
54
+ });
55
+ /**
56
+ * Loads a fresh copy of the module under test (and providerFor, which needs
57
+ * the same env-var-driven paths) AFTER env vars are set for this test.
58
+ */
59
+ function load() {
60
+ const mutationTargets = require('../../../src/core/init/mutation-targets');
61
+ const providers = require('../../../src/providers');
62
+ return {
63
+ planInitMutationTargets: mutationTargets.planInitMutationTargets,
64
+ providerFor: providers.providerFor,
65
+ };
66
+ }
67
+ /** A cwd guaranteed to have no project-root marker (.git/package.json/.awm) in its ancestry. */
68
+ function bareCwd() {
69
+ const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-mutation-targets-cwd-'));
70
+ extraDirs.push(dir);
71
+ return dir;
72
+ }
73
+ describe('planInitMutationTargets', () => {
74
+ it('always includes preferences.json and the artifact-state file', () => {
75
+ const { planInitMutationTargets } = load();
76
+ const targets = planInitMutationTargets({ cwd: bareCwd(), agent: 'claude-code', bundles: [] });
77
+ expect(targets).toContain(path_1.default.join(tmpHome, '.awm', 'preferences.json'));
78
+ expect(targets).toContain(path_1.default.join(tmpHome, '.awm', 'state', 'artifacts.json'));
79
+ });
80
+ describe('hooks', () => {
81
+ it('includes hooks.settingsPath and hooks.scriptsDir for an agent with a hook config (claude-code)', () => {
82
+ const { planInitMutationTargets, providerFor } = load();
83
+ const provider = providerFor('claude-code');
84
+ const targets = planInitMutationTargets({ cwd: bareCwd(), agent: 'claude-code', bundles: [] });
85
+ expect(provider.hooks).toBeDefined();
86
+ expect(targets).toContain(provider.hooks.settingsPath);
87
+ expect(targets).toContain(provider.hooks.scriptsDir);
88
+ });
89
+ it('includes hooks.json + scriptsDir for codex (its own hook config shape)', () => {
90
+ const { planInitMutationTargets, providerFor } = load();
91
+ const provider = providerFor('codex');
92
+ const targets = planInitMutationTargets({ cwd: bareCwd(), agent: 'codex', bundles: [] });
93
+ expect(targets).toContain(provider.hooks.settingsPath);
94
+ expect(targets).toContain(provider.hooks.scriptsDir);
95
+ });
96
+ it('includes no hook paths for an agent with no hook config (antigravity)', () => {
97
+ const { planInitMutationTargets } = load();
98
+ const targets = planInitMutationTargets({ cwd: bareCwd(), agent: 'antigravity', bundles: [] });
99
+ // antigravity has neither hooks nor injection — its only entries are
100
+ // the two always-present machine paths.
101
+ expect(targets).toEqual([
102
+ path_1.default.join(tmpHome, '.awm', 'preferences.json'),
103
+ path_1.default.join(tmpHome, '.awm', 'state', 'artifacts.json'),
104
+ path_1.default.join(tmpHome, '.gemini', 'antigravity', 'skills'),
105
+ ]);
106
+ });
107
+ });
108
+ describe('injection', () => {
109
+ it('includes the config-instructions path for opencode', () => {
110
+ const { planInitMutationTargets, providerFor } = load();
111
+ const provider = providerFor('opencode');
112
+ const targets = planInitMutationTargets({ cwd: bareCwd(), agent: 'opencode', bundles: [] });
113
+ expect(provider.injection?.type).toBe('config-instructions');
114
+ expect(targets).toContain(provider.injection.configPath);
115
+ });
116
+ it('includes the managed-agents-md global path for codex', () => {
117
+ const { planInitMutationTargets, providerFor } = load();
118
+ const provider = providerFor('codex');
119
+ const targets = planInitMutationTargets({ cwd: bareCwd(), agent: 'codex', bundles: [] });
120
+ expect(provider.injection?.type).toBe('managed-agents-md');
121
+ expect(targets).toContain(provider.injection.globalPath);
122
+ });
123
+ it('adds no separate injection path for claude-code (cc-settings-merge is covered by the hook)', () => {
124
+ const { planInitMutationTargets, providerFor } = load();
125
+ const provider = providerFor('claude-code');
126
+ const targets = planInitMutationTargets({ cwd: bareCwd(), agent: 'claude-code', bundles: [] });
127
+ expect(provider.injection?.type).toBe('cc-settings-merge');
128
+ // Every target should be explainable by preferences/state/hooks/skills-dir —
129
+ // none of them should be a bespoke injection config file.
130
+ expect(targets).toEqual([
131
+ path_1.default.join(tmpHome, '.awm', 'preferences.json'),
132
+ path_1.default.join(tmpHome, '.awm', 'state', 'artifacts.json'),
133
+ provider.hooks.settingsPath,
134
+ provider.hooks.scriptsDir,
135
+ provider.skill.global,
136
+ ]);
137
+ });
138
+ });
139
+ describe('machine-level bundle targets (baseline + ambient)', () => {
140
+ it('includes physical skill targets for baseline and ambient bundles, but not a project-scope bundle', () => {
141
+ const { planInitMutationTargets, providerFor } = load();
142
+ const bundles = [
143
+ bundle('dev-core', 'baseline', ['using-awm']),
144
+ bundle('ambient-pack', 'ambient', ['ambient-skill']),
145
+ bundle('project-only', 'project', ['project-skill']),
146
+ ];
147
+ const targets = planInitMutationTargets({ cwd: bareCwd(), agent: 'claude-code', bundles });
148
+ const skillsDir = providerFor('claude-code').skill.global;
149
+ expect(targets).toContain(path_1.default.join(skillsDir, 'using-awm'));
150
+ expect(targets).toContain(path_1.default.join(skillsDir, 'ambient-skill'));
151
+ // project-scope bundles are only enumerated via .awm/profile.json
152
+ // extensions below, never from the machine-level baseline/ambient pass.
153
+ expect(targets).not.toContain(path_1.default.join(skillsDir, 'project-skill'));
154
+ });
155
+ });
156
+ describe('project-level targets', () => {
157
+ function makeProjectRoot() {
158
+ const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-mutation-targets-project-'));
159
+ fs_1.default.mkdirSync(path_1.default.join(root, '.git')); // project-root marker for findProjectRoot
160
+ extraDirs.push(root);
161
+ return root;
162
+ }
163
+ it('includes profile.json, sensors.json, and extension bundle targets when a project root is found', () => {
164
+ const { planInitMutationTargets, providerFor } = load();
165
+ const projectRoot = makeProjectRoot();
166
+ fs_1.default.mkdirSync(path_1.default.join(projectRoot, '.awm'), { recursive: true });
167
+ fs_1.default.writeFileSync(path_1.default.join(projectRoot, '.awm', 'profile.json'), JSON.stringify({ extensions: ['frontend'] }));
168
+ const bundles = [bundle('frontend', 'project', ['frontend-skill'])];
169
+ const targets = planInitMutationTargets({ cwd: projectRoot, agent: 'claude-code', bundles });
170
+ expect(targets).toContain(path_1.default.join(projectRoot, '.awm', 'profile.json'));
171
+ expect(targets).toContain(path_1.default.join(projectRoot, '.awm', 'sensors.json'));
172
+ // frontend is a project-scope bundle -> installs locally under the project root
173
+ const localSkillsDir = path_1.default.join(projectRoot, providerFor('claude-code').skill.local);
174
+ expect(targets).toContain(path_1.default.join(localSkillsDir, 'frontend-skill'));
175
+ });
176
+ it('includes the project injection file for config-instructions agents (opencode)', () => {
177
+ const { planInitMutationTargets } = load();
178
+ const projectRoot = makeProjectRoot();
179
+ const targets = planInitMutationTargets({ cwd: projectRoot, agent: 'opencode', bundles: [] });
180
+ expect(targets).toContain(path_1.default.join(projectRoot, 'opencode.json'));
181
+ });
182
+ it('includes the project injection file (AGENTS.md) for managed-agents-md agents (codex)', () => {
183
+ const { planInitMutationTargets } = load();
184
+ const projectRoot = makeProjectRoot();
185
+ const targets = planInitMutationTargets({ cwd: projectRoot, agent: 'codex', bundles: [] });
186
+ expect(targets).toContain(path_1.default.join(projectRoot, 'AGENTS.md'));
187
+ });
188
+ it('omits every project-level target when no project root is found', () => {
189
+ const { planInitMutationTargets } = load();
190
+ const cwd = bareCwd(); // no .git/package.json/.awm ancestor
191
+ const targets = planInitMutationTargets({ cwd, agent: 'claude-code', bundles: [] });
192
+ expect(targets.some((t) => t.endsWith(path_1.default.join('.awm', 'profile.json')))).toBe(false);
193
+ expect(targets.some((t) => t.endsWith(path_1.default.join('.awm', 'sensors.json')))).toBe(false);
194
+ });
195
+ it('ignores an extension named in the profile that no longer resolves to a known bundle', () => {
196
+ const { planInitMutationTargets, providerFor } = load();
197
+ const projectRoot = makeProjectRoot();
198
+ fs_1.default.mkdirSync(path_1.default.join(projectRoot, '.awm'), { recursive: true });
199
+ fs_1.default.writeFileSync(path_1.default.join(projectRoot, '.awm', 'profile.json'), JSON.stringify({ extensions: ['stale-bundle'] }));
200
+ const targets = planInitMutationTargets({ cwd: projectRoot, agent: 'claude-code', bundles: [] });
201
+ const localSkillsDir = path_1.default.join(projectRoot, providerFor('claude-code').skill.local);
202
+ // No target should be produced under the local skills dir for a bundle
203
+ // that isn't in the (empty) known-bundles list passed in.
204
+ expect(targets.some((t) => t.startsWith(localSkillsDir))).toBe(false);
205
+ });
206
+ });
207
+ describe('Finding 1 fix — global skills directory itself', () => {
208
+ it('includes providerFor(agent).skill.global for every agent, independent of the bundle catalog', () => {
209
+ const { planInitMutationTargets, providerFor } = load();
210
+ for (const agent of ['claude-code', 'codex', 'opencode', 'antigravity']) {
211
+ const targets = planInitMutationTargets({ cwd: bareCwd(), agent, bundles: [] });
212
+ expect(targets).toContain(providerFor(agent).skill.global);
213
+ }
214
+ });
215
+ it('the skills-directory target covers orphaned entries repairGlobalSkills would mutate, not just bundle-derived subpaths', () => {
216
+ // Regression guard for the logic gap: before the fix, only
217
+ // bundle-DERIVED skill paths (e.g. .../skills/using-awm) were
218
+ // enumerated. An orphaned symlink like .../skills/some-old-skill
219
+ // (belonging to no currently-known bundle — the exact condition
220
+ // that makes repairGlobalSkills classify it as repairable/dead)
221
+ // would never appear in the bundle-driven enumeration. The parent
222
+ // directory itself must be present so beginBackupSession snapshots
223
+ // the whole tree.
224
+ const { planInitMutationTargets, providerFor } = load();
225
+ const bundles = [bundle('dev-core', 'baseline', ['using-awm'])];
226
+ const targets = planInitMutationTargets({ cwd: bareCwd(), agent: 'claude-code', bundles });
227
+ const skillsDir = providerFor('claude-code').skill.global;
228
+ expect(targets).toContain(skillsDir);
229
+ // Sanity: the orphan path itself is a child of the now-covered dir,
230
+ // even though it is never separately enumerated.
231
+ const orphanPath = path_1.default.join(skillsDir, 'some-old-orphaned-skill');
232
+ expect(orphanPath.startsWith(skillsDir)).toBe(true);
233
+ });
234
+ });
235
+ });
@@ -74,7 +74,7 @@ describe('runInitSteps — orchestrator', () => {
74
74
  const contentDir = roots[0] ?? '';
75
75
  const ctx = gatherContext({ cwd, bundles });
76
76
  return {
77
- cwd, ctx, bundles, agent: 'claude-code', installMethod: 'symlink',
77
+ cwd, ctx, bundles, agent: 'claude-code', enabledAgents: ['claude-code'], installMethod: 'symlink',
78
78
  registryRoot: contentRoot, contentDir, sensorPacksRoot: contentRoot,
79
79
  confirmExtensions: async (p) => p,
80
80
  // syncCache es no-op: el cache ya está sembrado en disco
@@ -0,0 +1,177 @@
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
+ // -----------------------------------------------------------------------
115
+ // Codex-only bootstrap: Claude's scriptsDir has NEVER existed.
116
+ //
117
+ // Every nested-exclusion test above seeds ~/.awm/hooks first, so they only
118
+ // ever exercise the present→present transition. On a fresh Codex-only
119
+ // machine (a cloud box, the actual rollout target) that directory does not
120
+ // exist, and installing the Codex hook creates it as a side effect of
121
+ // `mkdirSync(dirname, {recursive:true})` in syncExecutable — flipping
122
+ // Claude's own scriptsDir from absent to present-but-empty. That is not a
123
+ // change to anything Claude owns, but it moved the hash and aborted init.
124
+ // -----------------------------------------------------------------------
125
+ it('does NOT flag Codex creating its nested scriptsDir when Claude\'s has never existed', () => {
126
+ const { gatherProviderFacts, assertClaudeBaselinePreserved } = require('../../../src/core/init/provider-facts');
127
+ const claudeScripts = path_1.default.join(tmpHome, '.awm', 'hooks');
128
+ expect(fs_1.default.existsSync(claudeScripts)).toBe(false);
129
+ const before = gatherProviderFacts('claude-code');
130
+ // Exactly what syncExecutable does for the Codex hook: one recursive
131
+ // mkdir that necessarily materializes Claude's hooks/ as the parent.
132
+ const codexScripts = path_1.default.join(claudeScripts, 'codex');
133
+ fs_1.default.mkdirSync(codexScripts, { recursive: true });
134
+ fs_1.default.writeFileSync(path_1.default.join(codexScripts, 'session-start'), '#!/bin/sh\necho codex\n');
135
+ const after = gatherProviderFacts('claude-code');
136
+ expect(after.hash).toBe(before.hash);
137
+ expect(() => assertClaudeBaselinePreserved(before, after)).not.toThrow();
138
+ });
139
+ it('still flags real Claude content appearing where the directory did not exist', () => {
140
+ const { gatherProviderFacts, assertClaudeBaselinePreserved } = require('../../../src/core/init/provider-facts');
141
+ const before = gatherProviderFacts('claude-code');
142
+ // The guard must not be relaxed into blindness: an absent directory
143
+ // gaining Claude-owned content is still a genuine baseline change.
144
+ const claudeScripts = path_1.default.join(tmpHome, '.awm', 'hooks');
145
+ fs_1.default.mkdirSync(claudeScripts, { recursive: true });
146
+ fs_1.default.writeFileSync(path_1.default.join(claudeScripts, 'session-start'), '#!/bin/sh\n');
147
+ const after = gatherProviderFacts('claude-code');
148
+ expect(after.hash).not.toBe(before.hash);
149
+ expect(() => assertClaudeBaselinePreserved(before, after)).toThrow(/must never happen \(R19\)/);
150
+ });
151
+ it('still flags Claude content being emptied out of an existing directory', () => {
152
+ const { gatherProviderFacts, assertClaudeBaselinePreserved } = require('../../../src/core/init/provider-facts');
153
+ const claudeScripts = path_1.default.join(tmpHome, '.awm', 'hooks');
154
+ fs_1.default.mkdirSync(claudeScripts, { recursive: true });
155
+ fs_1.default.writeFileSync(path_1.default.join(claudeScripts, 'session-start'), '#!/bin/sh\n');
156
+ const before = gatherProviderFacts('claude-code');
157
+ fs_1.default.rmSync(path_1.default.join(claudeScripts, 'session-start'));
158
+ const after = gatherProviderFacts('claude-code');
159
+ expect(after.hash).not.toBe(before.hash);
160
+ expect(() => assertClaudeBaselinePreserved(before, after)).toThrow(/must never happen \(R19\)/);
161
+ });
162
+ it('still flags a genuinely Claude-owned change alongside an untouched Codex nested dir', () => {
163
+ const { gatherProviderFacts, assertClaudeBaselinePreserved } = require('../../../src/core/init/provider-facts');
164
+ const claudeScripts = path_1.default.join(tmpHome, '.awm', 'hooks');
165
+ const codexScripts = path_1.default.join(claudeScripts, 'codex');
166
+ fs_1.default.mkdirSync(codexScripts, { recursive: true });
167
+ fs_1.default.writeFileSync(path_1.default.join(claudeScripts, 'session-start'), '#!/bin/sh\n');
168
+ fs_1.default.writeFileSync(path_1.default.join(codexScripts, 'session-start'), '#!/bin/sh\necho codex\n');
169
+ const before = gatherProviderFacts('claude-code');
170
+ // A genuinely Claude-owned file (a sibling of the codex/ subdir, not inside it)
171
+ // changes — the exclusion must not swallow this.
172
+ fs_1.default.writeFileSync(path_1.default.join(claudeScripts, 'session-start'), '#!/bin/sh\necho mutated claude script\n');
173
+ const after = gatherProviderFacts('claude-code');
174
+ expect(after.hash).not.toBe(before.hash);
175
+ expect(() => assertClaudeBaselinePreserved(before, after)).toThrow(/must never happen \(R19\)/);
176
+ });
177
+ });
@@ -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
  });