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
@@ -60,11 +60,29 @@ describe('renderReport', () => {
60
60
  });
61
61
  describe('runDoctor', () => {
62
62
  let tmpHome;
63
+ let tmpWork;
63
64
  let originalHome;
64
65
  let originalAwmHome;
65
66
  let writeSpy;
67
+ function stdout() {
68
+ return writeSpy.mock.calls.map((c) => c[0]).join('');
69
+ }
70
+ function prefsWith(enabledAgents) {
71
+ return {
72
+ defaultAgent: enabledAgents[0],
73
+ enabledAgents,
74
+ installMethod: 'symlink',
75
+ defaultScope: 'local',
76
+ };
77
+ }
78
+ function writePrefs(prefs) {
79
+ const dir = path_1.default.join(tmpHome, '.awm');
80
+ fs_1.default.mkdirSync(dir, { recursive: true });
81
+ fs_1.default.writeFileSync(path_1.default.join(dir, 'preferences.json'), JSON.stringify(prefs, null, 2) + '\n');
82
+ }
66
83
  beforeEach(() => {
67
84
  tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-doctor-run-'));
85
+ tmpWork = tmpHome;
68
86
  originalHome = process.env.HOME;
69
87
  originalAwmHome = process.env.AWM_HOME;
70
88
  process.env.HOME = tmpHome;
@@ -87,12 +105,55 @@ describe('runDoctor', () => {
87
105
  const code = (0, doctor_1.runDoctor)({ cwd: tmpHome });
88
106
  expect(code).toBe(1);
89
107
  });
90
- it('--json emits a parseable CheckReport and keeps the same exit code', () => {
108
+ it('--json emits a parseable provider report and keeps the same exit code', () => {
91
109
  const code = (0, doctor_1.runDoctor)({ cwd: tmpHome, json: true });
92
- const written = writeSpy.mock.calls.map((c) => c[0]).join('');
93
- const parsed = JSON.parse(written);
110
+ const parsed = JSON.parse(stdout());
94
111
  expect(parsed.overall).toBe('degraded');
95
- expect(Array.isArray(parsed.results)).toBe(true);
112
+ expect(Array.isArray(parsed.providers)).toBe(true);
96
113
  expect(code).toBe(1);
97
114
  });
115
+ it('reports every enabled provider and stable remediation codes in JSON', () => {
116
+ writePrefs(prefsWith(['claude-code', 'opencode', 'codex']));
117
+ const code = (0, doctor_1.runDoctor)({ cwd: tmpWork, json: true });
118
+ const report = JSON.parse(stdout());
119
+ expect(report.providers.map((provider) => provider.id))
120
+ .toEqual(['claude-code', 'opencode', 'codex']); // verifies R12
121
+ expect(report.providers.find((provider) => provider.id === 'codex'))
122
+ .toMatchObject({
123
+ checks: expect.arrayContaining([
124
+ expect.objectContaining({ id: 'binary.version' }),
125
+ expect.objectContaining({ id: 'skills.global' }),
126
+ expect.objectContaining({ id: 'agents.native' }),
127
+ expect.objectContaining({ id: 'hook.trust' }),
128
+ ]),
129
+ }); // verifies R2, R7, R8, R18
130
+ expect(code).toBe(1);
131
+ });
132
+ it('reports a clear, specific error for an invalid --agent value, not a generic "internal error"', () => {
133
+ const errSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
134
+ try {
135
+ const code = (0, doctor_1.runDoctor)({ cwd: tmpHome, agent: 'not-a-real-agent' });
136
+ expect(code).toBe(2);
137
+ const written = errSpy.mock.calls.map((c) => c[0]).join('');
138
+ expect(written).toContain('awm doctor: Invalid agent "not-a-real-agent"');
139
+ expect(written).not.toContain('internal error');
140
+ }
141
+ finally {
142
+ errSpy.mockRestore();
143
+ }
144
+ });
145
+ it('reports a clear error when --agent names a target that is not enabled', () => {
146
+ const errSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
147
+ try {
148
+ // Bare HOME → default preferences enable only claude-code.
149
+ const code = (0, doctor_1.runDoctor)({ cwd: tmpHome, agent: 'codex' });
150
+ expect(code).toBe(2);
151
+ const written = errSpy.mock.calls.map((c) => c[0]).join('');
152
+ expect(written).toContain('awm doctor: codex is not enabled');
153
+ expect(written).not.toContain('internal error');
154
+ }
155
+ finally {
156
+ errSpy.mockRestore();
157
+ }
158
+ });
98
159
  });
@@ -0,0 +1,230 @@
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 path_1 = __importDefault(require("path"));
8
+ const os_1 = __importDefault(require("os"));
9
+ const crypto_1 = __importDefault(require("crypto"));
10
+ describe('installHook / computeHookStatus / uninstallHook — Codex adapter', () => {
11
+ let tmpHome;
12
+ let tmpRegistry;
13
+ let hooksJson;
14
+ let codexScriptsDir;
15
+ let originalHome;
16
+ let originalAwmHome;
17
+ beforeEach(() => {
18
+ tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-codex-'));
19
+ tmpRegistry = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-codex-registry-'));
20
+ hooksJson = path_1.default.join(tmpHome, '.codex/hooks.json');
21
+ codexScriptsDir = path_1.default.join(tmpHome, '.awm/hooks/codex');
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
+ jest.resetModules();
27
+ });
28
+ afterEach(() => {
29
+ fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
30
+ fs_1.default.rmSync(tmpRegistry, { recursive: true, force: true });
31
+ if (originalHome === undefined)
32
+ delete process.env.HOME;
33
+ else
34
+ process.env.HOME = originalHome;
35
+ if (originalAwmHome === undefined)
36
+ delete process.env.AWM_HOME;
37
+ else
38
+ process.env.AWM_HOME = originalAwmHome;
39
+ });
40
+ function writeRegistry(content = '#!/usr/bin/env bash\necho "{}"') {
41
+ const regHooks = path_1.default.join(tmpRegistry, 'hooks');
42
+ fs_1.default.mkdirSync(regHooks, { recursive: true });
43
+ fs_1.default.writeFileSync(path_1.default.join(regHooks, 'codex-session-start'), content, { mode: 0o755 });
44
+ }
45
+ function awmSessionStartEntry() {
46
+ return {
47
+ matcher: 'startup|resume|clear|compact',
48
+ hooks: [{
49
+ type: 'command',
50
+ command: path_1.default.join(codexScriptsDir, 'session-start'),
51
+ statusMessage: 'Loading AWM session state',
52
+ }],
53
+ };
54
+ }
55
+ it('merges one AWM SessionStart group and preserves user hooks', () => {
56
+ fs_1.default.mkdirSync(path_1.default.dirname(hooksJson), { recursive: true });
57
+ fs_1.default.writeFileSync(hooksJson, JSON.stringify({
58
+ description: 'user hooks',
59
+ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'echo stop' }] }] },
60
+ }));
61
+ writeRegistry();
62
+ const { installHook } = require('../../../src/commands/hooks/install');
63
+ installHook({ agent: 'codex', registryRoot: tmpRegistry, installMethod: 'copy' });
64
+ const cfg = JSON.parse(fs_1.default.readFileSync(hooksJson, 'utf8'));
65
+ expect(cfg.description).toBe('user hooks');
66
+ expect(cfg.hooks.Stop).toHaveLength(1);
67
+ expect(cfg.hooks.SessionStart).toEqual([{
68
+ matcher: 'startup|resume|clear|compact',
69
+ hooks: [{
70
+ type: 'command',
71
+ command: path_1.default.join(tmpHome, '.awm/hooks/codex/session-start'),
72
+ statusMessage: 'Loading AWM session state',
73
+ }],
74
+ }]); // verifies R3, R3.1
75
+ expect(fs_1.default.existsSync(path_1.default.join(codexScriptsDir, 'session-start'))).toBe(true);
76
+ });
77
+ it('installs cleanly with no pre-existing hooks.json', () => {
78
+ writeRegistry();
79
+ const { installHook } = require('../../../src/commands/hooks/install');
80
+ const result = installHook({ agent: 'codex', registryRoot: tmpRegistry, installMethod: 'symlink' });
81
+ expect(result.status).toBe('installed');
82
+ expect(result.backupPath).toBeNull();
83
+ const cfg = JSON.parse(fs_1.default.readFileSync(hooksJson, 'utf8'));
84
+ expect(cfg.hooks.SessionStart).toEqual([awmSessionStartEntry()]);
85
+ expect(fs_1.default.lstatSync(path_1.default.join(codexScriptsDir, 'session-start')).isSymbolicLink()).toBe(true);
86
+ });
87
+ it('fails fast when the registry has no codex-session-start source', () => {
88
+ const { installHook } = require('../../../src/commands/hooks/install');
89
+ expect(() => installHook({ agent: 'codex', registryRoot: tmpRegistry, installMethod: 'copy' }))
90
+ .toThrow(/Codex hook source missing/);
91
+ expect(fs_1.default.existsSync(hooksJson)).toBe(false);
92
+ });
93
+ it('refuses duplicate AWM entries without rewriting hooks.json', () => {
94
+ writeRegistry();
95
+ fs_1.default.mkdirSync(path_1.default.dirname(hooksJson), { recursive: true });
96
+ const duplicateAwmHookConfig = {
97
+ hooks: {
98
+ SessionStart: [awmSessionStartEntry(), awmSessionStartEntry()],
99
+ },
100
+ };
101
+ fs_1.default.writeFileSync(hooksJson, JSON.stringify(duplicateAwmHookConfig, null, 2));
102
+ const before = fs_1.default.readFileSync(hooksJson, 'utf8');
103
+ const { installHook } = require('../../../src/commands/hooks/install');
104
+ expect(() => installHook({ agent: 'codex', registryRoot: tmpRegistry, installMethod: 'copy' }))
105
+ .toThrow('multiple AWM SessionStart entries'); // verifies R17
106
+ expect(fs_1.default.readFileSync(hooksJson, 'utf8')).toBe(before);
107
+ expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.awm/backups'))).toBe(false);
108
+ });
109
+ it('replaces a stale AWM entry in place when the script path changes', () => {
110
+ writeRegistry();
111
+ fs_1.default.mkdirSync(path_1.default.dirname(hooksJson), { recursive: true });
112
+ fs_1.default.writeFileSync(hooksJson, JSON.stringify({
113
+ hooks: {
114
+ SessionStart: [{
115
+ matcher: 'startup|resume|clear|compact',
116
+ hooks: [{
117
+ type: 'command',
118
+ command: path_1.default.join(codexScriptsDir, 'session-start'),
119
+ statusMessage: 'stale message',
120
+ }],
121
+ }],
122
+ },
123
+ }, null, 2));
124
+ const { installHook } = require('../../../src/commands/hooks/install');
125
+ installHook({ agent: 'codex', registryRoot: tmpRegistry, installMethod: 'copy' });
126
+ const cfg = JSON.parse(fs_1.default.readFileSync(hooksJson, 'utf8'));
127
+ expect(cfg.hooks.SessionStart).toHaveLength(1);
128
+ expect(cfg.hooks.SessionStart[0].hooks[0].statusMessage).toBe('Loading AWM session state');
129
+ });
130
+ it('is a no-op on a second install when nothing changed (idempotent, no backup/write churn)', () => {
131
+ writeRegistry();
132
+ const { installHook } = require('../../../src/commands/hooks/install');
133
+ const first = installHook({ agent: 'codex', registryRoot: tmpRegistry, installMethod: 'copy' });
134
+ expect(first.status).toBe('installed');
135
+ const scriptPath = path_1.default.join(codexScriptsDir, 'session-start');
136
+ const contentBefore = fs_1.default.readFileSync(hooksJson, 'utf8');
137
+ const hooksJsonMtimeBefore = fs_1.default.statSync(hooksJson).mtimeMs;
138
+ const scriptMtimeBefore = fs_1.default.statSync(scriptPath).mtimeMs;
139
+ const second = installHook({ agent: 'codex', registryRoot: tmpRegistry, installMethod: 'copy' });
140
+ expect(second.status).toBe('already-up-to-date');
141
+ expect(second.backupPath).toBeNull();
142
+ expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.awm/backups'))).toBe(false); // no backup churn
143
+ expect(fs_1.default.readFileSync(hooksJson, 'utf8')).toBe(contentBefore);
144
+ expect(fs_1.default.statSync(hooksJson).mtimeMs).toBe(hooksJsonMtimeBefore);
145
+ expect(fs_1.default.statSync(scriptPath).mtimeMs).toBe(scriptMtimeBefore);
146
+ });
147
+ function hashOf(file) {
148
+ return crypto_1.default.createHash('sha256').update(fs_1.default.readFileSync(file)).digest('hex');
149
+ }
150
+ function installCodexFixture({ heartbeat }) {
151
+ fs_1.default.mkdirSync(codexScriptsDir, { recursive: true });
152
+ const scriptPath = path_1.default.join(codexScriptsDir, 'session-start');
153
+ fs_1.default.writeFileSync(scriptPath, '#!/usr/bin/env bash\necho "hi"', { mode: 0o755 });
154
+ fs_1.default.mkdirSync(path_1.default.dirname(hooksJson), { recursive: true });
155
+ fs_1.default.writeFileSync(hooksJson, JSON.stringify({
156
+ hooks: { SessionStart: [awmSessionStartEntry()] },
157
+ }, null, 2));
158
+ if (heartbeat) {
159
+ fs_1.default.writeFileSync(path_1.default.join(codexScriptsDir, 'heartbeat.json'), JSON.stringify({ hash: hashOf(scriptPath), ts: new Date().toISOString() }));
160
+ }
161
+ }
162
+ it.each([
163
+ [false, 'pending-trust'],
164
+ [true, 'healthy'],
165
+ ])('derives trust from a current heartbeat (heartbeat present=%s)', (heartbeat, expected) => {
166
+ installCodexFixture({ heartbeat: heartbeat });
167
+ const { computeHookStatus } = require('../../../src/commands/hooks/status');
168
+ expect(computeHookStatus('codex').trust).toBe(expected); // verifies R18
169
+ });
170
+ it('reports stale trust when the heartbeat hash no longer matches the installed script', () => {
171
+ installCodexFixture({ heartbeat: true });
172
+ fs_1.default.writeFileSync(path_1.default.join(codexScriptsDir, 'session-start'), '#!/usr/bin/env bash\necho "changed"', { mode: 0o755 });
173
+ const { computeHookStatus } = require('../../../src/commands/hooks/status');
174
+ expect(computeHookStatus('codex').trust).toBe('stale');
175
+ });
176
+ it('degrades to stale (not throwing) when heartbeat.json is malformed JSON', () => {
177
+ installCodexFixture({ heartbeat: false });
178
+ fs_1.default.writeFileSync(path_1.default.join(codexScriptsDir, 'heartbeat.json'), '{ not valid json');
179
+ const { computeHookStatus } = require('../../../src/commands/hooks/status');
180
+ let result;
181
+ expect(() => { result = computeHookStatus('codex'); }).not.toThrow();
182
+ expect(result.trust).toBe('stale');
183
+ });
184
+ it('omits trust entirely when the AWM entry is not present in hooks.json', () => {
185
+ // Script + heartbeat both exist (e.g. left over from a prior install),
186
+ // but hooks.json has no AWM SessionStart entry — nothing to trust.
187
+ fs_1.default.mkdirSync(codexScriptsDir, { recursive: true });
188
+ const scriptPath = path_1.default.join(codexScriptsDir, 'session-start');
189
+ fs_1.default.writeFileSync(scriptPath, '#!/usr/bin/env bash\necho "hi"', { mode: 0o755 });
190
+ fs_1.default.writeFileSync(path_1.default.join(codexScriptsDir, 'heartbeat.json'), JSON.stringify({ hash: hashOf(scriptPath), ts: new Date().toISOString() }));
191
+ const { computeHookStatus } = require('../../../src/commands/hooks/status');
192
+ const result = computeHookStatus('codex');
193
+ expect(result.checks.settingsEntry.ok).toBe(false);
194
+ expect(result.trust).toBeUndefined();
195
+ });
196
+ it('reports HEALTHY overall with no bootstrapSkill/runHookWrapper checks', () => {
197
+ installCodexFixture({ heartbeat: true });
198
+ const { computeHookStatus } = require('../../../src/commands/hooks/status');
199
+ const result = computeHookStatus('codex');
200
+ expect(result.overall).toBe('HEALTHY');
201
+ expect(result.checks.sessionStartScript.ok).toBe(true);
202
+ expect(result.checks.settingsEntry.ok).toBe(true);
203
+ expect(result.checks.bootstrapSkill).toBeUndefined();
204
+ expect(result.checks.runHookWrapper).toBeUndefined();
205
+ });
206
+ it('uninstall removes only the AWM entry and preserves other Codex hooks', () => {
207
+ fs_1.default.mkdirSync(path_1.default.dirname(hooksJson), { recursive: true });
208
+ fs_1.default.writeFileSync(hooksJson, JSON.stringify({
209
+ description: 'user hooks',
210
+ hooks: {
211
+ Stop: [{ hooks: [{ type: 'command', command: 'echo stop' }] }],
212
+ SessionStart: [awmSessionStartEntry()],
213
+ },
214
+ }, null, 2));
215
+ const { uninstallHook } = require('../../../src/commands/hooks/uninstall');
216
+ const result = uninstallHook({ agent: 'codex' });
217
+ expect(result.status).toBe('uninstalled');
218
+ expect(result.backupPath).not.toBeNull();
219
+ const cfg = JSON.parse(fs_1.default.readFileSync(hooksJson, 'utf8'));
220
+ expect(cfg.description).toBe('user hooks');
221
+ expect(cfg.hooks.Stop).toHaveLength(1);
222
+ expect(cfg.hooks.SessionStart).toBeUndefined();
223
+ });
224
+ it('uninstall is a no-op when hooks.json does not exist', () => {
225
+ const { uninstallHook } = require('../../../src/commands/hooks/uninstall');
226
+ const result = uninstallHook({ agent: 'codex' });
227
+ expect(result.status).toBe('not-installed');
228
+ expect(result.backupPath).toBeNull();
229
+ });
230
+ });
@@ -144,6 +144,25 @@ describe('installHook (happy path + merge)', () => {
144
144
  it('throws for unsupported agent target', () => {
145
145
  const { installHook } = require('../../../src/commands/hooks/install');
146
146
  expect(() => installHook({ agent: 'antigravity', registryRoot: tmpRegistry, installMethod: 'symlink' }))
147
- .toThrow(/not supported/);
147
+ .toThrow(/hooks not supported/);
148
+ });
149
+ // Characterization test (Task 7, Step 1): freezes the Claude adapter's
150
+ // observable contract — matcher, ordering, and untouched unrelated
151
+ // settings/hooks keys — before the Claude-specific logic moves into
152
+ // claude.ts. Verifies R19.
153
+ it('keeps the Claude SessionStart matcher and unrelated settings unchanged', () => {
154
+ const claudeDir = path_1.default.join(tmpHome, '.claude');
155
+ fs_1.default.mkdirSync(claudeDir, { recursive: true });
156
+ const settingsPath = path_1.default.join(claudeDir, 'settings.json');
157
+ fs_1.default.writeFileSync(settingsPath, JSON.stringify({
158
+ permissions: { allow: ['Read'] },
159
+ hooks: { SessionEnd: [{ hooks: [{ type: 'command', command: 'echo bye' }] }] },
160
+ }));
161
+ const { installHook } = require('../../../src/commands/hooks/install');
162
+ installHook({ agent: 'claude-code', registryRoot: tmpRegistry, installMethod: 'copy' });
163
+ const settings = JSON.parse(fs_1.default.readFileSync(settingsPath, 'utf8'));
164
+ expect(settings.permissions).toEqual({ allow: ['Read'] });
165
+ expect(settings.hooks.SessionEnd).toHaveLength(1);
166
+ expect(settings.hooks.SessionStart[0].matcher).toBe('startup|clear|compact');
148
167
  });
149
168
  });
