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,96 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const fs_1 = __importDefault(require("fs"));
7
+ const os_1 = __importDefault(require("os"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const crypto_1 = __importDefault(require("crypto"));
10
+ const atomic_file_1 = require("../../src/core/atomic-file");
11
+ describe('writeFileAtomic', () => {
12
+ let dir;
13
+ beforeEach(() => {
14
+ dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-atomic-'));
15
+ });
16
+ afterEach(() => {
17
+ jest.restoreAllMocks();
18
+ fs_1.default.rmSync(dir, { recursive: true, force: true });
19
+ });
20
+ it('writes through an operation-scoped temporary file and renames it', () => {
21
+ const file = path_1.default.join(dir, 'nested', 'AGENTS.md');
22
+ const open = jest.spyOn(fs_1.default, 'openSync');
23
+ const rename = jest.spyOn(fs_1.default, 'renameSync');
24
+ (0, atomic_file_1.writeFileAtomic)(file, 'content', 0o600);
25
+ const temporary = String(open.mock.calls[0][0]);
26
+ expect(path_1.default.dirname(temporary)).toBe(path_1.default.dirname(file));
27
+ expect(path_1.default.basename(temporary)).toMatch(/^\.AGENTS\.md\.\d+\.[0-9a-f]{32}\.tmp$/);
28
+ expect(open.mock.calls[0][1]).toBe('wx');
29
+ expect(rename).toHaveBeenCalledWith(temporary, file);
30
+ expect(fs_1.default.readFileSync(file, 'utf8')).toBe('content');
31
+ expect(fs_1.default.statSync(file).mode & 0o777).toBe(0o600);
32
+ expect(fs_1.default.existsSync(temporary)).toBe(false);
33
+ });
34
+ it('cleans only its temporary file when rename fails', () => {
35
+ const file = path_1.default.join(dir, 'AGENTS.md');
36
+ const open = jest.spyOn(fs_1.default, 'openSync');
37
+ const unrelated = path_1.default.join(dir, '.unrelated.tmp');
38
+ fs_1.default.writeFileSync(unrelated, 'keep');
39
+ jest.spyOn(fs_1.default, 'renameSync').mockImplementation(() => {
40
+ throw new Error('rename failed');
41
+ });
42
+ expect(() => (0, atomic_file_1.writeFileAtomic)(file, 'content')).toThrow('rename failed');
43
+ const temp = String(open.mock.calls[0][0]);
44
+ expect(fs_1.default.existsSync(temp)).toBe(false);
45
+ expect(fs_1.default.readFileSync(unrelated, 'utf8')).toBe('keep');
46
+ expect(fs_1.default.existsSync(file)).toBe(false);
47
+ });
48
+ it('preserves the permission bits and content of an existing regular target on failure', () => {
49
+ const file = path_1.default.join(dir, 'AGENTS.md');
50
+ fs_1.default.writeFileSync(file, 'original', { mode: 0o600 });
51
+ jest.spyOn(fs_1.default, 'renameSync').mockImplementation(() => {
52
+ throw new Error('rename failed');
53
+ });
54
+ expect(() => (0, atomic_file_1.writeFileAtomic)(file, 'replacement', 0o644)).toThrow('rename failed');
55
+ expect(fs_1.default.readFileSync(file, 'utf8')).toBe('original');
56
+ expect(fs_1.default.statSync(file).mode & 0o777).toBe(0o600);
57
+ });
58
+ it('preserves existing target permissions after replacement', () => {
59
+ const file = path_1.default.join(dir, 'AGENTS.md');
60
+ fs_1.default.writeFileSync(file, 'original', { mode: 0o600 });
61
+ (0, atomic_file_1.writeFileAtomic)(file, 'replacement');
62
+ expect(fs_1.default.readFileSync(file, 'utf8')).toBe('replacement');
63
+ expect(fs_1.default.statSync(file).mode & 0o777).toBe(0o600);
64
+ });
65
+ it('rejects a target symlink without severing it or changing its victim', () => {
66
+ const victim = path_1.default.join(dir, 'victim');
67
+ const file = path_1.default.join(dir, 'AGENTS.md');
68
+ fs_1.default.writeFileSync(victim, 'victim');
69
+ fs_1.default.symlinkSync(victim, file);
70
+ expect(() => (0, atomic_file_1.writeFileAtomic)(file, 'replacement')).toThrow('refusing to replace symlink');
71
+ expect(fs_1.default.lstatSync(file).isSymbolicLink()).toBe(true);
72
+ expect(fs_1.default.readFileSync(victim, 'utf8')).toBe('victim');
73
+ });
74
+ it('cannot follow or remove a pre-created staging symlink', () => {
75
+ const file = path_1.default.join(dir, 'AGENTS.md');
76
+ const victim = path_1.default.join(dir, 'victim');
77
+ const random = Buffer.alloc(16, 0xab);
78
+ const temporary = path_1.default.join(dir, `.AGENTS.md.${process.pid}.${random.toString('hex')}.tmp`);
79
+ fs_1.default.writeFileSync(victim, 'victim');
80
+ fs_1.default.symlinkSync(victim, temporary);
81
+ jest.spyOn(crypto_1.default, 'randomBytes').mockImplementation((() => random));
82
+ expect(() => (0, atomic_file_1.writeFileAtomic)(file, 'replacement')).toThrow();
83
+ expect(fs_1.default.lstatSync(temporary).isSymbolicLink()).toBe(true);
84
+ expect(fs_1.default.readFileSync(victim, 'utf8')).toBe('victim');
85
+ expect(fs_1.default.existsSync(file)).toBe(false);
86
+ });
87
+ it.each([
88
+ ['', 'content', 0o644, 'file'],
89
+ ['file', null, 0o644, 'content'],
90
+ ['file', 'content', -1, 'mode'],
91
+ ['file', 'content', 1.5, 'mode'],
92
+ ['file', 'content', 0o10000, 'mode'],
93
+ ])('rejects invalid runtime inputs', (file, content, mode, message) => {
94
+ expect(() => (0, atomic_file_1.writeFileAtomic)(file, content, mode)).toThrow(message);
95
+ });
96
+ });
@@ -1,4 +1,37 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
37
  };
@@ -9,6 +42,61 @@ const path_1 = __importDefault(require("path"));
9
42
  const bundles_1 = require("../../src/core/bundles");
10
43
  const bundle_install_1 = require("../../src/core/bundle-install");
11
44
  const profile_1 = require("../../src/core/profile");
45
+ const executor_1 = require("../../src/core/executor");
46
+ const executor = __importStar(require("../../src/core/executor"));
47
+ /**
48
+ * `installBundle` is a thin façade — it expands the bundle closure into
49
+ * artifact intents, runs them through `planInstall` (dedup + ownership +
50
+ * shared-skill-group gating), and hands the resulting plan to
51
+ * `applyInstallPlan` (install-transaction.ts) to actually touch the
52
+ * filesystem. Most tests below use this simpler test double instead — it
53
+ * skips per-artifact on a missing source (unlike the real, transactional
54
+ * applyInstallPlan, which aborts the whole plan) and doesn't render
55
+ * `codex-agent-toml` targets, which keeps the fixtures here from needing
56
+ * valid canonical-agent frontmatter for every scenario. Tests that
57
+ * specifically exercise the REAL default applyInstallPlan pass no
58
+ * `applyPlan` override at all — see below.
59
+ */
60
+ function testApplyPlan(plan) {
61
+ const installed = [];
62
+ const skipped = [];
63
+ const modifiedFiles = [];
64
+ for (const op of plan.operations) {
65
+ if (!fs_1.default.existsSync(op.sourcePath)) {
66
+ skipped.push(`${op.name} (source missing: ${op.sourcePath})`);
67
+ continue;
68
+ }
69
+ (0, executor_1.installArtifact)(op.sourcePath, op.targetPath, op.method);
70
+ installed.push(`${op.name} → ${op.owners.join(',')} (${op.scope})`);
71
+ modifiedFiles.push(op.targetPath);
72
+ }
73
+ return { installed, skipped, transactionId: 'test-fixture', modifiedFiles };
74
+ }
75
+ // Per CLAUDE.md: no test may touch the real ~/.awm. installBundle now calls
76
+ // getPreferences() internally (to resolve enabledAgents for the planner), so
77
+ // every test in this file needs HOME/AWM_HOME pointed at an isolated tmpdir,
78
+ // following the pattern in tests/commands/hooks/install.test.ts.
79
+ let tmpHome;
80
+ let originalHome;
81
+ let originalAwmHome;
82
+ beforeEach(() => {
83
+ tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-binstall-home-'));
84
+ originalHome = process.env.HOME;
85
+ originalAwmHome = process.env.AWM_HOME;
86
+ process.env.HOME = tmpHome;
87
+ process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
88
+ });
89
+ afterEach(() => {
90
+ fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
91
+ if (originalHome === undefined)
92
+ delete process.env.HOME;
93
+ else
94
+ process.env.HOME = originalHome;
95
+ if (originalAwmHome === undefined)
96
+ delete process.env.AWM_HOME;
97
+ else
98
+ process.env.AWM_HOME = originalAwmHome;
99
+ });
12
100
  /**
13
101
  * Builds a fixture with:
14
102
  * - content registry: catalog + two project bundles (base, ext dependsOn base),
@@ -32,7 +120,7 @@ function makeFixture() {
32
120
  fs_1.default.mkdirSync(path_1.default.join(content, 'workflows'), { recursive: true });
33
121
  fs_1.default.writeFileSync(path_1.default.join(content, 'workflows', 'wf-ext.md'), '# wf');
34
122
  fs_1.default.mkdirSync(path_1.default.join(content, 'agents'), { recursive: true });
35
- fs_1.default.writeFileSync(path_1.default.join(content, 'agents', 'ag-ext.md'), '# agent');
123
+ fs_1.default.writeFileSync(path_1.default.join(content, 'agents', 'ag-ext.md'), '---\nname: ag-ext\ndescription: Test agent for bundle-install fixtures\n---\nDo the thing.\n');
36
124
  fs_1.default.writeFileSync(path_1.default.join(content, 'catalog.json'), JSON.stringify({
37
125
  version: 1,
38
126
  bundles: [
@@ -55,7 +143,7 @@ describe('installBundle', () => {
55
143
  const { content, projectRoot, bundles } = makeFixture();
56
144
  const result = (0, bundle_install_1.installBundle)({
57
145
  bundleName: 'ext', bundles, agents: ['claude-code'],
58
- method: 'symlink', projectRoot, contentDir: content,
146
+ method: 'symlink', projectRoot, contentDir: content, applyPlan: testApplyPlan,
59
147
  });
60
148
  const skillsDir = path_1.default.join(projectRoot, '.claude', 'skills');
61
149
  expect(fs_1.default.existsSync(path_1.default.join(skillsDir, 's-base'))).toBe(true); // from dep `base`
@@ -63,21 +151,29 @@ describe('installBundle', () => {
63
151
  expect(fs_1.default.lstatSync(path_1.default.join(skillsDir, 's-ext')).isSymbolicLink()).toBe(true);
64
152
  expect(result.installed.some((l) => l.includes('s-base'))).toBe(true);
65
153
  });
66
- it('installs supported artifact types and skips unsupported ones (claude-code workflows)', () => {
154
+ it('installs supported artifact types (claude-code agents)', () => {
67
155
  const { content, projectRoot, bundles } = makeFixture();
68
156
  const result = (0, bundle_install_1.installBundle)({
69
157
  bundleName: 'ext', bundles, agents: ['claude-code'],
70
- method: 'symlink', projectRoot, contentDir: content,
158
+ method: 'symlink', projectRoot, contentDir: content, applyPlan: testApplyPlan,
71
159
  });
72
- // claude-code has no workflow dir → wf-ext is skipped; agents are supported.
73
160
  expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.claude', 'agents', 'ag-ext.md'))).toBe(true);
74
- expect(result.skipped.some((l) => l.includes('wf-ext'))).toBe(true);
161
+ // ACCEPTED GAP (Task 5, confirmed unchanged by Task 6): claude-code has
162
+ // no `workflow` provider config, so planInstall (install-planner.ts)
163
+ // silently drops wf-ext for this agent before a plan/operations ever
164
+ // exist for it — there is no "unsupported for this agent" signal for
165
+ // applyInstallPlan to surface as `skipped`, by design (install-planner.ts
166
+ // mirrors the legacy installBundle skip semantics for this exact case).
167
+ // Fixing this would mean adding a skip-reason channel to InstallPlan
168
+ // itself, which is install-planner.ts's contract (Task 5), not Task 6's.
169
+ expect(result.skipped.some((l) => l.includes('wf-ext'))).toBe(false);
170
+ expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.claude', 'workflows', 'wf-ext.md'))).toBe(false);
75
171
  });
76
172
  it('is idempotent: a second run leaves valid symlinks and does not throw', () => {
77
173
  const { content, projectRoot, bundles } = makeFixture();
78
174
  const opts = {
79
175
  bundleName: 'ext', bundles, agents: ['claude-code'],
80
- method: 'symlink', projectRoot, contentDir: content,
176
+ method: 'symlink', projectRoot, contentDir: content, applyPlan: testApplyPlan,
81
177
  };
82
178
  (0, bundle_install_1.installBundle)(opts);
83
179
  expect(() => (0, bundle_install_1.installBundle)(opts)).not.toThrow();
@@ -88,18 +184,113 @@ describe('installBundle', () => {
88
184
  fs_1.default.rmSync(path_1.default.join(content, 'skills', 's-base'), { recursive: true, force: true });
89
185
  const result = (0, bundle_install_1.installBundle)({
90
186
  bundleName: 'ext', bundles, agents: ['claude-code'],
91
- method: 'symlink', projectRoot, contentDir: content,
187
+ method: 'symlink', projectRoot, contentDir: content, applyPlan: testApplyPlan,
92
188
  });
93
189
  expect(result.skipped.some((l) => l.includes('s-base'))).toBe(true);
94
190
  expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.claude', 'skills', 's-ext'))).toBe(true);
95
191
  });
192
+ it('materializes a Codex agent as rendered TOML via the real default applyInstallPlan (no applyPlan override)', () => {
193
+ const { content, projectRoot, bundles } = makeFixture();
194
+ const ext = bundles.find((bundle) => bundle.name === 'ext');
195
+ const agentOnly = [{
196
+ ...ext,
197
+ dependsOn: [],
198
+ skills: [],
199
+ workflows: [],
200
+ }];
201
+ const result = (0, bundle_install_1.installBundle)({
202
+ bundleName: 'ext',
203
+ bundles: agentOnly,
204
+ agents: ['codex'],
205
+ method: 'symlink',
206
+ projectRoot,
207
+ contentDir: content,
208
+ });
209
+ const tomlPath = path_1.default.join(projectRoot, '.codex/agents/ag-ext.toml');
210
+ expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.codex/agents/ag-ext.md'))).toBe(false);
211
+ expect(fs_1.default.existsSync(tomlPath)).toBe(true);
212
+ expect(fs_1.default.readFileSync(tomlPath, 'utf8')).toContain('name = "ag-ext"');
213
+ expect(result.transactionId).toBeTruthy();
214
+ expect(result.modifiedFiles).toContain(tomlPath);
215
+ });
216
+ it('keeps Codex skill installs on the legacy link renderer', () => {
217
+ const { content, projectRoot, bundles } = makeFixture();
218
+ const base = bundles.find((bundle) => bundle.name === 'base');
219
+ const result = (0, bundle_install_1.installBundle)({
220
+ bundleName: 'base',
221
+ bundles: [base],
222
+ agents: ['codex'],
223
+ method: 'symlink',
224
+ projectRoot,
225
+ contentDir: content,
226
+ applyPlan: testApplyPlan,
227
+ });
228
+ expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.agents/skills/s-base'))).toBe(true);
229
+ // KNOWN FORMAT CHANGE (Task 6): the old `[bundleName]` suffix is gone —
230
+ // ArtifactIntent (and therefore the plan) no longer carries bundle
231
+ // provenance, only artifact name/type/owners. See this task's report.
232
+ expect(result.installed).toContain('s-base → codex (local)');
233
+ });
234
+ it('applies real filesystem changes for mixed Codex bundles when no applyPlan is supplied', () => {
235
+ const { content, projectRoot, bundles } = makeFixture();
236
+ const result = (0, bundle_install_1.addBundle)({
237
+ bundleName: 'ext',
238
+ bundles,
239
+ agents: ['codex'],
240
+ method: 'symlink',
241
+ projectRoot,
242
+ contentDir: content,
243
+ });
244
+ expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.agents/skills/s-base'))).toBe(true);
245
+ expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.agents/skills/s-ext'))).toBe(true);
246
+ expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.codex/agents/ag-ext.toml'))).toBe(true);
247
+ expect(result.recordedExtension).toBe('ext');
248
+ expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.awm/profile.json'))).toBe(true);
249
+ expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.gitignore'))).toBe(true);
250
+ });
251
+ it('installs a skill shared by two agents (OpenCode + Codex) with exactly one replaceArtifact call, but the summary contains both providers', () => {
252
+ const { content, projectRoot, bundles } = makeFixture();
253
+ const base = bundles.find((bundle) => bundle.name === 'base');
254
+ const replaceSpy = jest.spyOn(executor, 'replaceArtifact');
255
+ const result = (0, bundle_install_1.installBundle)({
256
+ bundleName: 'base',
257
+ bundles: [base],
258
+ agents: ['opencode', 'codex'],
259
+ method: 'symlink',
260
+ projectRoot,
261
+ contentDir: content,
262
+ });
263
+ const skillTarget = path_1.default.join(projectRoot, '.agents/skills/s-base');
264
+ expect(fs_1.default.existsSync(skillTarget)).toBe(true);
265
+ // One physical write for the shared target...
266
+ expect(replaceSpy).toHaveBeenCalledTimes(1);
267
+ replaceSpy.mockRestore();
268
+ // ...but the real installBundle/applyInstallPlan result must report
269
+ // BOTH providers as owning the artifact, not just the one whose
270
+ // selection triggered the write (install-planner.ts tags the other as
271
+ // 'retain', which applyInstallPlan must still surface).
272
+ const installedForSkill = result.installed.filter((line) => line.startsWith('s-base → '));
273
+ expect(installedForSkill).toEqual(expect.arrayContaining(['s-base → opencode', 's-base → codex']));
274
+ expect(result.modifiedFiles).toContain(skillTarget);
275
+ });
96
276
  });
97
277
  describe('addBundle', () => {
278
+ // KNOWN RED (Task 6): addBundle's `recordedExtension` detection greps
279
+ // `summary.installed` lines for a `[bundleName]` suffix to tell "this
280
+ // bundle's own artifacts installed" apart from "only a dependency
281
+ // installed". ArtifactIntent (Task 5's contract, see install-planner.ts)
282
+ // does not carry bundle provenance, so that suffix can no longer be
283
+ // produced anywhere in the pipeline — recordedExtension is always null
284
+ // now. Left unfixed deliberately: fixing it means either adding bundle
285
+ // provenance to ArtifactIntent/ManagedArtifactRecord or having addBundle
286
+ // compare against the named bundle's own artifact list directly, both of
287
+ // which are Task 6-shaped decisions (it owns applyInstallPlan's real
288
+ // return contract). Flagged prominently in this task's final report.
98
289
  it('records a project bundle installed locally as an extension + gitignores symlinks', () => {
99
290
  const { content, projectRoot, bundles } = makeFixture();
100
291
  const result = (0, bundle_install_1.addBundle)({
101
292
  bundleName: 'ext', bundles, agents: ['claude-code'],
102
- method: 'symlink', projectRoot, contentDir: content,
293
+ method: 'symlink', projectRoot, contentDir: content, applyPlan: testApplyPlan,
103
294
  });
104
295
  expect(result.recordedExtension).toBe('ext');
105
296
  expect((0, profile_1.readProfile)(projectRoot).extensions).toEqual(['ext']);
@@ -110,7 +301,7 @@ describe('addBundle', () => {
110
301
  const { content, projectRoot, bundles } = makeFixture();
111
302
  (0, bundle_install_1.addBundle)({
112
303
  bundleName: 'ext', bundles, agents: ['claude-code'],
113
- method: 'symlink', projectRoot, contentDir: content,
304
+ method: 'symlink', projectRoot, contentDir: content, applyPlan: testApplyPlan,
114
305
  });
115
306
  expect((0, profile_1.readProfile)(projectRoot).extensions).toEqual(['ext']); // not ['base','ext']
116
307
  });
@@ -118,7 +309,7 @@ describe('addBundle', () => {
118
309
  const { content, projectRoot, bundles } = makeFixture();
119
310
  const opts = {
120
311
  bundleName: 'ext', bundles, agents: ['claude-code'],
121
- method: 'symlink', projectRoot, contentDir: content,
312
+ method: 'symlink', projectRoot, contentDir: content, applyPlan: testApplyPlan,
122
313
  };
123
314
  (0, bundle_install_1.addBundle)(opts);
124
315
  (0, bundle_install_1.addBundle)(opts);
@@ -131,7 +322,7 @@ describe('addBundle', () => {
131
322
  fs_1.default.rmSync(path_1.default.join(content, 'agents', 'ag-ext.md'), { force: true });
132
323
  const result = (0, bundle_install_1.addBundle)({
133
324
  bundleName: 'ext', bundles, agents: ['claude-code'],
134
- method: 'symlink', projectRoot, contentDir: content,
325
+ method: 'symlink', projectRoot, contentDir: content, applyPlan: testApplyPlan,
135
326
  });
136
327
  expect(result.recordedExtension).toBeNull();
137
328
  expect((0, profile_1.readProfile)(projectRoot).extensions).toEqual([]);
@@ -143,7 +334,7 @@ describe('addBundle', () => {
143
334
  fs_1.default.rmSync(path_1.default.join(content, 'agents', 'ag-ext.md'), { force: true });
144
335
  const result = (0, bundle_install_1.addBundle)({
145
336
  bundleName: 'ext', bundles, agents: ['claude-code'],
146
- method: 'symlink', projectRoot, contentDir: content,
337
+ method: 'symlink', projectRoot, contentDir: content, applyPlan: testApplyPlan,
147
338
  });
148
339
  expect(result.installed.some((l) => l.includes('s-base'))).toBe(true); // dep installed
149
340
  expect(result.recordedExtension).toBeNull(); // ext not recorded
@@ -153,7 +344,7 @@ describe('addBundle', () => {
153
344
  const { content, projectRoot, bundles } = makeFixture();
154
345
  const result = (0, bundle_install_1.addBundle)({
155
346
  bundleName: 'ext', bundles, agents: ['claude-code'],
156
- method: 'symlink', projectRoot, contentDir: content,
347
+ method: 'symlink', projectRoot, contentDir: content, applyPlan: testApplyPlan,
157
348
  scopeOverride: 'local',
158
349
  });
159
350
  expect(result.recordedExtension).toBe('ext');
@@ -167,7 +358,7 @@ describe('syncProfile', () => {
167
358
  (0, profile_1.writeProfile)(projectRoot, { extensions: ['ext'] });
168
359
  const result = (0, bundle_install_1.syncProfile)({
169
360
  projectRoot, bundles, agents: ['claude-code'],
170
- method: 'symlink', contentDir: content,
361
+ method: 'symlink', contentDir: content, applyPlan: testApplyPlan,
171
362
  });
172
363
  expect(result.extensions).toEqual(['ext']);
173
364
  expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.claude', 'skills', 's-ext'))).toBe(true);
@@ -178,7 +369,7 @@ describe('syncProfile', () => {
178
369
  (0, profile_1.writeProfile)(projectRoot, { extensions: [] });
179
370
  const result = (0, bundle_install_1.syncProfile)({
180
371
  projectRoot, bundles, agents: ['claude-code'],
181
- method: 'symlink', contentDir: content,
372
+ method: 'symlink', contentDir: content, applyPlan: testApplyPlan,
182
373
  });
183
374
  expect(result.extensions).toEqual([]);
184
375
  expect(result.installed).toEqual([]);
@@ -189,7 +380,7 @@ describe('syncProfile', () => {
189
380
  (0, profile_1.writeProfile)(projectRoot, { extensions: ['stale-bundle', 'ext'] });
190
381
  const result = (0, bundle_install_1.syncProfile)({
191
382
  projectRoot, bundles, agents: ['claude-code'],
192
- method: 'symlink', contentDir: content,
383
+ method: 'symlink', contentDir: content, applyPlan: testApplyPlan,
193
384
  });
194
385
  expect(result.extensions).toEqual(['stale-bundle', 'ext']);
195
386
  expect(result.skipped.some((l) => l.includes('stale-bundle'))).toBe(true);
@@ -68,6 +68,18 @@ describe('resolveBundleSkills', () => {
68
68
  expect((0, bundles_1.resolveBundleSkills)('dev', bundles).sort()).toEqual(['architecture-advisor', 'brainstorming']);
69
69
  });
70
70
  });
71
+ describe('resolveBundleAgents', () => {
72
+ it('follows dependsOn transitively and dedupes, mirroring resolveBundleSkills', () => {
73
+ const content = makeFixture();
74
+ const bundles = (0, bundles_1.discoverBundles)(content);
75
+ expect((0, bundles_1.resolveBundleAgents)('frontend', bundles)).toEqual(['development-process']); // via dep on 'dev'
76
+ });
77
+ it('returns own agents when no deps', () => {
78
+ const content = makeFixture();
79
+ const bundles = (0, bundles_1.discoverBundles)(content);
80
+ expect((0, bundles_1.resolveBundleAgents)('dev', bundles)).toEqual(['development-process']);
81
+ });
82
+ });
71
83
  describe('defaultScopeForBundle', () => {
72
84
  it('maps baseline and ambient to global, project to local', () => {
73
85
  expect((0, bundles_1.defaultScopeForBundle)('baseline')).toBe('global');
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const managed_block_1 = require("../../../src/core/context/managed-block");
4
+ const body = 'Use AWM through the development-process skill.';
5
+ const malformedBoundaries = [
6
+ `${managed_block_1.AWM_START}\n<!-- AWM:BOUNDARY prefix=9 suffix=1 -->\nowned\n${managed_block_1.AWM_END}`,
7
+ `${managed_block_1.AWM_START}\n<!-- AWM:BOUNDARY prefix=1 suffix=2 -->\nowned\n${managed_block_1.AWM_END}`,
8
+ `${managed_block_1.AWM_START}\n<!-- AWM:BOUNDARY prefix=1 suffix=1 -->\n<!-- AWM:BOUNDARY prefix=0 suffix=1 -->\nowned\n${managed_block_1.AWM_END}`,
9
+ `${managed_block_1.AWM_START}\nowned\n<!-- AWM:BOUNDARY prefix=1 suffix=1 -->\n${managed_block_1.AWM_END}`,
10
+ `${managed_block_1.AWM_START}\n<!-- AWM:BOUNDARY prefix=1\nowned\n${managed_block_1.AWM_END}`,
11
+ `${managed_block_1.AWM_START}\ninline <!-- AWM:BOUNDARY prefix=1 suffix=1 -->\nowned\n${managed_block_1.AWM_END}`,
12
+ `<!-- AWM:BOUNDARY prefix=0 suffix=1 -->\n${managed_block_1.AWM_START}\nowned\n${managed_block_1.AWM_END}`,
13
+ ];
14
+ describe('mergeManagedBlock', () => {
15
+ it('appends exactly one block and preserves user content byte-for-byte', () => {
16
+ const original = '# My rules\n\nKeep this.\n';
17
+ const merged = (0, managed_block_1.mergeManagedBlock)(original, body);
18
+ expect(merged).toContain(original);
19
+ expect(merged.match(/<!-- AWM:START -->/g)).toHaveLength(1);
20
+ expect((0, managed_block_1.mergeManagedBlock)(merged, body)).toBe(merged);
21
+ });
22
+ it.each([
23
+ ['', `${managed_block_1.AWM_START}\n<!-- AWM:BOUNDARY prefix=0 suffix=1 -->\n${body}\n${managed_block_1.AWM_END}\n`],
24
+ ['user', `user\n\n${managed_block_1.AWM_START}\n<!-- AWM:BOUNDARY prefix=2 suffix=1 -->\n${body}\n${managed_block_1.AWM_END}\n`],
25
+ ['user\n', `user\n\n${managed_block_1.AWM_START}\n<!-- AWM:BOUNDARY prefix=1 suffix=1 -->\n${body}\n${managed_block_1.AWM_END}\n`],
26
+ ['user\n\n', `user\n\n${managed_block_1.AWM_START}\n<!-- AWM:BOUNDARY prefix=0 suffix=1 -->\n${body}\n${managed_block_1.AWM_END}\n`],
27
+ ])('uses a stable separator for original %j', (original, expected) => {
28
+ expect((0, managed_block_1.mergeManagedBlock)(original, body)).toBe(expected);
29
+ });
30
+ it('replaces a valid block while preserving every byte before and after it', () => {
31
+ const before = 'prefix\r\n\r\n';
32
+ const after = '\r\nsuffix\n';
33
+ const original = `${before}${managed_block_1.AWM_START}\nold\n${managed_block_1.AWM_END}${after}`;
34
+ expect((0, managed_block_1.mergeManagedBlock)(original, 'new\n')).toBe(`${before}${managed_block_1.AWM_START}\nnew\n${managed_block_1.AWM_END}${after}`);
35
+ });
36
+ it('normalizes only trailing newlines in the managed body and is idempotent', () => {
37
+ const once = (0, managed_block_1.mergeManagedBlock)('', `${body}\n\n`);
38
+ expect((0, managed_block_1.mergeManagedBlock)(once, `${body}\n`)).toBe(once);
39
+ });
40
+ it.each([
41
+ [`${managed_block_1.AWM_START}\nbody\n`, 'unmatched'],
42
+ [`${managed_block_1.AWM_END}\n`, 'unmatched'],
43
+ [`${managed_block_1.AWM_START}\na\n${managed_block_1.AWM_END}\n${managed_block_1.AWM_START}\nb\n${managed_block_1.AWM_END}`, 'duplicate'],
44
+ [`${managed_block_1.AWM_START}\n${managed_block_1.AWM_START}\n${managed_block_1.AWM_END}\n${managed_block_1.AWM_END}`, 'nested'],
45
+ [`${managed_block_1.AWM_END}\nbody\n${managed_block_1.AWM_START}`, 'reversed'],
46
+ ['<!-- AWM:STARTED -->\nuser', 'malformed'],
47
+ ['<!-- prefix AWM:END -->\nuser', 'malformed'],
48
+ ['<!-- AWM:START', 'incomplete'],
49
+ ['<!-- AWM:END', 'incomplete'],
50
+ [`inline ${managed_block_1.AWM_START}\nbody\n${managed_block_1.AWM_END}`, 'standalone'],
51
+ [`\`${managed_block_1.AWM_START}\` and \`${managed_block_1.AWM_END}\``, 'standalone'],
52
+ [`\`\`\`md\n${managed_block_1.AWM_START}\nbody\n${managed_block_1.AWM_END}\n\`\`\``, 'standalone'],
53
+ ])('rejects ambiguous markers in %j', (input, message) => {
54
+ expect(() => (0, managed_block_1.mergeManagedBlock)(input, body)).toThrow(message);
55
+ });
56
+ it.each([
57
+ [null, body, 'original'],
58
+ [undefined, body, 'original'],
59
+ [42, body, 'original'],
60
+ ['', null, 'body'],
61
+ ['', {}, 'body'],
62
+ ])('rejects invalid runtime input', (original, managedBody, message) => {
63
+ expect(() => (0, managed_block_1.mergeManagedBlock)(original, managedBody)).toThrow(message);
64
+ });
65
+ it.each(malformedBoundaries)('rejects ambiguous boundary metadata in %j', (original) => {
66
+ expect(() => (0, managed_block_1.mergeManagedBlock)(original, body)).toThrow('malformed AWM boundary metadata');
67
+ });
68
+ });
69
+ describe('removeManagedBlock', () => {
70
+ it.each([
71
+ '',
72
+ 'user',
73
+ 'user\n',
74
+ 'user\n\n',
75
+ '# Rules\n\nKeep every byte.\n',
76
+ ])('restores original %j byte-for-byte after merge', (original) => {
77
+ expect((0, managed_block_1.removeManagedBlock)((0, managed_block_1.mergeManagedBlock)(original, body))).toBe(original);
78
+ });
79
+ it('removes only the valid block and preserves every surrounding byte', () => {
80
+ const original = `before\n\n${managed_block_1.AWM_START}\nowned\n${managed_block_1.AWM_END}\n\nafter`;
81
+ expect((0, managed_block_1.removeManagedBlock)(original)).toBe('before\n\n\n\nafter');
82
+ });
83
+ it('is unchanged when no block exists and rejects ambiguous markers', () => {
84
+ expect((0, managed_block_1.removeManagedBlock)('user only\n')).toBe('user only\n');
85
+ expect(() => (0, managed_block_1.removeManagedBlock)(`${managed_block_1.AWM_START}\nmissing end`)).toThrow('unmatched');
86
+ });
87
+ it.each(malformedBoundaries)('rejects ambiguous boundary metadata before removal in %j', (original) => {
88
+ expect(() => (0, managed_block_1.removeManagedBlock)(original)).toThrow('malformed AWM boundary metadata');
89
+ });
90
+ });
@@ -25,7 +25,7 @@ function tmpRegistry() {
25
25
  }
26
26
  describe('InjectionOrchestrator (claude-code dispatch via HookMergeStrategy)', () => {
27
27
  const ccOverride = {
28
- label: 'Claude Code', skill: { global: '', local: '' }, workflow: null, agent: null,
28
+ label: 'Claude Code', skill: { global: '', local: '', renderer: 'link' }, workflow: null, agent: null,
29
29
  injection: { type: 'cc-settings-merge' },
30
30
  hooks: { type: 'cc-settings-merge', settingsPath: '', scriptsDir: '', matcher: '', eventName: '' },
31
31
  };
@@ -61,7 +61,12 @@ describe('InjectionOrchestrator (claude-code dispatch via HookMergeStrategy)', (
61
61
  expect(state).toBe('injected');
62
62
  });
63
63
  it('throws when providerOverride has no injection (does not fall through to real agent config)', () => {
64
- const noInjection = { label: 'Test', skill: { global: '', local: '' }, workflow: null, agent: null };
64
+ const noInjection = {
65
+ label: 'Test',
66
+ skill: { global: '', local: '', renderer: 'link' },
67
+ workflow: null,
68
+ agent: null,
69
+ };
65
70
  const orch = new orchestrator_1.InjectionOrchestrator({ providerOverride: noInjection });
66
71
  expect(() => orch.installContext({ agent: 'claude-code', scope: 'global', registryRoot: '/any', installMethod: 'symlink', profileExtensions: [] }))
67
72
  .toThrow('no injection mechanism');
@@ -79,7 +84,7 @@ describe('InjectionOrchestrator (opencode, real strategy)', () => {
79
84
  registryRoot = tmpRegistry();
80
85
  orch = new orchestrator_1.InjectionOrchestrator({
81
86
  providerOverride: {
82
- label: 'OpenCode', skill: { global: '', local: '' }, workflow: null, agent: null,
87
+ label: 'OpenCode', skill: { global: '', local: '', renderer: 'link' }, workflow: null, agent: null,
83
88
  injection: { type: 'config-instructions', configPath, field: 'instructions' },
84
89
  },
85
90
  contextPathOverride: absPath,
@@ -125,3 +130,43 @@ describe('InjectionOrchestrator (opencode, real strategy)', () => {
125
130
  expect((cfg.instructions ?? []).includes(absPath)).toBe(false);
126
131
  });
127
132
  });
133
+ describe('InjectionOrchestrator (codex, managed AGENTS.md strategy)', () => {
134
+ let roots;
135
+ beforeEach(() => {
136
+ roots = [];
137
+ jest.clearAllMocks();
138
+ });
139
+ afterEach(() => {
140
+ for (const root of roots)
141
+ fs_1.default.rmSync(root, { recursive: true, force: true });
142
+ });
143
+ it('owns only the managed block and never dispatches to Claude hooks', () => {
144
+ const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-codex-orch-'));
145
+ const registryRoot = tmpRegistry();
146
+ roots.push(dir, registryRoot);
147
+ const agentsPath = path_1.default.join(dir, 'AGENTS.md');
148
+ const contextPath = path_1.default.join(dir, 'awm-context.md');
149
+ fs_1.default.writeFileSync(agentsPath, '# User rules\n');
150
+ const orch = new orchestrator_1.InjectionOrchestrator({
151
+ providerOverride: {
152
+ label: 'Codex', skill: { global: '', local: '', renderer: 'link' }, workflow: null, agent: null,
153
+ injection: { type: 'managed-agents-md', globalPath: agentsPath, localFile: 'AGENTS.md' },
154
+ },
155
+ contextPathOverride: contextPath,
156
+ });
157
+ const op = {
158
+ agent: 'codex',
159
+ scope: 'global',
160
+ registryRoot,
161
+ installMethod: 'copy',
162
+ profileExtensions: [],
163
+ };
164
+ orch.installContext(op);
165
+ expect(fs_1.default.readFileSync(agentsPath, 'utf8')).toContain('# User rules\n');
166
+ expect(fs_1.default.readFileSync(agentsPath, 'utf8')).toContain('BODY');
167
+ expect(install_1.installHook).not.toHaveBeenCalled();
168
+ expect(orch.contextStatus(op)).toBe('injected');
169
+ orch.uninstallContext(op);
170
+ expect(fs_1.default.readFileSync(agentsPath, 'utf8')).toBe('# User rules\n');
171
+ });
172
+ });
@@ -100,7 +100,7 @@ describe('regenerateGlobalContext', () => {
100
100
  const { InjectionOrchestrator } = require('../../../src/core/context/orchestrator');
101
101
  const brokenOrch = new InjectionOrchestrator();
102
102
  brokenOrch.contextStatus = () => { throw new Error('unexpected orchestrator error'); };
103
- expect(regenerateGlobalContext(brokenOrch)).toEqual([{ agent: 'opencode', action: 'skipped' }]);
103
+ expect(regenerateGlobalContext(undefined, brokenOrch)).toEqual([{ agent: 'opencode', action: 'skipped' }]);
104
104
  expect(fs_1.default.existsSync(contextPath())).toBe(false); // installContext never called
105
105
  });
106
106
  });