agentic-workflow-manager 3.2.0 → 3.2.2
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/core/init/orchestrator.js +19 -0
- package/dist/src/core/init/provider-facts.js +17 -2
- package/dist/tests/core/init/orchestrator.test.js +28 -0
- package/dist/tests/core/init/provider-facts.test.js +48 -0
- package/dist/tests/integration/codex-provider-isolated.test.js +29 -0
- package/package.json +1 -1
|
@@ -42,7 +42,26 @@ async function runInitSteps(deps) {
|
|
|
42
42
|
steps.push(await wrapStep('project.constitutionInjection', 'project', () => (0, steps_1.stepConstitutionInjection)(deps)));
|
|
43
43
|
steps.push(await wrapStep('project.context', 'project', () => (0, steps_1.stepContext)(deps)));
|
|
44
44
|
}
|
|
45
|
+
// Re-gather fresh so the report reflects whatever the steps above just
|
|
46
|
+
// installed. `results` deliberately keeps every check — including project
|
|
47
|
+
// ones — so the render still shows real project state (e.g. "run awm sync")
|
|
48
|
+
// when one exists; only `overall`, the exit-code verdict, needs scoping.
|
|
49
|
+
//
|
|
50
|
+
// If this run started with project scope nulled — `--machine-only`
|
|
51
|
+
// (init.ts's effectiveCtx), or genuinely no project at this cwd — `overall`
|
|
52
|
+
// must reflect only what THIS run attempted. `runChecks` otherwise mixes
|
|
53
|
+
// machine + project results into one flat `overall` (checks.ts), so a
|
|
54
|
+
// `--machine-only` bootstrap into an already-initialized project with
|
|
55
|
+
// unrelated unsynced bundles reports "degraded" and callers gating on it
|
|
56
|
+
// (`runInit`'s exit code) fail a run that fully succeeded at everything it
|
|
57
|
+
// was asked to do — the exact shape of a real Codex Cloud bootstrap script
|
|
58
|
+
// aborting under `set -euo pipefail` right after `awm init`.
|
|
45
59
|
const after = (0, checks_1.runChecks)((0, context_1.gatherContext)({ cwd: deps.cwd, bundles: deps.bundles, agent: deps.agent }));
|
|
60
|
+
if (deps.ctx.project === null) {
|
|
61
|
+
after.overall = after.results
|
|
62
|
+
.filter((r) => r.level === 'machine')
|
|
63
|
+
.some((r) => r.status === 'missing') ? 'degraded' : 'healthy';
|
|
64
|
+
}
|
|
46
65
|
return {
|
|
47
66
|
steps,
|
|
48
67
|
applied: steps.filter((s) => s.action === 'applied').length,
|
|
@@ -85,19 +85,34 @@ function hashDirectoryTree(dir, exclude) {
|
|
|
85
85
|
});
|
|
86
86
|
return parts.join('|');
|
|
87
87
|
}
|
|
88
|
+
/** Canonical digest for "this agent owns nothing here" — see hashPath. */
|
|
89
|
+
const NO_CONTENT = 'absent';
|
|
88
90
|
function hashPath(target, exclude) {
|
|
89
91
|
let stat;
|
|
90
92
|
try {
|
|
91
93
|
stat = fs_1.default.lstatSync(target);
|
|
92
94
|
}
|
|
93
95
|
catch {
|
|
94
|
-
return
|
|
96
|
+
return NO_CONTENT;
|
|
95
97
|
}
|
|
96
98
|
if (stat.isSymbolicLink()) {
|
|
97
99
|
return `symlink:${fs_1.default.readlinkSync(target)}`;
|
|
98
100
|
}
|
|
99
101
|
if (stat.isDirectory()) {
|
|
100
|
-
|
|
102
|
+
const tree = hashDirectoryTree(target, exclude);
|
|
103
|
+
// A managed directory that exists but holds nothing this agent owns is
|
|
104
|
+
// indistinguishable, for R19's purposes, from one that doesn't exist:
|
|
105
|
+
// either way Claude has no hook, no skill and no agent installed there.
|
|
106
|
+
// Collapsing both to one digest is what makes a Codex-only bootstrap
|
|
107
|
+
// possible — Codex's scriptsDir (~/.awm/hooks/codex) is nested inside
|
|
108
|
+
// Claude's (~/.awm/hooks), so installing the Codex hook necessarily
|
|
109
|
+
// creates Claude's directory via `mkdirSync(..., {recursive: true})`
|
|
110
|
+
// (commands/hooks/shared.ts). On a machine that never ran a Claude
|
|
111
|
+
// init, that flipped Claude's digest from absent to `dir:` and aborted
|
|
112
|
+
// every `awm init --agent codex` — the exact fresh-cloud-box scenario
|
|
113
|
+
// Codex Cloud boots into. Genuine changes still register: content
|
|
114
|
+
// appearing here, or being emptied out, both move the digest.
|
|
115
|
+
return tree === '' ? NO_CONTENT : `dir:${tree}`;
|
|
101
116
|
}
|
|
102
117
|
return `file:${hashFileContent(target)}`;
|
|
103
118
|
}
|
|
@@ -96,6 +96,34 @@ describe('runInitSteps — orchestrator', () => {
|
|
|
96
96
|
fs_1.default.rmSync(bareCwd, { recursive: true, force: true });
|
|
97
97
|
}
|
|
98
98
|
});
|
|
99
|
+
it('machine-only inside an already-initialized project with unsynced bundles still reaches overall healthy', async () => {
|
|
100
|
+
// Reproduces a real Codex Cloud bootstrap: `awm init --agent codex --yes
|
|
101
|
+
// --machine-only` run with cwd inside an existing, previously-initialized
|
|
102
|
+
// project (its own `.awm/profile.json` already committed, declaring bundles
|
|
103
|
+
// this machine-only run was never asked to sync). The bare-cwd test above
|
|
104
|
+
// can't catch this — there, `ctx.project` is null for two reasons at once
|
|
105
|
+
// (machineOnly AND no project exists), so it never exercises "machineOnly
|
|
106
|
+
// nulled a project that actually has real, degraded content."
|
|
107
|
+
const root = path_1.default.join(tmpHome, 'existing-project');
|
|
108
|
+
fs_1.default.mkdirSync(path_1.default.join(root, '.awm'), { recursive: true });
|
|
109
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'package.json'), JSON.stringify({ dependencies: { next: '14.0.0' } }));
|
|
110
|
+
// Declares the "dev" bundle active, but its skills were never symlinked —
|
|
111
|
+
// exactly `active bundles (N missing)` in the real report the user saw.
|
|
112
|
+
fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'profile.json'), JSON.stringify({ extensions: ['dev'] }));
|
|
113
|
+
const deps = buildDeps(root);
|
|
114
|
+
deps.ctx.project = null; // what init.ts's effectiveCtx does for --machine-only
|
|
115
|
+
const { runInitSteps } = require('../../../src/core/init/orchestrator');
|
|
116
|
+
const out = await runInitSteps(deps);
|
|
117
|
+
// The steps this run actually attempted (machine-only) all succeeded —
|
|
118
|
+
// that must be reflected in the caller's exit code.
|
|
119
|
+
expect(out.steps.every((s) => s.action !== 'failed')).toBe(true);
|
|
120
|
+
expect(out.after.results.find((r) => r.id === 'machine.devCore')?.status).toBe('ok');
|
|
121
|
+
// Project state exists and IS genuinely degraded, and it must stay visible
|
|
122
|
+
// in the report (the CLI's own render still shows it) — but it must not
|
|
123
|
+
// drag down the overall verdict for a run that never touched project scope.
|
|
124
|
+
expect(out.after.results.some((r) => r.id === 'project.activation' && r.status === 'missing')).toBe(true);
|
|
125
|
+
expect(out.after.overall).toBe('healthy');
|
|
126
|
+
});
|
|
99
127
|
it('project repo: applies activation/sensors, flags constitution+context as pending', async () => {
|
|
100
128
|
const root = path_1.default.join(tmpHome, 'repo');
|
|
101
129
|
fs_1.default.mkdirSync(path_1.default.join(root, '.awm'), { recursive: true });
|
|
@@ -111,6 +111,54 @@ describe('gatherProviderFacts / assertClaudeBaselinePreserved', () => {
|
|
|
111
111
|
expect(afterModify.hash).toBe(before.hash);
|
|
112
112
|
expect(() => assertClaudeBaselinePreserved(before, afterModify)).not.toThrow();
|
|
113
113
|
});
|
|
114
|
+
// -----------------------------------------------------------------------
|
|
115
|
+
// Codex-only bootstrap: Claude's scriptsDir has NEVER existed.
|
|
116
|
+
//
|
|
117
|
+
// Every nested-exclusion test above seeds ~/.awm/hooks first, so they only
|
|
118
|
+
// ever exercise the present→present transition. On a fresh Codex-only
|
|
119
|
+
// machine (a cloud box, the actual rollout target) that directory does not
|
|
120
|
+
// exist, and installing the Codex hook creates it as a side effect of
|
|
121
|
+
// `mkdirSync(dirname, {recursive:true})` in syncExecutable — flipping
|
|
122
|
+
// Claude's own scriptsDir from absent to present-but-empty. That is not a
|
|
123
|
+
// change to anything Claude owns, but it moved the hash and aborted init.
|
|
124
|
+
// -----------------------------------------------------------------------
|
|
125
|
+
it('does NOT flag Codex creating its nested scriptsDir when Claude\'s has never existed', () => {
|
|
126
|
+
const { gatherProviderFacts, assertClaudeBaselinePreserved } = require('../../../src/core/init/provider-facts');
|
|
127
|
+
const claudeScripts = path_1.default.join(tmpHome, '.awm', 'hooks');
|
|
128
|
+
expect(fs_1.default.existsSync(claudeScripts)).toBe(false);
|
|
129
|
+
const before = gatherProviderFacts('claude-code');
|
|
130
|
+
// Exactly what syncExecutable does for the Codex hook: one recursive
|
|
131
|
+
// mkdir that necessarily materializes Claude's hooks/ as the parent.
|
|
132
|
+
const codexScripts = path_1.default.join(claudeScripts, 'codex');
|
|
133
|
+
fs_1.default.mkdirSync(codexScripts, { recursive: true });
|
|
134
|
+
fs_1.default.writeFileSync(path_1.default.join(codexScripts, 'session-start'), '#!/bin/sh\necho codex\n');
|
|
135
|
+
const after = gatherProviderFacts('claude-code');
|
|
136
|
+
expect(after.hash).toBe(before.hash);
|
|
137
|
+
expect(() => assertClaudeBaselinePreserved(before, after)).not.toThrow();
|
|
138
|
+
});
|
|
139
|
+
it('still flags real Claude content appearing where the directory did not exist', () => {
|
|
140
|
+
const { gatherProviderFacts, assertClaudeBaselinePreserved } = require('../../../src/core/init/provider-facts');
|
|
141
|
+
const before = gatherProviderFacts('claude-code');
|
|
142
|
+
// The guard must not be relaxed into blindness: an absent directory
|
|
143
|
+
// gaining Claude-owned content is still a genuine baseline change.
|
|
144
|
+
const claudeScripts = path_1.default.join(tmpHome, '.awm', 'hooks');
|
|
145
|
+
fs_1.default.mkdirSync(claudeScripts, { recursive: true });
|
|
146
|
+
fs_1.default.writeFileSync(path_1.default.join(claudeScripts, 'session-start'), '#!/bin/sh\n');
|
|
147
|
+
const after = gatherProviderFacts('claude-code');
|
|
148
|
+
expect(after.hash).not.toBe(before.hash);
|
|
149
|
+
expect(() => assertClaudeBaselinePreserved(before, after)).toThrow(/must never happen \(R19\)/);
|
|
150
|
+
});
|
|
151
|
+
it('still flags Claude content being emptied out of an existing directory', () => {
|
|
152
|
+
const { gatherProviderFacts, assertClaudeBaselinePreserved } = require('../../../src/core/init/provider-facts');
|
|
153
|
+
const claudeScripts = path_1.default.join(tmpHome, '.awm', 'hooks');
|
|
154
|
+
fs_1.default.mkdirSync(claudeScripts, { recursive: true });
|
|
155
|
+
fs_1.default.writeFileSync(path_1.default.join(claudeScripts, 'session-start'), '#!/bin/sh\n');
|
|
156
|
+
const before = gatherProviderFacts('claude-code');
|
|
157
|
+
fs_1.default.rmSync(path_1.default.join(claudeScripts, 'session-start'));
|
|
158
|
+
const after = gatherProviderFacts('claude-code');
|
|
159
|
+
expect(after.hash).not.toBe(before.hash);
|
|
160
|
+
expect(() => assertClaudeBaselinePreserved(before, after)).toThrow(/must never happen \(R19\)/);
|
|
161
|
+
});
|
|
114
162
|
it('still flags a genuinely Claude-owned change alongside an untouched Codex nested dir', () => {
|
|
115
163
|
const { gatherProviderFacts, assertClaudeBaselinePreserved } = require('../../../src/core/init/provider-facts');
|
|
116
164
|
const claudeScripts = path_1.default.join(tmpHome, '.awm', 'hooks');
|
|
@@ -191,6 +191,35 @@ describe('codex provider — isolated home E2E (Task 9)', () => {
|
|
|
191
191
|
// R19/R19.1/R23: nothing under Claude's baseline moved during the Codex run.
|
|
192
192
|
expect(snapshotTree(path_1.default.join(tmpHome, '.claude'))).toEqual(claudeBefore);
|
|
193
193
|
});
|
|
194
|
+
it('initializes Codex on a machine that has never run a Claude Code init', async () => {
|
|
195
|
+
// The E2E above inits claude-code FIRST, so ~/.awm/hooks always exists
|
|
196
|
+
// by the time Codex runs — which is precisely why it never caught this.
|
|
197
|
+
// A fresh Codex Cloud box has no Claude install at all, and installing
|
|
198
|
+
// the Codex hook creates ~/.awm/hooks as a side effect of the recursive
|
|
199
|
+
// mkdir for its own nested ~/.awm/hooks/codex. That flipped Claude's
|
|
200
|
+
// R19 baseline digest from absent to present-but-empty and aborted init
|
|
201
|
+
// with "Claude Code baseline changed during a non-Claude init".
|
|
202
|
+
seedPublicRegistryFixture(path_1.default.join(tmpHome, '.awm/registries/baseline'));
|
|
203
|
+
const { runInit } = require('../../src/commands/init');
|
|
204
|
+
expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.awm/hooks'))).toBe(false);
|
|
205
|
+
expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.claude'))).toBe(false);
|
|
206
|
+
const codex = await runInit({
|
|
207
|
+
cwd: tmpWork,
|
|
208
|
+
yes: true,
|
|
209
|
+
agent: 'codex',
|
|
210
|
+
machineOnly: true,
|
|
211
|
+
assertProviderSupported: () => ({ provider: 'codex', version: '0.150.0' }),
|
|
212
|
+
});
|
|
213
|
+
// Exit 2 is the internal-error path the R19 guard aborts through.
|
|
214
|
+
expect(codex).toBeLessThanOrEqual(1);
|
|
215
|
+
expect(readPrefs().enabledAgents).toEqual(['codex']);
|
|
216
|
+
// The Codex hook really installed, and Claude gained no content of its own.
|
|
217
|
+
const hooksJson = JSON.parse(fs_1.default.readFileSync(path_1.default.join(tmpHome, '.codex/hooks.json'), 'utf8'));
|
|
218
|
+
expect(hooksJson.hooks.SessionStart).toHaveLength(1);
|
|
219
|
+
expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.awm/hooks/codex/session-start'))).toBe(true);
|
|
220
|
+
expect(fs_1.default.readdirSync(path_1.default.join(tmpHome, '.awm/hooks'))).toEqual(['codex']);
|
|
221
|
+
expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.claude/settings.json'))).toBe(false);
|
|
222
|
+
});
|
|
194
223
|
it('doctor reports the isolated Codex install as supported/healthy, not against the real ~/.awm', () => {
|
|
195
224
|
seedPublicRegistryFixture(path_1.default.join(tmpHome, '.awm/registries/baseline'));
|
|
196
225
|
writePrefs(prefsWith(['claude-code', 'codex']));
|