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.
@@ -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 'absent';
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
- return `dir:${hashDirectoryTree(target, exclude)}`;
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']));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-workflow-manager",
3
- "version": "3.2.0",
3
+ "version": "3.2.2",
4
4
  "main": "dist/src/index.js",
5
5
  "bin": {
6
6
  "awm": "./dist/src/index.js"