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.
- 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/export.js +48 -0
- 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/discovery.js +9 -3
- package/dist/src/core/executor.js +30 -9
- package/dist/src/core/export/index.js +51 -0
- package/dist/src/core/export/pack.js +58 -0
- package/dist/src/core/export/resolve.js +77 -0
- package/dist/src/core/export/transform.js +57 -0
- package/dist/src/core/export/types.js +2 -0
- package/dist/src/core/init/mutation-targets.js +139 -0
- package/dist/src/core/init/provider-facts.js +164 -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 +99 -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/export.test.js +64 -0
- 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 +104 -0
- package/dist/tests/core/export/pack.test.js +122 -0
- package/dist/tests/core/export/resolve.test.js +78 -0
- package/dist/tests/core/export/transform.test.js +74 -0
- 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 +129 -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 +206 -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
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
|
+
const os_1 = __importDefault(require("os"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
describe('gatherProviderFacts / assertClaudeBaselinePreserved', () => {
|
|
10
|
+
let tmpHome;
|
|
11
|
+
let originalHome;
|
|
12
|
+
let originalAwmHome;
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-provider-facts-'));
|
|
15
|
+
originalHome = process.env.HOME;
|
|
16
|
+
originalAwmHome = process.env.AWM_HOME;
|
|
17
|
+
process.env.HOME = tmpHome;
|
|
18
|
+
process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
|
|
19
|
+
jest.resetModules();
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
|
|
23
|
+
if (originalHome === undefined)
|
|
24
|
+
delete process.env.HOME;
|
|
25
|
+
else
|
|
26
|
+
process.env.HOME = originalHome;
|
|
27
|
+
if (originalAwmHome === undefined)
|
|
28
|
+
delete process.env.AWM_HOME;
|
|
29
|
+
else
|
|
30
|
+
process.env.AWM_HOME = originalAwmHome;
|
|
31
|
+
});
|
|
32
|
+
it('two snapshots of untouched state are identical (no false positive)', () => {
|
|
33
|
+
const { gatherProviderFacts, assertClaudeBaselinePreserved } = require('../../../src/core/init/provider-facts');
|
|
34
|
+
const before = gatherProviderFacts('claude-code');
|
|
35
|
+
const after = gatherProviderFacts('claude-code');
|
|
36
|
+
expect(() => assertClaudeBaselinePreserved(before, after)).not.toThrow();
|
|
37
|
+
expect(before.hash).toBe(after.hash);
|
|
38
|
+
});
|
|
39
|
+
it('detects a top-level file change under a managed directory', () => {
|
|
40
|
+
const { gatherProviderFacts, assertClaudeBaselinePreserved } = require('../../../src/core/init/provider-facts');
|
|
41
|
+
const skillsDir = path_1.default.join(tmpHome, '.claude', 'skills');
|
|
42
|
+
fs_1.default.mkdirSync(skillsDir, { recursive: true });
|
|
43
|
+
fs_1.default.writeFileSync(path_1.default.join(skillsDir, 'SKILL.md'), 'original');
|
|
44
|
+
const before = gatherProviderFacts('claude-code');
|
|
45
|
+
fs_1.default.writeFileSync(path_1.default.join(skillsDir, 'SKILL.md'), 'mutated');
|
|
46
|
+
const after = gatherProviderFacts('claude-code');
|
|
47
|
+
expect(before.hash).not.toBe(after.hash);
|
|
48
|
+
expect(() => assertClaudeBaselinePreserved(before, after)).toThrow(/must never happen \(R19\)/);
|
|
49
|
+
});
|
|
50
|
+
it('detects a content-only change two levels deep under a managed directory', () => {
|
|
51
|
+
const { gatherProviderFacts, assertClaudeBaselinePreserved } = require('../../../src/core/init/provider-facts');
|
|
52
|
+
const skillsDir = path_1.default.join(tmpHome, '.claude', 'skills');
|
|
53
|
+
// .claude/skills/some-skill/nested/deep.md — two directory levels below skills/
|
|
54
|
+
const nested = path_1.default.join(skillsDir, 'some-skill', 'nested');
|
|
55
|
+
fs_1.default.mkdirSync(nested, { recursive: true });
|
|
56
|
+
const deepFile = path_1.default.join(nested, 'deep.md');
|
|
57
|
+
fs_1.default.writeFileSync(deepFile, 'original deep content');
|
|
58
|
+
const before = gatherProviderFacts('claude-code');
|
|
59
|
+
// Content-only mutation — no rename, no size-preserving trick needed;
|
|
60
|
+
// this alone would have been invisible to a top-level-only stat digest.
|
|
61
|
+
fs_1.default.writeFileSync(deepFile, 'mutated deep content');
|
|
62
|
+
const after = gatherProviderFacts('claude-code');
|
|
63
|
+
expect(before.hash).not.toBe(after.hash);
|
|
64
|
+
expect(() => assertClaudeBaselinePreserved(before, after)).toThrow(/must never happen \(R19\)/);
|
|
65
|
+
});
|
|
66
|
+
it('detects an added file three levels deep without touching any existing file', () => {
|
|
67
|
+
const { gatherProviderFacts } = require('../../../src/core/init/provider-facts');
|
|
68
|
+
const skillsDir = path_1.default.join(tmpHome, '.claude', 'skills');
|
|
69
|
+
const nested = path_1.default.join(skillsDir, 'pkg', 'a', 'b');
|
|
70
|
+
fs_1.default.mkdirSync(nested, { recursive: true });
|
|
71
|
+
fs_1.default.writeFileSync(path_1.default.join(nested, 'existing.md'), 'unchanged');
|
|
72
|
+
const before = gatherProviderFacts('claude-code');
|
|
73
|
+
fs_1.default.writeFileSync(path_1.default.join(nested, 'new-file.md'), 'new');
|
|
74
|
+
const after = gatherProviderFacts('claude-code');
|
|
75
|
+
expect(before.hash).not.toBe(after.hash);
|
|
76
|
+
});
|
|
77
|
+
it('throws a distinct message when comparing facts for mismatched agents', () => {
|
|
78
|
+
const { gatherProviderFacts, assertClaudeBaselinePreserved } = require('../../../src/core/init/provider-facts');
|
|
79
|
+
const claude = gatherProviderFacts('claude-code');
|
|
80
|
+
const codex = gatherProviderFacts('codex');
|
|
81
|
+
expect(() => assertClaudeBaselinePreserved(claude, codex)).toThrow('mismatched agents');
|
|
82
|
+
});
|
|
83
|
+
// -----------------------------------------------------------------------
|
|
84
|
+
// Nested-scriptsDir exclusion (Task 9 fix) — Codex's hook scriptsDir
|
|
85
|
+
// (~/.awm/hooks/codex) is physically nested inside Claude's own hook
|
|
86
|
+
// scriptsDir (~/.awm/hooks — providers/index.ts). A naive recursive hash
|
|
87
|
+
// of Claude's scriptsDir would pick up every Codex hook write as a
|
|
88
|
+
// "Claude baseline changed" false positive. These two tests pin that
|
|
89
|
+
// exclusion directly, rather than relying only on the E2E's indirect
|
|
90
|
+
// before/after tree-snapshot check.
|
|
91
|
+
// -----------------------------------------------------------------------
|
|
92
|
+
it('does NOT flag a write inside Codex\'s nested scriptsDir as a Claude baseline change', () => {
|
|
93
|
+
const { gatherProviderFacts, assertClaudeBaselinePreserved } = require('../../../src/core/init/provider-facts');
|
|
94
|
+
// Seed Claude's own scriptsDir first (mirrors a real prior Claude hook install).
|
|
95
|
+
const claudeScripts = path_1.default.join(tmpHome, '.awm', 'hooks');
|
|
96
|
+
fs_1.default.mkdirSync(claudeScripts, { recursive: true });
|
|
97
|
+
fs_1.default.writeFileSync(path_1.default.join(claudeScripts, 'session-start'), '#!/bin/sh\n');
|
|
98
|
+
fs_1.default.writeFileSync(path_1.default.join(claudeScripts, 'run-hook.cmd'), '#!/bin/sh\n');
|
|
99
|
+
const before = gatherProviderFacts('claude-code');
|
|
100
|
+
// Codex's own scriptsDir is nested one level inside Claude's — simulate a
|
|
101
|
+
// real Codex hook install writing there for the first time.
|
|
102
|
+
const codexScripts = path_1.default.join(tmpHome, '.awm', 'hooks', 'codex');
|
|
103
|
+
fs_1.default.mkdirSync(codexScripts, { recursive: true });
|
|
104
|
+
fs_1.default.writeFileSync(path_1.default.join(codexScripts, 'session-start'), '#!/bin/sh\necho codex\n');
|
|
105
|
+
const afterAdd = gatherProviderFacts('claude-code');
|
|
106
|
+
expect(afterAdd.hash).toBe(before.hash);
|
|
107
|
+
expect(() => assertClaudeBaselinePreserved(before, afterAdd)).not.toThrow();
|
|
108
|
+
// Modifying the Codex-owned nested file's content must also stay invisible.
|
|
109
|
+
fs_1.default.writeFileSync(path_1.default.join(codexScripts, 'session-start'), '#!/bin/sh\necho codex v2\n');
|
|
110
|
+
const afterModify = gatherProviderFacts('claude-code');
|
|
111
|
+
expect(afterModify.hash).toBe(before.hash);
|
|
112
|
+
expect(() => assertClaudeBaselinePreserved(before, afterModify)).not.toThrow();
|
|
113
|
+
});
|
|
114
|
+
it('still flags a genuinely Claude-owned change alongside an untouched Codex nested dir', () => {
|
|
115
|
+
const { gatherProviderFacts, assertClaudeBaselinePreserved } = require('../../../src/core/init/provider-facts');
|
|
116
|
+
const claudeScripts = path_1.default.join(tmpHome, '.awm', 'hooks');
|
|
117
|
+
const codexScripts = path_1.default.join(claudeScripts, 'codex');
|
|
118
|
+
fs_1.default.mkdirSync(codexScripts, { recursive: true });
|
|
119
|
+
fs_1.default.writeFileSync(path_1.default.join(claudeScripts, 'session-start'), '#!/bin/sh\n');
|
|
120
|
+
fs_1.default.writeFileSync(path_1.default.join(codexScripts, 'session-start'), '#!/bin/sh\necho codex\n');
|
|
121
|
+
const before = gatherProviderFacts('claude-code');
|
|
122
|
+
// A genuinely Claude-owned file (a sibling of the codex/ subdir, not inside it)
|
|
123
|
+
// changes — the exclusion must not swallow this.
|
|
124
|
+
fs_1.default.writeFileSync(path_1.default.join(claudeScripts, 'session-start'), '#!/bin/sh\necho mutated claude script\n');
|
|
125
|
+
const after = gatherProviderFacts('claude-code');
|
|
126
|
+
expect(after.hash).not.toBe(before.hash);
|
|
127
|
+
expect(() => assertClaudeBaselinePreserved(before, after)).toThrow(/must never happen \(R19\)/);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -55,7 +55,7 @@ function spies() {
|
|
|
55
55
|
function deps(ctx, actions, over = {}) {
|
|
56
56
|
return {
|
|
57
57
|
cwd: '/repo', ctx, bundles: [bundle('dev', 'baseline', ['brainstorming'])],
|
|
58
|
-
agent: 'claude-code', installMethod: 'symlink',
|
|
58
|
+
agent: 'claude-code', enabledAgents: ['claude-code'], installMethod: 'symlink',
|
|
59
59
|
registryRoot: '/cache', contentDir: '/cache/registry', sensorPacksRoot: '/cache/registry',
|
|
60
60
|
confirmExtensions: async (p) => p, actions, ...over,
|
|
61
61
|
};
|
|
@@ -111,6 +111,22 @@ describe('stepHook / stepDevCore / stepAmbient', () => {
|
|
|
111
111
|
expect((0, steps_1.stepHook)(deps({ machine: m, project: null }, a)).action).toBe('applied');
|
|
112
112
|
expect(a.installHook).toHaveBeenCalled();
|
|
113
113
|
});
|
|
114
|
+
// Regression: a real (unstubbed) `awm init --agent opencode` used to throw
|
|
115
|
+
// "hooks not supported for agent target: opencode" from installHook,
|
|
116
|
+
// because stepHook never checked whether the target agent has a hook
|
|
117
|
+
// mechanism at all before calling installHook (providers/index.ts:
|
|
118
|
+
// OpenCode/Antigravity have no `hooks` config). Found while building the
|
|
119
|
+
// real Codex+OpenCode coexistence E2E test — mirrors provider-checks.ts's
|
|
120
|
+
// `hookTrustCheck`, which already treats a missing `hooks` config as "not
|
|
121
|
+
// applicable" rather than a failure.
|
|
122
|
+
it('hook skips (not calls installHook) for an agent with no hook mechanism', () => {
|
|
123
|
+
const a = spies();
|
|
124
|
+
const m = machine();
|
|
125
|
+
m.hook = { present: false };
|
|
126
|
+
const r = (0, steps_1.stepHook)(deps({ machine: m, project: null }, a, { agent: 'opencode', enabledAgents: ['opencode'] }));
|
|
127
|
+
expect(r.action).toBe('skipped');
|
|
128
|
+
expect(a.installHook).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
114
130
|
it('devCore installs baseline when links broken', () => {
|
|
115
131
|
const a = spies();
|
|
116
132
|
const m = machine();
|
|
@@ -137,6 +153,56 @@ describe('stepHook / stepDevCore / stepAmbient', () => {
|
|
|
137
153
|
const a = spies();
|
|
138
154
|
expect((0, steps_1.stepAmbient)(deps({ machine: machine(), project: null }, a)).action).toBe('skipped');
|
|
139
155
|
});
|
|
156
|
+
// Regression coverage for the BLOCKER found in post-implementation QA:
|
|
157
|
+
// stepDevCore/stepAmbient used to pass a `[d.agent]` singleton as
|
|
158
|
+
// `agents`, which install-planner.ts's `assertCompleteSharedGroup` (R14)
|
|
159
|
+
// refuses whenever a co-owner sharing the same physical skill directory
|
|
160
|
+
// (OpenCode and Codex both resolve to ~/.agents/skills) is independently
|
|
161
|
+
// enabled — making `awm init --agent codex` structurally fail once
|
|
162
|
+
// OpenCode was already enabled, and vice versa.
|
|
163
|
+
it('devCore includes a co-enabled agent sharing the same skill target (codex + opencode)', () => {
|
|
164
|
+
const a = spies();
|
|
165
|
+
const m = machine();
|
|
166
|
+
m.devCore = { present: false, brokenLinks: [] };
|
|
167
|
+
const r = (0, steps_1.stepDevCore)(deps({ machine: m, project: null }, a, {
|
|
168
|
+
agent: 'codex', enabledAgents: ['claude-code', 'opencode', 'codex'],
|
|
169
|
+
}));
|
|
170
|
+
expect(r.action).toBe('applied');
|
|
171
|
+
expect(a.installBundle).toHaveBeenCalledWith(expect.objectContaining({ agents: expect.arrayContaining(['codex', 'opencode']) }));
|
|
172
|
+
const call = a.installBundle.mock.calls[0][0];
|
|
173
|
+
expect(call.agents).toHaveLength(2); // claude-code is NOT included — it doesn't share the skill target
|
|
174
|
+
});
|
|
175
|
+
it('devCore does not add a co-owner that is not currently enabled', () => {
|
|
176
|
+
const a = spies();
|
|
177
|
+
const m = machine();
|
|
178
|
+
m.devCore = { present: false, brokenLinks: [] };
|
|
179
|
+
const r = (0, steps_1.stepDevCore)(deps({ machine: m, project: null }, a, {
|
|
180
|
+
agent: 'codex', enabledAgents: ['claude-code', 'codex'],
|
|
181
|
+
}));
|
|
182
|
+
expect(r.action).toBe('applied');
|
|
183
|
+
expect(a.installBundle).toHaveBeenCalledWith(expect.objectContaining({ agents: ['codex'] }));
|
|
184
|
+
});
|
|
185
|
+
it('devCore never adds a co-owner for claude-code (its skill dir is never shared)', () => {
|
|
186
|
+
const a = spies();
|
|
187
|
+
const m = machine();
|
|
188
|
+
m.devCore = { present: false, brokenLinks: [] };
|
|
189
|
+
const r = (0, steps_1.stepDevCore)(deps({ machine: m, project: null }, a, {
|
|
190
|
+
agent: 'claude-code', enabledAgents: ['claude-code', 'opencode', 'codex'],
|
|
191
|
+
}));
|
|
192
|
+
expect(r.action).toBe('applied');
|
|
193
|
+
expect(a.installBundle).toHaveBeenCalledWith(expect.objectContaining({ agents: ['claude-code'] }));
|
|
194
|
+
});
|
|
195
|
+
it('ambient includes a co-enabled agent sharing the same skill target (codex + opencode)', () => {
|
|
196
|
+
const a = spies();
|
|
197
|
+
const m = machine();
|
|
198
|
+
m.ambient = { wanted: ['docs'], installed: [] };
|
|
199
|
+
const r = (0, steps_1.stepAmbient)(deps({ machine: m, project: null }, a, {
|
|
200
|
+
agent: 'opencode', enabledAgents: ['claude-code', 'opencode', 'codex'],
|
|
201
|
+
}));
|
|
202
|
+
expect(r.action).toBe('applied');
|
|
203
|
+
const call = a.installBundle.mock.calls[0][0];
|
|
204
|
+
expect(call.agents.sort()).toEqual(['codex', 'opencode']);
|
|
205
|
+
});
|
|
140
206
|
});
|
|
141
207
|
describe('stepProfile', () => {
|
|
142
208
|
it('adds confirmed extension when detector finds a match', async () => {
|
|
@@ -237,6 +303,31 @@ describe('stepActivation', () => {
|
|
|
237
303
|
(0, steps_1.stepActivation)(deps(ctx, a, { agent: 'opencode' }));
|
|
238
304
|
expect(a.gatherProject).toHaveBeenCalledWith(expect.any(String), expect.any(Array), 'opencode');
|
|
239
305
|
});
|
|
306
|
+
// Same class of bug as stepDevCore/stepAmbient's shared-group regression
|
|
307
|
+
// above, in the LOCAL-scope (project) code path: OpenCode and Codex share
|
|
308
|
+
// both their global AND local skill directories, so a project extension
|
|
309
|
+
// with a skill artifact hits the identical R14 refusal whenever
|
|
310
|
+
// syncProfile is called with a `[d.agent]` singleton and a co-owner is
|
|
311
|
+
// independently enabled.
|
|
312
|
+
it('activation includes a co-enabled agent sharing the same LOCAL skill target (codex + opencode)', () => {
|
|
313
|
+
const a = spies();
|
|
314
|
+
a.gatherProject = jest.fn((_cwd, _bundles) => project({ activeBundles: { expected: ['x'], linked: [], broken: [] } }));
|
|
315
|
+
const r = (0, steps_1.stepActivation)(deps({ machine: machine(), project: project() }, a, {
|
|
316
|
+
agent: 'codex', enabledAgents: ['claude-code', 'opencode', 'codex'],
|
|
317
|
+
}));
|
|
318
|
+
expect(r.action).toBe('applied');
|
|
319
|
+
const call = a.syncProfile.mock.calls[0][0];
|
|
320
|
+
expect(call.agents.sort()).toEqual(['codex', 'opencode']);
|
|
321
|
+
});
|
|
322
|
+
it('activation does not add a co-owner that is not currently enabled', () => {
|
|
323
|
+
const a = spies();
|
|
324
|
+
a.gatherProject = jest.fn((_cwd, _bundles) => project({ activeBundles: { expected: ['x'], linked: [], broken: [] } }));
|
|
325
|
+
const r = (0, steps_1.stepActivation)(deps({ machine: machine(), project: project() }, a, {
|
|
326
|
+
agent: 'codex', enabledAgents: ['claude-code', 'codex'],
|
|
327
|
+
}));
|
|
328
|
+
expect(r.action).toBe('applied');
|
|
329
|
+
expect(a.syncProfile).toHaveBeenCalledWith(expect.objectContaining({ agents: ['codex'] }));
|
|
330
|
+
});
|
|
240
331
|
});
|
|
241
332
|
describe('stepSensors', () => {
|
|
242
333
|
it('skips when sensors present', () => {
|
|
@@ -296,7 +387,7 @@ describe('stepGlobalSkillsRepair', () => {
|
|
|
296
387
|
m.globalSkills = { valid: [], repairable: ['b'], dead: [] };
|
|
297
388
|
const r = (0, steps_1.stepGlobalSkillsRepair)(deps({ machine: m, project: null }, a, { agent: 'opencode' }));
|
|
298
389
|
expect(r.action).toBe('applied');
|
|
299
|
-
expect(a.repairGlobalSkills).toHaveBeenCalledWith(providers_1.
|
|
390
|
+
expect(a.repairGlobalSkills).toHaveBeenCalledWith((0, providers_1.providerFor)('opencode').skill.global, expect.any(Array));
|
|
300
391
|
});
|
|
301
392
|
});
|
|
302
393
|
describe('stepConstitutionInjection (#6)', () => {
|
|
@@ -318,6 +409,12 @@ describe('stepConstitutionInjection (#6)', () => {
|
|
|
318
409
|
expect(r.action).toBe('skipped');
|
|
319
410
|
expect(a.injectProjectConstitution).not.toHaveBeenCalled();
|
|
320
411
|
});
|
|
412
|
+
it('installs Codex project guidance before CONSTITUTION.md exists', () => {
|
|
413
|
+
const a = spies();
|
|
414
|
+
const r = (0, steps_1.stepConstitutionInjection)(deps({ machine: machine(), project: project({ constitution: { present: false } }) }, a, { agent: 'codex' }));
|
|
415
|
+
expect(r.action).toBe('applied');
|
|
416
|
+
expect(a.injectProjectConstitution).toHaveBeenCalledWith({ projectRoot: '/repo', agent: 'codex' });
|
|
417
|
+
});
|
|
321
418
|
it('maps already → skipped when CONSTITUTION.md was already in opencode.json', () => {
|
|
322
419
|
const a = spies();
|
|
323
420
|
a.injectProjectConstitution.mockReturnValue('already');
|
|
@@ -360,4 +457,11 @@ describe('stepContextInjection', () => {
|
|
|
360
457
|
expect(r.action).toBe('applied');
|
|
361
458
|
expect(a.installContext).toHaveBeenCalled();
|
|
362
459
|
});
|
|
460
|
+
it('installs the Codex global managed block when absent', () => {
|
|
461
|
+
const a = spies();
|
|
462
|
+
a.contextStatus.mockReturnValue('absent');
|
|
463
|
+
const r = (0, steps_1.stepContextInjection)(deps({ machine: machine(), project: null }, a, { agent: 'codex' }));
|
|
464
|
+
expect(r.action).toBe('applied');
|
|
465
|
+
expect(a.installContext).toHaveBeenCalledWith(expect.objectContaining({ agent: 'codex', scope: 'global' }));
|
|
466
|
+
});
|
|
363
467
|
});
|
|
@@ -0,0 +1,224 @@
|
|
|
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 install_planner_1 = require("../../src/core/install-planner");
|
|
10
|
+
describe('install-planner', () => {
|
|
11
|
+
let tmpHome;
|
|
12
|
+
let tmpWork;
|
|
13
|
+
let originalHome;
|
|
14
|
+
let originalAwmHome;
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
// Isolate ~ (and thus provider global paths, per CLAUDE.md: no test may
|
|
17
|
+
// touch the real ~/.awm or ~/.agents). HOME also drives providerFor()'s
|
|
18
|
+
// global dirs, which is exactly what makes opencode/codex "share" a
|
|
19
|
+
// physical skills directory in these tests.
|
|
20
|
+
tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-planner-home-'));
|
|
21
|
+
tmpWork = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-planner-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 skillArtifact(name) {
|
|
40
|
+
return {
|
|
41
|
+
name,
|
|
42
|
+
installName: name,
|
|
43
|
+
type: 'skill',
|
|
44
|
+
sourcePath: path_1.default.join(tmpWork, 'registry', 'skills', name),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function agentArtifact(name) {
|
|
48
|
+
return {
|
|
49
|
+
name,
|
|
50
|
+
installName: `${name}.md`,
|
|
51
|
+
type: 'agent',
|
|
52
|
+
sourcePath: path_1.default.join(tmpWork, 'registry', 'agents', `${name}.md`),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function workflowArtifact(name) {
|
|
56
|
+
return {
|
|
57
|
+
name,
|
|
58
|
+
installName: `${name}.md`,
|
|
59
|
+
type: 'workflow',
|
|
60
|
+
sourcePath: path_1.default.join(tmpWork, 'registry', 'workflows', `${name}.md`),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function recordOwnedBy(name, owners) {
|
|
64
|
+
return {
|
|
65
|
+
name,
|
|
66
|
+
type: 'skill',
|
|
67
|
+
scope: 'global',
|
|
68
|
+
targetPath: path_1.default.join(tmpHome, '.agents', 'skills', name),
|
|
69
|
+
sourcePath: path_1.default.join(tmpWork, 'registry', 'skills', name),
|
|
70
|
+
renderer: 'link',
|
|
71
|
+
owners,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
describe('planInstall', () => {
|
|
75
|
+
it('deduplicates the OpenCode/Codex physical skill write and reports both owners', () => {
|
|
76
|
+
const plan = (0, install_planner_1.planInstall)({
|
|
77
|
+
artifacts: [skillArtifact('development-process')],
|
|
78
|
+
selectedAgents: ['opencode', 'codex'],
|
|
79
|
+
enabledAgents: ['claude-code', 'opencode', 'codex'],
|
|
80
|
+
scope: 'global',
|
|
81
|
+
projectRoot: tmpWork,
|
|
82
|
+
method: 'symlink',
|
|
83
|
+
});
|
|
84
|
+
expect(plan.operations).toHaveLength(1); // verifies R15
|
|
85
|
+
expect(plan.operations[0].owners).toEqual(['opencode', 'codex']); // verifies R15.1
|
|
86
|
+
});
|
|
87
|
+
it('aborts a skill change for an incomplete shared target group before writes', () => {
|
|
88
|
+
expect(() => (0, install_planner_1.planInstall)({
|
|
89
|
+
artifacts: [skillArtifact('development-process')],
|
|
90
|
+
selectedAgents: ['codex'],
|
|
91
|
+
enabledAgents: ['opencode', 'codex'],
|
|
92
|
+
scope: 'global',
|
|
93
|
+
projectRoot: tmpWork,
|
|
94
|
+
method: 'symlink',
|
|
95
|
+
})).toThrow('select the complete shared target group: opencode,codex'); // verifies R14
|
|
96
|
+
});
|
|
97
|
+
it('does not apply the shared-domain restriction to independently addressed agents', () => {
|
|
98
|
+
const plan = (0, install_planner_1.planInstall)({
|
|
99
|
+
artifacts: [agentArtifact('development-process')],
|
|
100
|
+
selectedAgents: ['codex'],
|
|
101
|
+
enabledAgents: ['opencode', 'codex'],
|
|
102
|
+
scope: 'global',
|
|
103
|
+
projectRoot: tmpWork,
|
|
104
|
+
method: 'symlink',
|
|
105
|
+
});
|
|
106
|
+
expect(plan.operations[0].owners).toEqual(['codex']); // verifies R13
|
|
107
|
+
});
|
|
108
|
+
it('returns no operations for an empty artifacts array', () => {
|
|
109
|
+
const plan = (0, install_planner_1.planInstall)({
|
|
110
|
+
artifacts: [],
|
|
111
|
+
selectedAgents: ['claude-code'],
|
|
112
|
+
enabledAgents: ['claude-code'],
|
|
113
|
+
scope: 'global',
|
|
114
|
+
projectRoot: tmpWork,
|
|
115
|
+
method: 'symlink',
|
|
116
|
+
});
|
|
117
|
+
expect(plan).toEqual({ operations: [], records: [], reports: [] });
|
|
118
|
+
});
|
|
119
|
+
it('returns no operations for an empty selectedAgents array', () => {
|
|
120
|
+
const plan = (0, install_planner_1.planInstall)({
|
|
121
|
+
artifacts: [skillArtifact('development-process')],
|
|
122
|
+
selectedAgents: [],
|
|
123
|
+
enabledAgents: ['claude-code'],
|
|
124
|
+
scope: 'global',
|
|
125
|
+
projectRoot: tmpWork,
|
|
126
|
+
method: 'symlink',
|
|
127
|
+
});
|
|
128
|
+
expect(plan).toEqual({ operations: [], records: [], reports: [] });
|
|
129
|
+
});
|
|
130
|
+
it('skips an artifact type unsupported by an agent instead of throwing (workflow on claude-code)', () => {
|
|
131
|
+
const plan = (0, install_planner_1.planInstall)({
|
|
132
|
+
artifacts: [workflowArtifact('wf-ext')],
|
|
133
|
+
selectedAgents: ['claude-code'],
|
|
134
|
+
enabledAgents: ['claude-code'],
|
|
135
|
+
scope: 'global',
|
|
136
|
+
projectRoot: tmpWork,
|
|
137
|
+
method: 'symlink',
|
|
138
|
+
});
|
|
139
|
+
expect(plan.operations).toEqual([]);
|
|
140
|
+
});
|
|
141
|
+
it('resolves a local-scope target beneath projectRoot', () => {
|
|
142
|
+
const plan = (0, install_planner_1.planInstall)({
|
|
143
|
+
artifacts: [skillArtifact('development-process')],
|
|
144
|
+
selectedAgents: ['claude-code'],
|
|
145
|
+
enabledAgents: ['claude-code'],
|
|
146
|
+
scope: 'local',
|
|
147
|
+
projectRoot: tmpWork,
|
|
148
|
+
method: 'symlink',
|
|
149
|
+
});
|
|
150
|
+
expect(plan.operations[0].targetPath).toBe(path_1.default.join(tmpWork, '.claude', 'skills', 'development-process'));
|
|
151
|
+
});
|
|
152
|
+
it('dedupes two intents that share both targetPath and sourcePath into one operation', () => {
|
|
153
|
+
// e.g. the same skill pulled in twice via different bundles in a
|
|
154
|
+
// closure — same physical target, same backing source: fine.
|
|
155
|
+
const plan = (0, install_planner_1.planInstall)({
|
|
156
|
+
artifacts: [skillArtifact('development-process'), skillArtifact('development-process')],
|
|
157
|
+
selectedAgents: ['claude-code'],
|
|
158
|
+
enabledAgents: ['claude-code'],
|
|
159
|
+
scope: 'global',
|
|
160
|
+
projectRoot: tmpWork,
|
|
161
|
+
method: 'symlink',
|
|
162
|
+
});
|
|
163
|
+
expect(plan.operations).toHaveLength(1);
|
|
164
|
+
});
|
|
165
|
+
it('throws when two intents share a targetPath but have different sourcePaths', () => {
|
|
166
|
+
// e.g. the same skill name shipped by two different registries —
|
|
167
|
+
// one physical target can't be backed by two different sources.
|
|
168
|
+
const registryA = {
|
|
169
|
+
name: 'development-process',
|
|
170
|
+
installName: 'development-process',
|
|
171
|
+
type: 'skill',
|
|
172
|
+
sourcePath: path_1.default.join(tmpWork, 'registry-a', 'skills', 'development-process'),
|
|
173
|
+
};
|
|
174
|
+
const registryB = {
|
|
175
|
+
name: 'development-process',
|
|
176
|
+
installName: 'development-process',
|
|
177
|
+
type: 'skill',
|
|
178
|
+
sourcePath: path_1.default.join(tmpWork, 'registry-b', 'skills', 'development-process'),
|
|
179
|
+
};
|
|
180
|
+
expect(() => (0, install_planner_1.planInstall)({
|
|
181
|
+
artifacts: [registryA, registryB],
|
|
182
|
+
selectedAgents: ['claude-code'],
|
|
183
|
+
enabledAgents: ['claude-code'],
|
|
184
|
+
scope: 'global',
|
|
185
|
+
projectRoot: tmpWork,
|
|
186
|
+
method: 'symlink',
|
|
187
|
+
})).toThrow(/physical target already claimed by a different source/);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
describe('planRemoval', () => {
|
|
191
|
+
it('retains a target while a non-selected enabled owner remains', () => {
|
|
192
|
+
const result = (0, install_planner_1.planRemoval)({
|
|
193
|
+
records: [recordOwnedBy('development-process', ['opencode', 'codex'])],
|
|
194
|
+
selectedAgents: ['codex'],
|
|
195
|
+
enabledAgents: ['opencode', 'codex'],
|
|
196
|
+
artifactNames: ['development-process'],
|
|
197
|
+
});
|
|
198
|
+
expect(result.operations).toEqual([]);
|
|
199
|
+
expect(result.records[0].owners).toEqual(['opencode']); // verifies R16
|
|
200
|
+
});
|
|
201
|
+
it('produces an unlink once the last enabled owner is removed', () => {
|
|
202
|
+
const result = (0, install_planner_1.planRemoval)({
|
|
203
|
+
records: [recordOwnedBy('development-process', ['codex'])],
|
|
204
|
+
selectedAgents: ['codex'],
|
|
205
|
+
enabledAgents: ['opencode', 'codex'],
|
|
206
|
+
artifactNames: ['development-process'],
|
|
207
|
+
});
|
|
208
|
+
expect(result.records).toEqual([]);
|
|
209
|
+
expect(result.operations).toHaveLength(1);
|
|
210
|
+
expect(result.operations[0].action).toBe('unlink');
|
|
211
|
+
});
|
|
212
|
+
it('leaves records for artifacts not named in artifactNames untouched', () => {
|
|
213
|
+
const other = recordOwnedBy('other-skill', ['codex']);
|
|
214
|
+
const result = (0, install_planner_1.planRemoval)({
|
|
215
|
+
records: [other],
|
|
216
|
+
selectedAgents: ['codex'],
|
|
217
|
+
enabledAgents: ['opencode', 'codex'],
|
|
218
|
+
artifactNames: ['development-process'],
|
|
219
|
+
});
|
|
220
|
+
expect(result.records).toEqual([other]);
|
|
221
|
+
expect(result.operations).toEqual([]);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
});
|