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.
- package/dist/src/commands/add.js +84 -0
- package/dist/src/commands/agent.js +95 -0
- package/dist/src/commands/backup.js +46 -0
- package/dist/src/commands/doctor.js +80 -4
- package/dist/src/commands/hooks/claude.js +192 -0
- package/dist/src/commands/hooks/codex.js +191 -0
- package/dist/src/commands/hooks/index.js +23 -8
- package/dist/src/commands/hooks/install.js +11 -107
- package/dist/src/commands/hooks/resync.js +28 -18
- package/dist/src/commands/hooks/shared.js +94 -0
- package/dist/src/commands/hooks/status.js +13 -64
- package/dist/src/commands/hooks/uninstall.js +11 -47
- package/dist/src/commands/init.js +90 -28
- package/dist/src/commands/registry/index.js +1 -1
- package/dist/src/commands/sync.js +113 -0
- package/dist/src/commands/update.js +107 -0
- package/dist/src/core/agent-targets.js +50 -0
- package/dist/src/core/artifact-state.js +65 -0
- package/dist/src/core/atomic-file.js +72 -0
- package/dist/src/core/bundle-install.js +60 -38
- package/dist/src/core/bundles.js +21 -0
- package/dist/src/core/context/managed-block.js +190 -0
- package/dist/src/core/context/orchestrator.js +3 -1
- package/dist/src/core/context/project-constitution-inject.js +1 -1
- package/dist/src/core/context/regenerate.js +3 -3
- package/dist/src/core/context/strategies/codex-agents.js +100 -0
- package/dist/src/core/diagnostics/checks.js +16 -0
- package/dist/src/core/diagnostics/context.js +37 -8
- package/dist/src/core/diagnostics/provider-checks.js +219 -0
- package/dist/src/core/executor.js +30 -9
- package/dist/src/core/init/mutation-targets.js +139 -0
- package/dist/src/core/init/provider-facts.js +179 -0
- package/dist/src/core/init/steps.js +65 -9
- package/dist/src/core/install-planner.js +206 -0
- package/dist/src/core/install-transaction.js +405 -0
- package/dist/src/core/profile.js +1 -1
- package/dist/src/core/provider-artifacts.js +54 -0
- package/dist/src/core/provider-version.js +36 -0
- package/dist/src/core/reconciliation.js +61 -0
- package/dist/src/core/registries.js +10 -0
- package/dist/src/core/renderers/canonical-agent.js +27 -0
- package/dist/src/core/renderers/codex-agent.js +60 -0
- package/dist/src/core/skill-integrity.js +4 -3
- package/dist/src/index.js +97 -289
- package/dist/src/providers/index.js +143 -39
- package/dist/src/ui/provider-preflight.js +26 -0
- package/dist/src/utils/config.js +101 -9
- package/dist/tests/commands/agent.test.js +103 -0
- package/dist/tests/commands/backup.test.js +109 -0
- package/dist/tests/commands/doctor.test.js +65 -4
- package/dist/tests/commands/hooks/codex.test.js +230 -0
- package/dist/tests/commands/hooks/install.test.js +20 -1
- package/dist/tests/commands/hooks/resync.test.js +53 -6
- package/dist/tests/commands/hooks/status.test.js +10 -0
- package/dist/tests/commands/hooks/uninstall.test.js +12 -0
- package/dist/tests/commands/init.test.js +195 -8
- package/dist/tests/commands/multi-agent-targeting.test.js +231 -0
- package/dist/tests/core/agent-targets.test.js +45 -0
- package/dist/tests/core/artifact-state.test.js +144 -0
- package/dist/tests/core/atomic-file.test.js +96 -0
- package/dist/tests/core/bundle-install.test.js +208 -17
- package/dist/tests/core/bundles.test.js +12 -0
- package/dist/tests/core/context/managed-block.test.js +90 -0
- package/dist/tests/core/context/orchestrator.test.js +48 -3
- package/dist/tests/core/context/regenerate.test.js +1 -1
- package/dist/tests/core/context/strategies/codex-agents.test.js +185 -0
- package/dist/tests/core/context/strategies/config-instructions.test.js +1 -1
- package/dist/tests/core/diagnostics/checks.test.js +19 -0
- package/dist/tests/core/diagnostics/context.test.js +74 -0
- package/dist/tests/core/diagnostics/provider-checks.test.js +252 -0
- package/dist/tests/core/executor.test.js +20 -0
- package/dist/tests/core/export/engine.test.js +3 -2
- package/dist/tests/core/init/mutation-targets.test.js +235 -0
- package/dist/tests/core/init/orchestrator.test.js +1 -1
- package/dist/tests/core/init/provider-facts.test.js +177 -0
- package/dist/tests/core/init/steps.test.js +106 -2
- package/dist/tests/core/install-planner.test.js +224 -0
- package/dist/tests/core/install-transaction.test.js +257 -0
- package/dist/tests/core/provider-artifacts.test.js +64 -0
- package/dist/tests/core/provider-version.test.js +53 -0
- package/dist/tests/core/reconciliation.test.js +165 -0
- package/dist/tests/core/registries-sync.test.js +4 -4
- package/dist/tests/core/renderers/canonical-agent.test.js +58 -0
- package/dist/tests/core/renderers/codex-agent.test.js +128 -0
- package/dist/tests/core/versioning.test.js +1 -1
- package/dist/tests/integration/codex-provider-isolated.test.js +235 -0
- package/dist/tests/providers/hooks-config.test.js +43 -25
- package/dist/tests/providers/index.test.js +144 -39
- package/dist/tests/structural/codex-agent-escaping-completeness.test.js +77 -0
- package/dist/tests/ui/provider-preflight.test.js +25 -0
- package/dist/tests/utils/config.test.js +178 -18
- 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
|
|
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
|
|
93
|
-
const parsed = JSON.parse(written);
|
|
110
|
+
const parsed = JSON.parse(stdout());
|
|
94
111
|
expect(parsed.overall).toBe('degraded');
|
|
95
|
-
expect(Array.isArray(parsed.
|
|
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([
|
|
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([
|
|
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([
|
|
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([
|
|
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([
|
|
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([
|
|
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
|
});
|