agentic-workflow-manager 3.2.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.
@@ -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
  }
@@ -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.1",
4
4
  "main": "dist/src/index.js",
5
5
  "bin": {
6
6
  "awm": "./dist/src/index.js"