@@ -62,7 +62,10 @@ describe('resyncInstalledHooks', () => {
62
62
  writeSettingsWithAwmEntry(scriptsDir);
63
63
  const { resyncInstalledHooks } = require('../../../src/commands/hooks/resync');
64
64
  const results = resyncInstalledHooks(tmpRegistry);
65
- expect(results).toEqual([{ agent: 'claude-code', action: 'resynced' }]);
65
+ expect(results).toEqual([
66
+ { agent: 'claude-code', action: 'resynced' },
67
+ { agent: 'codex', action: 'not-installed' },
68
+ ]);
66
69
  const synced = fs_1.default.readFileSync(path_1.default.join(scriptsDir, 'session-start'), 'utf-8');
67
70
  expect(synced).toContain('NEW VERSION');
68
71
  expect(fs_1.default.lstatSync(path_1.default.join(scriptsDir, 'session-start')).isSymbolicLink()).toBe(false);
@@ -73,7 +76,10 @@ describe('resyncInstalledHooks', () => {
73
76
  writeRegistry('#!/usr/bin/env bash\necho "NEW VERSION"');
74
77
  const { resyncInstalledHooks } = require('../../../src/commands/hooks/resync');
75
78
  const results = resyncInstalledHooks(tmpRegistry);
76
- expect(results).toEqual([{ agent: 'claude-code', action: 'not-installed' }]);
79
+ expect(results).toEqual([
80
+ { agent: 'claude-code', action: 'not-installed' },
81
+ { agent: 'codex', action: 'not-installed' },
82
+ ]);
77
83
  expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.awm/hooks/session-start'))).toBe(false);
78
84
  });
79
85
  it('preserves symlink install method', () => {
@@ -85,7 +91,10 @@ describe('resyncInstalledHooks', () => {
85
91
  writeSettingsWithAwmEntry(scriptsDir);
86
92
  const { resyncInstalledHooks } = require('../../../src/commands/hooks/resync');
87
93
  const results = resyncInstalledHooks(tmpRegistry);
88
- expect(results).toEqual([{ agent: 'claude-code', action: 'resynced' }]);
94
+ expect(results).toEqual([
95
+ { agent: 'claude-code', action: 'resynced' },
96
+ { agent: 'codex', action: 'not-installed' },
97
+ ]);
89
98
  expect(fs_1.default.lstatSync(path_1.default.join(scriptsDir, 'session-start')).isSymbolicLink()).toBe(true);
90
99
  expect(fs_1.default.lstatSync(path_1.default.join(scriptsDir, 'using-awm.md')).isSymbolicLink()).toBe(true);
91
100
  });
@@ -96,7 +105,10 @@ describe('resyncInstalledHooks', () => {
96
105
  writeSettingsWithAwmEntry(scriptsDir);
97
106
  const { resyncInstalledHooks } = require('../../../src/commands/hooks/resync');
98
107
  const results = resyncInstalledHooks(tmpRegistry);
99
- expect(results).toEqual([{ agent: 'claude-code', action: 'registry-missing' }]);
108
+ expect(results).toEqual([
109
+ { agent: 'claude-code', action: 'registry-missing' },
110
+ { agent: 'codex', action: 'not-installed' },
111
+ ]);
100
112
  expect(fs_1.default.readFileSync(path_1.default.join(scriptsDir, 'session-start'), 'utf-8')).toContain('OLD');
101
113
  });
102
114
  it('re-creates session-start as copy when scriptsDir exists but session-start is missing', () => {
@@ -108,7 +120,10 @@ describe('resyncInstalledHooks', () => {
108
120
  writeSettingsWithAwmEntry(scriptsDir);
109
121
  const { resyncInstalledHooks } = require('../../../src/commands/hooks/resync');
110
122
  const results = resyncInstalledHooks(tmpRegistry);
111
- expect(results).toEqual([{ agent: 'claude-code', action: 'resynced' }]);
123
+ expect(results).toEqual([
124
+ { agent: 'claude-code', action: 'resynced' },
125
+ { agent: 'codex', action: 'not-installed' },
126
+ ]);
112
127
  expect(fs_1.default.existsSync(path_1.default.join(scriptsDir, 'session-start'))).toBe(true);
113
128
  // detectInstallMethod fell back to copy since lstatSync threw
114
129
  expect(fs_1.default.lstatSync(path_1.default.join(scriptsDir, 'session-start')).isSymbolicLink()).toBe(false);
@@ -128,8 +143,40 @@ describe('resyncInstalledHooks', () => {
128
143
  writeSettingsWithAwmEntry(scriptsDir);
129
144
  const { resyncInstalledHooks } = require('../../../src/commands/hooks/resync');
130
145
  const results = resyncInstalledHooks(tmpRegistry);
131
- expect(results).toEqual([{ agent: 'claude-code', action: 'registry-missing' }]);
146
+ expect(results).toEqual([
147
+ { agent: 'claude-code', action: 'registry-missing' },
148
+ { agent: 'codex', action: 'not-installed' },
149
+ ]);
132
150
  // old script left intact — never leave user without hook
133
151
  expect(fs_1.default.readFileSync(path_1.default.join(scriptsDir, 'session-start'), 'utf-8')).toContain('OLD');
134
152
  });
153
+ it('refreshes an installed Codex hook script and skips the untouched Claude target', () => {
154
+ const codexScriptsDir = path_1.default.join(tmpHome, '.awm/hooks/codex');
155
+ fs_1.default.mkdirSync(codexScriptsDir, { recursive: true });
156
+ fs_1.default.writeFileSync(path_1.default.join(codexScriptsDir, 'session-start'), '#!/usr/bin/env bash\necho "OLD CODEX"', { mode: 0o755 });
157
+ const codexHooksPath = path_1.default.join(tmpHome, '.codex/hooks.json');
158
+ fs_1.default.mkdirSync(path_1.default.dirname(codexHooksPath), { recursive: true });
159
+ fs_1.default.writeFileSync(codexHooksPath, JSON.stringify({
160
+ hooks: {
161
+ SessionStart: [{
162
+ matcher: 'startup|resume|clear|compact',
163
+ hooks: [{
164
+ type: 'command',
165
+ command: path_1.default.join(codexScriptsDir, 'session-start'),
166
+ statusMessage: 'Loading AWM session state',
167
+ }],
168
+ }],
169
+ },
170
+ }, null, 2));
171
+ const regHooks = path_1.default.join(tmpRegistry, 'hooks');
172
+ fs_1.default.mkdirSync(regHooks, { recursive: true });
173
+ fs_1.default.writeFileSync(path_1.default.join(regHooks, 'codex-session-start'), '#!/usr/bin/env bash\necho "NEW CODEX"', { mode: 0o755 });
174
+ const { resyncInstalledHooks } = require('../../../src/commands/hooks/resync');
175
+ const results = resyncInstalledHooks(tmpRegistry);
176
+ expect(results).toEqual([
177
+ { agent: 'claude-code', action: 'not-installed' },
178
+ { agent: 'codex', action: 'resynced' },
179
+ ]);
180
+ expect(fs_1.default.readFileSync(path_1.default.join(codexScriptsDir, 'session-start'), 'utf-8')).toContain('NEW CODEX');
181
+ });
135
182
  });
@@ -85,4 +85,14 @@ describe('computeHookStatus', () => {
85
85
  const { computeHookStatus } = require('../../../src/commands/hooks/status');
86
86
  expect(() => computeHookStatus('antigravity')).toThrow(/hooks not supported/i);
87
87
  });
88
+ it('dispatches Codex targets to the Codex status checks (not the Claude ones)', () => {
89
+ // No Codex hook installed in this tmpHome — should report NOT/DEGRADED
90
+ // via the Codex-specific checks (sessionStartScript, settingsEntry),
91
+ // never touching Claude's settings.json path.
92
+ const { computeHookStatus } = require('../../../src/commands/hooks/status');
93
+ const result = computeHookStatus('codex');
94
+ expect(result.checks.settingsEntry.detail).toContain('.codex/hooks.json');
95
+ expect(result.checks.bootstrapSkill).toBeUndefined();
96
+ expect(result.checks.runHookWrapper).toBeUndefined();
97
+ });
88
98
  });
@@ -101,4 +101,16 @@ describe('uninstallHook', () => {
101
101
  expect(result.backupPath).not.toBeNull();
102
102
  expect(fs_1.default.existsSync(result.backupPath)).toBe(true);
103
103
  });
104
+ it('is a no-op for Codex when hooks.json has no AWM entry (dispatch sanity check)', () => {
105
+ const codexDir = path_1.default.join(tmpHome, '.codex');
106
+ const hooksPath = path_1.default.join(codexDir, 'hooks.json');
107
+ const original = '{ "description": "user content" }\n';
108
+ fs_1.default.mkdirSync(codexDir, { recursive: true });
109
+ fs_1.default.writeFileSync(hooksPath, original);
110
+ const { uninstallHook } = require('../../../src/commands/hooks/uninstall');
111
+ const result = uninstallHook({ agent: 'codex' });
112
+ expect(result.status).toBe('not-installed');
113
+ expect(fs_1.default.readFileSync(hooksPath, 'utf-8')).toBe(original);
114
+ expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.awm/backups'))).toBe(false);
115
+ });
104
116
  });