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,257 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ // tests/core/install-transaction.test.ts
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const os_1 = __importDefault(require("os"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const install_transaction_1 = require("../../src/core/install-transaction");
11
+ const artifact_state_1 = require("../../src/core/artifact-state");
12
+ // Per CLAUDE.md: no test may touch the real ~/.awm. Every test here backs up
13
+ // or transacts through awmHome(), so HOME/AWM_HOME must point at an isolated
14
+ // tmpdir (pattern from tests/commands/hooks/install.test.ts).
15
+ let tmpHome;
16
+ let tmpWork;
17
+ let originalHome;
18
+ let originalAwmHome;
19
+ beforeEach(() => {
20
+ tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-txn-home-'));
21
+ tmpWork = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-txn-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 makeOp(name, overrides = {}) {
40
+ return {
41
+ name,
42
+ type: 'skill',
43
+ scope: 'local',
44
+ targetPath: path_1.default.join(tmpWork, name),
45
+ sourcePath: path_1.default.join(tmpWork, `${name}-source`),
46
+ renderer: 'link',
47
+ owners: ['claude-code'],
48
+ method: 'copy',
49
+ output: 'link',
50
+ ...overrides,
51
+ };
52
+ }
53
+ function planWithTwoTargets() {
54
+ const a = makeOp('a');
55
+ const b = makeOp('b');
56
+ return { operations: [a, b], records: [], reports: [] };
57
+ }
58
+ function planWithThreeTargets() {
59
+ const a = makeOp('a');
60
+ const b = makeOp('b');
61
+ const c = makeOp('c');
62
+ return { operations: [a, b, c], records: [], reports: [] };
63
+ }
64
+ /**
65
+ * Builds a plan whose second operation ("b") is guaranteed to be replaced
66
+ * (real filesystem write) before verification runs, letting the test force a
67
+ * verification failure via a `verify` override while still exercising the
68
+ * REAL default backup/stage/replace/rollback pipeline underneath.
69
+ */
70
+ function planThatFailsSecondVerification(original) {
71
+ const sourceA = path_1.default.join(tmpWork, 'source-a');
72
+ fs_1.default.mkdirSync(sourceA, { recursive: true });
73
+ fs_1.default.writeFileSync(path_1.default.join(sourceA, 'file.txt'), 'new-a');
74
+ const sourceB = path_1.default.join(tmpWork, 'source-b');
75
+ fs_1.default.mkdirSync(sourceB, { recursive: true });
76
+ fs_1.default.writeFileSync(path_1.default.join(sourceB, 'file.txt'), 'new-b');
77
+ const opA = makeOp('a', { targetPath: original, sourcePath: sourceA });
78
+ const opB = makeOp('b', { targetPath: path_1.default.join(tmpWork, 'target-b'), sourcePath: sourceB });
79
+ return { operations: [opA, opB], records: [], reports: [] };
80
+ }
81
+ describe('applyInstallPlan', () => {
82
+ it('validates and backs up every target before the first replacement', () => {
83
+ const calls = [];
84
+ (0, install_transaction_1.applyInstallPlan)(planWithTwoTargets(), {
85
+ validate: (op) => calls.push(`validate:${op.name}`),
86
+ backup: (op) => { calls.push(`backup:${op.name}`); return null; },
87
+ stage: (op) => { calls.push(`stage:${op.name}`); return `/staged/${op.name}`; },
88
+ replace: (op) => calls.push(`replace:${op.name}`),
89
+ verify: (op) => calls.push(`verify:${op.name}`),
90
+ rollback: (op) => calls.push(`rollback:${op.name}`),
91
+ });
92
+ expect(calls.indexOf('validate:a')).toBeLessThan(calls.indexOf('backup:a'));
93
+ expect(calls.indexOf('backup:b')).toBeLessThan(calls.indexOf('replace:a')); // verifies R24.1
94
+ });
95
+ it('restores already replaced targets when verification fails', () => {
96
+ const original = path_1.default.join(tmpWork, 'target-a');
97
+ fs_1.default.mkdirSync(original, { recursive: true });
98
+ fs_1.default.writeFileSync(path_1.default.join(original, 'sentinel'), 'before');
99
+ const plan = planThatFailsSecondVerification(original);
100
+ const deps = {
101
+ ...(0, install_transaction_1.defaultTransactionDeps)(),
102
+ verify: (op) => {
103
+ if (op.name === 'b')
104
+ throw new Error('verification failed: forced for test');
105
+ },
106
+ };
107
+ expect(() => (0, install_transaction_1.applyInstallPlan)(plan, deps)).toThrow('verification failed');
108
+ expect(fs_1.default.readFileSync(path_1.default.join(original, 'sentinel'), 'utf8')).toBe('before'); // verifies R25
109
+ });
110
+ it('returns a transactionId and the list of modified files on success', () => {
111
+ const sourceA = path_1.default.join(tmpWork, 'source-a');
112
+ fs_1.default.mkdirSync(sourceA, { recursive: true });
113
+ fs_1.default.writeFileSync(path_1.default.join(sourceA, 'file.txt'), 'content-a');
114
+ const targetA = path_1.default.join(tmpWork, 'target-a');
115
+ const plan = {
116
+ operations: [makeOp('a', { targetPath: targetA, sourcePath: sourceA })],
117
+ records: [],
118
+ reports: [{ owner: 'claude-code', targetPath: targetA, action: 'install' }],
119
+ };
120
+ const summary = (0, install_transaction_1.applyInstallPlan)(plan);
121
+ expect(summary.transactionId).toBeTruthy();
122
+ expect(summary.modifiedFiles).toEqual([targetA]);
123
+ expect(fs_1.default.existsSync(path_1.default.join(targetA, 'file.txt'))).toBe(true);
124
+ const backupDir = path_1.default.join(process.env.AWM_HOME, 'backups', summary.transactionId);
125
+ expect(fs_1.default.existsSync(path_1.default.join(backupDir, 'manifest.json'))).toBe(true);
126
+ });
127
+ it('is a safe no-op for an empty plan', () => {
128
+ const summary = (0, install_transaction_1.applyInstallPlan)({ operations: [], records: [], reports: [] });
129
+ expect(summary.installed).toEqual([]);
130
+ expect(summary.modifiedFiles).toEqual([]);
131
+ expect(summary.transactionId).toBeTruthy();
132
+ });
133
+ it('keeps rolling back remaining targets, and still surfaces the original error, when a MIDDLE rollback fails', () => {
134
+ // 3 ops (a, b, c) all replaced; verification fails on 'c', so rollback
135
+ // runs in reverse order: c, b, a. Making 'b' — the middle op in that
136
+ // reversed order, with an op both before and after it in the loop —
137
+ // the one that throws is what actually proves the per-op try/catch
138
+ // isolates failures: 'a' still runs AFTER 'b' throws, not just
139
+ // trivially "nothing left to run" as a last-in-order failure would.
140
+ const calls = [];
141
+ const plan = planWithThreeTargets();
142
+ expect(() => (0, install_transaction_1.applyInstallPlan)(plan, {
143
+ validate: () => { },
144
+ backup: () => null,
145
+ stage: (op) => `/staged/${op.name}`,
146
+ replace: (op) => calls.push(`replace:${op.name}`),
147
+ verify: (op) => { if (op.name === 'c')
148
+ throw new Error('verification failed: forced'); },
149
+ rollback: (op) => {
150
+ calls.push(`rollback:${op.name}`);
151
+ if (op.name === 'b')
152
+ throw new Error('rollback failed for b (simulated)');
153
+ },
154
+ })).toThrow('verification failed: forced');
155
+ // All three replaced ops got a rollback attempt, in reverse order —
156
+ // including 'a', which runs strictly after 'b' throws, proving the
157
+ // loop doesn't stop or skip ahead when a non-terminal rollback fails.
158
+ expect(calls).toEqual(['replace:a', 'replace:b', 'replace:c', 'rollback:c', 'rollback:b', 'rollback:a']);
159
+ });
160
+ describe('artifact-state persistence across multiple calls (BLOCKER regression)', () => {
161
+ // A real `awm init` run makes several separate applyInstallPlan calls
162
+ // in sequence — one per bundle (dev-core, each ambient bundle, each
163
+ // synced project extension). Each call's plan.records only covers the
164
+ // artifacts THAT call touched. Persisting must be additive: an
165
+ // earlier call's ownership records must survive a later call, not be
166
+ // silently discarded by a wholesale overwrite of state/artifacts.json.
167
+ function sourceFor(name) {
168
+ const src = path_1.default.join(tmpWork, `${name}-source`);
169
+ fs_1.default.mkdirSync(src, { recursive: true });
170
+ fs_1.default.writeFileSync(path_1.default.join(src, 'file.txt'), name);
171
+ return src;
172
+ }
173
+ function planFor(name, targetPath) {
174
+ const op = makeOp(name, { targetPath, sourcePath: sourceFor(name) });
175
+ return { operations: [op], records: [op], reports: [{ owner: 'claude-code', targetPath, action: 'install' }] };
176
+ }
177
+ it('preserves an earlier applyInstallPlan call’s records after a later call for a DIFFERENT target', () => {
178
+ const targetA = path_1.default.join(tmpWork, 'target-a');
179
+ const targetB = path_1.default.join(tmpWork, 'target-b');
180
+ (0, install_transaction_1.applyInstallPlan)(planFor('a', targetA));
181
+ (0, install_transaction_1.applyInstallPlan)(planFor('b', targetB));
182
+ const state = (0, artifact_state_1.readArtifactState)();
183
+ expect(state.map((r) => r.targetPath).sort()).toEqual([targetA, targetB].sort());
184
+ });
185
+ it('replaces (does not duplicate) the record for a targetPath touched by two separate applyInstallPlan calls', () => {
186
+ const targetA = path_1.default.join(tmpWork, 'target-a');
187
+ (0, install_transaction_1.applyInstallPlan)(planFor('a', targetA));
188
+ const secondOp = makeOp('a', { targetPath: targetA, sourcePath: sourceFor('a2'), owners: ['claude-code', 'codex'] });
189
+ (0, install_transaction_1.applyInstallPlan)({
190
+ operations: [secondOp],
191
+ records: [secondOp],
192
+ reports: [{ owner: 'codex', targetPath: targetA, action: 'retain' }],
193
+ });
194
+ const state = (0, artifact_state_1.readArtifactState)();
195
+ expect(state).toHaveLength(1);
196
+ expect(state[0].targetPath).toBe(targetA);
197
+ expect(state[0].owners).toEqual(['claude-code', 'codex']);
198
+ });
199
+ });
200
+ });
201
+ describe('beginBackupSession / restoreBackup', () => {
202
+ it('backs up existing targets before mutation and restores them on rollback', () => {
203
+ const fileA = path_1.default.join(tmpWork, 'a.json');
204
+ const fileB = path_1.default.join(tmpWork, 'b.json');
205
+ fs_1.default.writeFileSync(fileA, 'A-before');
206
+ fs_1.default.writeFileSync(fileB, 'B-before');
207
+ const session = (0, install_transaction_1.beginBackupSession)([fileA, fileB]);
208
+ fs_1.default.writeFileSync(fileA, 'A-after');
209
+ fs_1.default.writeFileSync(fileB, 'B-after');
210
+ session.rollback();
211
+ expect(fs_1.default.readFileSync(fileA, 'utf8')).toBe('A-before');
212
+ expect(fs_1.default.readFileSync(fileB, 'utf8')).toBe('B-before');
213
+ });
214
+ it('removes a target that did not exist before the session, on rollback', () => {
215
+ const newFile = path_1.default.join(tmpWork, 'created.json');
216
+ const session = (0, install_transaction_1.beginBackupSession)([newFile]);
217
+ fs_1.default.writeFileSync(newFile, 'created content');
218
+ session.rollback();
219
+ expect(fs_1.default.existsSync(newFile)).toBe(false);
220
+ });
221
+ it('commit marks the manifest committed without restoring anything', () => {
222
+ const fileA = path_1.default.join(tmpWork, 'c.json');
223
+ fs_1.default.writeFileSync(fileA, 'C-before');
224
+ const session = (0, install_transaction_1.beginBackupSession)([fileA]);
225
+ fs_1.default.writeFileSync(fileA, 'C-after');
226
+ session.commit();
227
+ expect(fs_1.default.readFileSync(fileA, 'utf8')).toBe('C-after');
228
+ const manifestPath = path_1.default.join(process.env.AWM_HOME, 'backups', session.transactionId, 'manifest.json');
229
+ const manifest = JSON.parse(fs_1.default.readFileSync(manifestPath, 'utf8'));
230
+ expect(manifest.committed).toBe(true);
231
+ });
232
+ it('refuses to back up a filesystem root', () => {
233
+ expect(() => (0, install_transaction_1.beginBackupSession)(['/'])).toThrow('refusing to back up a filesystem root');
234
+ });
235
+ it('creates the backup directory and manifest with restrictive permissions, no content leaked', () => {
236
+ const fileA = path_1.default.join(tmpWork, 'perm.json');
237
+ fs_1.default.writeFileSync(fileA, 'secret-content');
238
+ const session = (0, install_transaction_1.beginBackupSession)([fileA]);
239
+ const backupDir = path_1.default.join(process.env.AWM_HOME, 'backups', session.transactionId);
240
+ const dirMode = fs_1.default.statSync(backupDir).mode & 0o777;
241
+ const manifestPath = path_1.default.join(backupDir, 'manifest.json');
242
+ const manifestMode = fs_1.default.statSync(manifestPath).mode & 0o777;
243
+ expect(dirMode).toBe(0o700);
244
+ expect(manifestMode).toBe(0o600);
245
+ const manifestRaw = fs_1.default.readFileSync(manifestPath, 'utf8');
246
+ expect(manifestRaw).not.toContain('secret-content');
247
+ });
248
+ it('restoreBackup restores exactly the manifest targets', () => {
249
+ const fileA = path_1.default.join(tmpWork, 'd.json');
250
+ fs_1.default.writeFileSync(fileA, 'D-before');
251
+ const session = (0, install_transaction_1.beginBackupSession)([fileA]);
252
+ fs_1.default.writeFileSync(fileA, 'D-after');
253
+ const result = (0, install_transaction_1.restoreBackup)(session.transactionId);
254
+ expect(result.restored).toEqual([fileA]);
255
+ expect(fs_1.default.readFileSync(fileA, 'utf8')).toBe('D-before');
256
+ });
257
+ });
@@ -0,0 +1,64 @@
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 provider_artifacts_1 = require("../../src/core/provider-artifacts");
10
+ describe('preflightLinkArtifactPairs', () => {
11
+ it('rejects a mixed Codex selection before any installer call', () => {
12
+ const install = jest.fn();
13
+ expect(() => {
14
+ const pairs = [
15
+ { agent: 'codex', artifact: { type: 'skill', name: 'linked-skill' } },
16
+ { agent: 'codex', artifact: { type: 'agent', name: 'toml-agent' } },
17
+ ];
18
+ (0, provider_artifacts_1.preflightLinkArtifactPairs)(pairs);
19
+ for (const item of pairs)
20
+ install(item);
21
+ }).toThrow("Renderer 'codex-agent-toml' for codex agent artifacts is not implemented yet");
22
+ expect(install).not.toHaveBeenCalled();
23
+ });
24
+ });
25
+ describe('scanLegacyArtifacts', () => {
26
+ const originalHome = process.env.HOME;
27
+ const originalAwmHome = process.env.AWM_HOME;
28
+ let tmpHome;
29
+ beforeEach(() => {
30
+ tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-provider-artifacts-'));
31
+ process.env.HOME = tmpHome;
32
+ process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
33
+ });
34
+ afterEach(() => {
35
+ if (originalHome === undefined)
36
+ delete process.env.HOME;
37
+ else
38
+ process.env.HOME = originalHome;
39
+ if (originalAwmHome === undefined)
40
+ delete process.env.AWM_HOME;
41
+ else
42
+ process.env.AWM_HOME = originalAwmHome;
43
+ fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
44
+ });
45
+ it('never lists Codex TOML agents for legacy removal', () => {
46
+ const skillPath = path_1.default.join(tmpHome, '.agents/skills/linked-skill');
47
+ const agentPath = path_1.default.join(tmpHome, '.codex/agents/protected.toml');
48
+ fs_1.default.mkdirSync(skillPath, { recursive: true });
49
+ fs_1.default.mkdirSync(path_1.default.dirname(agentPath), { recursive: true });
50
+ fs_1.default.writeFileSync(agentPath, 'name = "protected"\n');
51
+ const listed = (0, provider_artifacts_1.scanLegacyArtifacts)(['codex'], 'global');
52
+ const legacyRemove = jest.fn((target) => fs_1.default.rmSync(target, {
53
+ recursive: true,
54
+ force: true,
55
+ }));
56
+ for (const artifact of listed) {
57
+ for (const fullPath of artifact.fullPaths)
58
+ legacyRemove(fullPath);
59
+ }
60
+ expect(listed.map((artifact) => artifact.name)).toEqual(['linked-skill']);
61
+ expect(legacyRemove).not.toHaveBeenCalledWith(agentPath);
62
+ expect(fs_1.default.existsSync(agentPath)).toBe(true);
63
+ });
64
+ });
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const provider_version_1 = require("../../src/core/provider-version");
4
+ describe('assertProviderSupported', () => {
5
+ it('accepts the current stable Codex line', () => {
6
+ const exec = jest.fn(() => Buffer.from('codex-cli 0.145.0\n'));
7
+ expect((0, provider_version_1.assertProviderSupported)('codex', exec)).toEqual({
8
+ provider: 'codex',
9
+ version: '0.145.0',
10
+ });
11
+ expect(exec).toHaveBeenCalledWith('codex', ['--version'], expect.any(Object));
12
+ });
13
+ it.each(['0.145.1', '0.146.0', '1.0.0'])('accepts stable Codex version %s above the minimum', (version) => {
14
+ expect((0, provider_version_1.assertProviderSupported)('codex', () => Buffer.from(`codex-cli ${version}\n`))).toEqual({ provider: 'codex', version });
15
+ });
16
+ it.each([
17
+ [Buffer.from('codex-cli 0.144.9\n'), 'requires Codex >= 0.145.0'],
18
+ [Buffer.from('unknown\n'), 'could not parse Codex version'],
19
+ [Buffer.from('codex-cli 0.145.0-beta.1\n'), 'could not parse Codex version'],
20
+ [Buffer.from('codex-cli v0.145.0\n'), 'could not parse Codex version'],
21
+ [Buffer.from('codex-cli 0.145\n'), 'could not parse Codex version'],
22
+ [Buffer.from('unexpected-tool 9.9.9\n'), 'could not parse Codex version'],
23
+ [Buffer.from('notice: codex-cli 0.145.0\n'), 'could not parse Codex version'],
24
+ [Buffer.from('codex-cli stable 0.145.0\n'), 'could not parse Codex version'],
25
+ [Buffer.from('codex-cli 0.145.0 trailing\n'), 'could not parse Codex version'],
26
+ ])('rejects unsupported output without mutation', (output, message) => {
27
+ expect(() => (0, provider_version_1.assertProviderSupported)('codex', () => output)).toThrow(message);
28
+ });
29
+ it('returns a null version for providers without a version gate', () => {
30
+ const exec = jest.fn();
31
+ expect((0, provider_version_1.assertProviderSupported)('claude-code', exec)).toEqual({
32
+ provider: 'claude-code',
33
+ version: null,
34
+ });
35
+ expect(exec).not.toHaveBeenCalled();
36
+ });
37
+ it('reports a missing Codex binary distinctly', () => {
38
+ const missing = Object.assign(new Error('spawnSync codex ENOENT'), { code: 'ENOENT' });
39
+ expect(() => (0, provider_version_1.assertProviderSupported)('codex', () => { throw missing; }))
40
+ .toThrow('Codex is not installed or not available on PATH');
41
+ });
42
+ it('reports other Codex probe failures distinctly', () => {
43
+ expect(() => (0, provider_version_1.assertProviderSupported)('codex', () => {
44
+ throw new Error('permission denied');
45
+ })).toThrow('Codex version probe failed: permission denied');
46
+ });
47
+ it('fails loudly for invalid runtime provider input', () => {
48
+ const exec = jest.fn();
49
+ expect(() => (0, provider_version_1.assertProviderSupported)('invalid', exec))
50
+ .toThrow('Unknown agent target');
51
+ expect(exec).not.toHaveBeenCalled();
52
+ });
53
+ });
@@ -0,0 +1,165 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ // tests/core/reconciliation.test.ts
7
+ //
8
+ // Exercises the REAL planReconciliation (no mocks) end-to-end: seeds a real
9
+ // registry content root with a baseline bundle, an ambient bundle, and a
10
+ // project-scope bundle, then verifies the computed plan only reconciles the
11
+ // machine-scope (baseline + ambient) artifacts — and, applied via the real
12
+ // applyInstallPlan, actually fixes both a MISSING target and a STALE target.
13
+ const fs_1 = __importDefault(require("fs"));
14
+ const os_1 = __importDefault(require("os"));
15
+ const path_1 = __importDefault(require("path"));
16
+ function writeBundle(contentRoot, name, scope, skills, dependsOn = []) {
17
+ fs_1.default.mkdirSync(path_1.default.join(contentRoot, 'bundles', name), { recursive: true });
18
+ fs_1.default.writeFileSync(path_1.default.join(contentRoot, 'bundles', name, 'bundle.json'), JSON.stringify({
19
+ name, version: '1.0.0', scope, dependsOn,
20
+ skills: skills.map((s) => ({ name: s })), workflows: [], agents: [],
21
+ }));
22
+ for (const skill of skills) {
23
+ fs_1.default.mkdirSync(path_1.default.join(contentRoot, 'skills', skill), { recursive: true });
24
+ fs_1.default.writeFileSync(path_1.default.join(contentRoot, 'skills', skill, 'SKILL.md'), `# ${skill}`);
25
+ }
26
+ }
27
+ function addToCatalog(contentRoot, name, scope) {
28
+ const catalogFile = path_1.default.join(contentRoot, 'catalog.json');
29
+ const catalog = JSON.parse(fs_1.default.readFileSync(catalogFile, 'utf-8'));
30
+ catalog.bundles.push({ name, source: `./bundles/${name}`, version: '1.0.0', scope });
31
+ fs_1.default.writeFileSync(catalogFile, JSON.stringify(catalog));
32
+ }
33
+ describe('planReconciliation (real, unmocked)', () => {
34
+ let tmpHome;
35
+ let contentRoot;
36
+ let originalHome;
37
+ let originalAwmHome;
38
+ beforeEach(() => {
39
+ tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-reconcile-'));
40
+ originalHome = process.env.HOME;
41
+ originalAwmHome = process.env.AWM_HOME;
42
+ process.env.HOME = tmpHome;
43
+ process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
44
+ jest.resetModules();
45
+ contentRoot = path_1.default.join(tmpHome, 'registry');
46
+ fs_1.default.mkdirSync(contentRoot, { recursive: true });
47
+ fs_1.default.writeFileSync(path_1.default.join(contentRoot, 'catalog.json'), JSON.stringify({
48
+ version: 1,
49
+ bundles: [
50
+ { name: 'dev', source: './bundles/dev', version: '1.0.0', scope: 'baseline' },
51
+ { name: 'ambient-x', source: './bundles/ambient-x', version: '1.0.0', scope: 'ambient' },
52
+ { name: 'proj-y', source: './bundles/proj-y', version: '1.0.0', scope: 'project' },
53
+ ],
54
+ }));
55
+ writeBundle(contentRoot, 'dev', 'baseline', ['brainstorming']);
56
+ writeBundle(contentRoot, 'ambient-x', 'ambient', ['ambient-skill']);
57
+ writeBundle(contentRoot, 'proj-y', 'project', ['proj-skill']);
58
+ });
59
+ afterEach(() => {
60
+ fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
61
+ if (originalHome === undefined)
62
+ delete process.env.HOME;
63
+ else
64
+ process.env.HOME = originalHome;
65
+ if (originalAwmHome === undefined)
66
+ delete process.env.AWM_HOME;
67
+ else
68
+ process.env.AWM_HOME = originalAwmHome;
69
+ });
70
+ it('plans only the machine-scope (baseline + ambient) artifacts, excluding project-scope bundles', () => {
71
+ const { planReconciliation } = require('../../src/core/reconciliation');
72
+ const plan = planReconciliation({ targets: ['claude-code'], roots: [contentRoot] });
73
+ const names = plan.operations.map((op) => op.name).sort();
74
+ expect(names).toEqual(['ambient-skill', 'brainstorming']); // proj-skill excluded (R — project bundles are `awm sync`'s job, not `awm update`'s)
75
+ const claudeSkillsDir = path_1.default.join(tmpHome, '.claude', 'skills');
76
+ const targets = plan.operations.map((op) => op.targetPath).sort();
77
+ expect(targets).toEqual([
78
+ path_1.default.join(claudeSkillsDir, 'ambient-skill'),
79
+ path_1.default.join(claudeSkillsDir, 'brainstorming'),
80
+ ]);
81
+ });
82
+ it('reconciles drift end-to-end when applied: installs a MISSING target and replaces a STALE one', () => {
83
+ const claudeSkillsDir = path_1.default.join(tmpHome, '.claude', 'skills');
84
+ fs_1.default.mkdirSync(claudeSkillsDir, { recursive: true });
85
+ // Drift scenario 1 — stale: 'brainstorming' exists but as a plain file
86
+ // with the wrong content (as if it were copied once and the registry
87
+ // has since diverged), not a symlink to the registry source.
88
+ fs_1.default.writeFileSync(path_1.default.join(claudeSkillsDir, 'brainstorming'), 'stale, wrong content');
89
+ // Drift scenario 2 — missing: 'ambient-skill' was never installed at all.
90
+ expect(fs_1.default.existsSync(path_1.default.join(claudeSkillsDir, 'ambient-skill'))).toBe(false);
91
+ const { planReconciliation } = require('../../src/core/reconciliation');
92
+ const { applyInstallPlan } = require('../../src/core/install-transaction');
93
+ const plan = planReconciliation({ targets: ['claude-code'], roots: [contentRoot] });
94
+ const summary = applyInstallPlan(plan);
95
+ expect(summary.installed.sort()).toEqual([
96
+ 'ambient-skill → claude-code',
97
+ 'brainstorming → claude-code',
98
+ ]);
99
+ // Stale target is now a real symlink to the registry source, not the old file.
100
+ const brainstormingStat = fs_1.default.lstatSync(path_1.default.join(claudeSkillsDir, 'brainstorming'));
101
+ expect(brainstormingStat.isSymbolicLink()).toBe(true);
102
+ expect(fs_1.default.realpathSync(path_1.default.join(claudeSkillsDir, 'brainstorming')))
103
+ .toBe(fs_1.default.realpathSync(path_1.default.join(contentRoot, 'skills', 'brainstorming')));
104
+ // Missing target now exists as a symlink too.
105
+ const ambientStat = fs_1.default.lstatSync(path_1.default.join(claudeSkillsDir, 'ambient-skill'));
106
+ expect(ambientStat.isSymbolicLink()).toBe(true);
107
+ expect(fs_1.default.realpathSync(path_1.default.join(claudeSkillsDir, 'ambient-skill')))
108
+ .toBe(fs_1.default.realpathSync(path_1.default.join(contentRoot, 'skills', 'ambient-skill')));
109
+ });
110
+ it('scopes to the selected agent target only', () => {
111
+ const { planReconciliation } = require('../../src/core/reconciliation');
112
+ const plan = planReconciliation({ targets: ['opencode'], roots: [contentRoot] });
113
+ const owners = new Set(plan.operations.flatMap((op) => op.owners));
114
+ expect(owners).toEqual(new Set(['opencode']));
115
+ });
116
+ // NOTE on the two tests below: `resolveBundleClosure` (bundles.ts) already
117
+ // treats a `dependsOn` entry naming a bundle that isn't in the catalog as
118
+ // silently absent from the closure — it does NOT throw (verified directly:
119
+ // a baseline bundle with `dependsOn: ['does-not-exist']` resolves its own
120
+ // artifacts fine; the ghost name is just dropped). So a plain dangling
121
+ // `dependsOn` string never reaches `planReconciliation`'s
122
+ // `try { expandBundleArtifacts(...) } catch { continue; }` guard at all —
123
+ // the first test below confirms that directly. To actually force the catch
124
+ // branch (and check it skips only the one broken bundle, not the whole
125
+ // batch, and doesn't silently eat some other bug), the second test injects
126
+ // a bundle whose `dependsOn` field is malformed in a way that genuinely
127
+ // breaks closure resolution (`dependsOn` is not an array, so
128
+ // `resolveBundleClosure`'s `for (const dep of b.dependsOn)` throws) —
129
+ // mirroring the kind of real, unresolvable-closure error the catch is
130
+ // meant to guard against.
131
+ it('tolerates a plain dangling dependsOn reference without needing the catch — the bundle installs normally', () => {
132
+ writeBundle(contentRoot, 'dangling-dep', 'baseline', ['dangling-skill'], ['does-not-exist']);
133
+ addToCatalog(contentRoot, 'dangling-dep', 'baseline');
134
+ const { planReconciliation } = require('../../src/core/reconciliation');
135
+ const plan = planReconciliation({ targets: ['claude-code'], roots: [contentRoot] });
136
+ const names = plan.operations.map((op) => op.name).sort();
137
+ // 'dangling-skill' installs fine even though its bundle's dependsOn
138
+ // points nowhere — resolveBundleClosure just drops the missing dep.
139
+ expect(names).toEqual(['ambient-skill', 'brainstorming', 'dangling-skill']);
140
+ });
141
+ it('skips a bundle whose closure genuinely fails to resolve, without aborting reconciliation for the rest', () => {
142
+ // Malformed dependsOn (not an array) makes resolveBundleClosure throw
143
+ // when it tries to iterate it — a real "unresolvable closure" error,
144
+ // unlike a plain dangling name reference (see test above).
145
+ writeBundle(contentRoot, 'broken', 'baseline', ['broken-skill'], 5);
146
+ addToCatalog(contentRoot, 'broken', 'baseline');
147
+ const { planReconciliation } = require('../../src/core/reconciliation');
148
+ let plan;
149
+ expect(() => {
150
+ plan = planReconciliation({ targets: ['claude-code'], roots: [contentRoot] });
151
+ }).not.toThrow();
152
+ // The healthy bundles (baseline 'dev' + ambient 'ambient-x') still
153
+ // reconcile correctly.
154
+ const names = plan.operations.map((op) => op.name).sort();
155
+ expect(names).toEqual(['ambient-skill', 'brainstorming']);
156
+ // The broken bundle is cleanly absent — no partial/broken entry for
157
+ // 'broken-skill' or any operation touching it.
158
+ expect(names).not.toContain('broken-skill');
159
+ expect(plan.operations.some((op) => op.sourcePath?.includes('broken'))).toBe(false);
160
+ // Applying the plan end-to-end still works (no dangling reference to
161
+ // the broken bundle leaked into the plan).
162
+ const { applyInstallPlan } = require('../../src/core/install-transaction');
163
+ expect(() => applyInstallPlan(plan)).not.toThrow();
164
+ });
165
+ });
@@ -93,7 +93,7 @@ describe('syncRegistries (git fixtures locales)', () => {
93
93
  m.writeRegistriesConfig([{ name: 'personal', remote: source }]);
94
94
  const awmDir = path_1.default.join(tmpHome, '.awm');
95
95
  fs_1.default.mkdirSync(awmDir, { recursive: true });
96
- fs_1.default.writeFileSync(path_1.default.join(awmDir, 'preferences.json'), JSON.stringify({ defaultAgent: 'claude', installMethod: 'symlink', defaultScope: 'local', pins: { personal: '9.9.9' } }));
96
+ fs_1.default.writeFileSync(path_1.default.join(awmDir, 'preferences.json'), JSON.stringify({ defaultAgent: 'claude-code', installMethod: 'symlink', defaultScope: 'local', pins: { personal: '9.9.9' } }));
97
97
  const results = await m.syncRegistries();
98
98
  expect(results[0].action).toBe('error');
99
99
  // El dir no debe quedar en disco tras el fallo
@@ -110,7 +110,7 @@ describe('syncRegistries (git fixtures locales)', () => {
110
110
  m.writeRegistriesConfig([{ name: 'personal', remote: source }]);
111
111
  const awmDir = path_1.default.join(tmpHome, '.awm');
112
112
  fs_1.default.mkdirSync(awmDir, { recursive: true });
113
- fs_1.default.writeFileSync(path_1.default.join(awmDir, 'preferences.json'), JSON.stringify({ defaultAgent: 'claude', installMethod: 'symlink', defaultScope: 'local', pins: { personal: '1.0.0' } }));
113
+ fs_1.default.writeFileSync(path_1.default.join(awmDir, 'preferences.json'), JSON.stringify({ defaultAgent: 'claude-code', installMethod: 'symlink', defaultScope: 'local', pins: { personal: '1.0.0' } }));
114
114
  const results = await m.syncRegistries();
115
115
  expect(results).toEqual([{ name: 'personal', action: 'recloned', version: 'v1.0.0' }]);
116
116
  });
@@ -174,7 +174,7 @@ describe('syncRegistries (git fixtures locales)', () => {
174
174
  expect(r1).toEqual([{ name: 'personal', action: 'recloned', version: 'v1.1.0' }]);
175
175
  // establece pin a la versión anterior
176
176
  const awmDir = path_1.default.join(tmpHome, '.awm');
177
- fs_1.default.writeFileSync(path_1.default.join(awmDir, 'preferences.json'), JSON.stringify({ defaultAgent: 'claude', installMethod: 'symlink', defaultScope: 'local', pins: { personal: '1.0.0' } }));
177
+ fs_1.default.writeFileSync(path_1.default.join(awmDir, 'preferences.json'), JSON.stringify({ defaultAgent: 'claude-code', installMethod: 'symlink', defaultScope: 'local', pins: { personal: '1.0.0' } }));
178
178
  // segunda sync con pin: retrocede a v1.0.0
179
179
  const r2 = await m.syncRegistries();
180
180
  expect(r2).toEqual([{ name: 'personal', action: 'pulled', version: 'v1.0.0' }]);
@@ -188,7 +188,7 @@ describe('syncRegistries (git fixtures locales)', () => {
188
188
  m.writeRegistriesConfig([{ name: 'personal', remote: source }]);
189
189
  const awmDir = path_1.default.join(tmpHome, '.awm');
190
190
  fs_1.default.mkdirSync(awmDir, { recursive: true });
191
- fs_1.default.writeFileSync(path_1.default.join(awmDir, 'preferences.json'), JSON.stringify({ defaultAgent: 'claude', installMethod: 'symlink', defaultScope: 'local', channel: 'dev' }));
191
+ fs_1.default.writeFileSync(path_1.default.join(awmDir, 'preferences.json'), JSON.stringify({ defaultAgent: 'claude-code', installMethod: 'symlink', defaultScope: 'local', channel: 'dev' }));
192
192
  // primera sync en canal dev: queda en HEAD (no en el tag)
193
193
  const r1 = await m.syncRegistries();
194
194
  expect(r1).toEqual([{ name: 'personal', action: 'recloned', version: 'HEAD' }]);
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const canonical_agent_1 = require("../../../src/core/renderers/canonical-agent");
4
+ // Real canonical agent content from the awm-baseline-registry (registries/baseline/agents/development-process.md),
5
+ // which the installer materializes at ~/.awm/registries/baseline/agents/development-process.md.
6
+ const canonicalWithModePrimary = `---
7
+ name: development-process
8
+ description: Use as agent profile to orchestrate the development lifecycle - invokes the development-process skill which contains the full orchestration logic
9
+ mode: primary
10
+ ---
11
+
12
+ # Development Process Orchestrator
13
+
14
+ You are a development orchestrator. You do NOT write code directly.
15
+
16
+ ## On Every Conversation Start
17
+
18
+ 1. **Invoke the \`development-process\` skill.** This skill contains the complete orchestration logic: state detection, lifecycle phases, decision rules, and the full catalog of available skills.
19
+ 2. Follow the skill's instructions exactly - it will guide you through identifying project state, recommending the next phase, and delegating to the correct skill.
20
+
21
+ ## Rules
22
+
23
+ - NEVER start writing code without first invoking \`development-process\`
24
+ - NEVER duplicate orchestration logic here - the skill is the single source of truth
25
+ - NEVER invoke a downstream skill without user approval
26
+ `;
27
+ it('parses name, description, and instructions from valid frontmatter', () => {
28
+ const source = `---
29
+ name: my-agent
30
+ description: Does a thing
31
+ ---
32
+
33
+ Body text here.
34
+ `;
35
+ expect((0, canonical_agent_1.parseCanonicalAgent)(source)).toEqual({
36
+ name: 'my-agent',
37
+ description: 'Does a thing',
38
+ instructions: 'Body text here.',
39
+ });
40
+ });
41
+ it('ignores provider-only mode while retaining canonical instructions', () => {
42
+ const parsed = (0, canonical_agent_1.parseCanonicalAgent)(canonicalWithModePrimary);
43
+ expect(parsed).toEqual({
44
+ name: 'development-process',
45
+ description: 'Use as agent profile to orchestrate the development lifecycle - invokes the development-process skill which contains the full orchestration logic',
46
+ instructions: expect.stringContaining('You do NOT write code directly.'),
47
+ }); // verifies R8, R9
48
+ });
49
+ it.each([
50
+ ['---\nname: Bad Name\ndescription: x\n---\nbody', 'invalid agent name'],
51
+ ['---\nname: ok\ndescription:\n---\nbody', 'non-empty description'],
52
+ ['---\nname: ok\ndescription: x\n---\n', 'non-empty instruction body'],
53
+ ['name: ok', 'frontmatter'],
54
+ ['---\nname: ok\nnot a field\n---\nbody', 'invalid canonical agent frontmatter line'],
55
+ ['---\nname: ok\ndescription: first\ndescription: second\n---\nbody', 'duplicate canonical agent frontmatter key: description'],
56
+ ])('rejects invalid canonical agent sources', (source, message) => {
57
+ expect(() => (0, canonical_agent_1.parseCanonicalAgent)(source)).toThrow(message); // verifies R17
58
+ });