agentic-workflow-manager 3.2.1 → 3.3.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.
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.applyBaseline = applyBaseline;
6
7
  exports.reconcilePack = reconcilePack;
7
8
  exports.findManifestDir = findManifestDir;
8
9
  exports.runSensors = runSensors;
@@ -26,11 +27,13 @@ const DEFAULT_SLOW_TIMEOUT = 120_000;
26
27
  const MAX_BUFFER = 64 * 1024 * 1024;
27
28
  /**
28
29
  * Apply the baseline to a sensor result: keep only findings not already accepted.
29
- * `status` becomes 'pass' when every finding was baseline-suppressed. Skipped
30
- * sensors are returned untouched.
30
+ * `status` becomes 'pass' when every finding was baseline-suppressed. Results
31
+ * without a verdict of their own — skipped and inconclusive — are returned
32
+ * untouched: there is nothing to ratchet, and letting them through here would
33
+ * hand back a `pass` for a sensor that never reported anything.
31
34
  */
32
35
  function applyBaseline(result, accepted) {
33
- if (result.status === 'skipped')
36
+ if (result.status === 'skipped' || result.status === 'inconclusive')
34
37
  return result;
35
38
  const { newErrors, suppressed } = (0, baseline_1.partition)(result.name, result.errors, accepted);
36
39
  if (suppressed === 0)
@@ -131,12 +134,14 @@ function runSensor(name, cmd, timeout, cwd) {
131
134
  catch (err) {
132
135
  // Output exceeded maxBuffer — child is killed before output can be read.
133
136
  // Check this BEFORE the SIGTERM branch (ENOBUFS kills with SIGTERM too).
137
+ // Nothing could be read, so nothing was certified.
134
138
  if (err.code === 'ENOBUFS') {
135
- return { name, status: 'skipped', errors: [], skipReason: `output exceeded ${MAX_BUFFER} bytes` };
139
+ return { name, status: 'inconclusive', errors: [], skipReason: `output exceeded ${MAX_BUFFER} bytes` };
136
140
  }
137
- // Genuine timeout: execSync kills with SIGTERM after `timeout` ms.
141
+ // Genuine timeout: execSync kills with SIGTERM after `timeout` ms. The
142
+ // sensor produced no verdict — inconclusive, not a benign skip.
138
143
  if (err.code === 'ETIMEDOUT' || (err.killed && err.signal === 'SIGTERM')) {
139
- return { name, status: 'skipped', errors: [], skipReason: `timeout after ${timeout}ms` };
144
+ return { name, status: 'inconclusive', errors: [], skipReason: `timeout after ${timeout}ms` };
140
145
  }
141
146
  // Non-zero exit — the normal path for linters/typecheckers that found
142
147
  // findings. Parse the output; if it yields findings, that's a fail.
@@ -146,9 +151,28 @@ function runSensor(name, cmd, timeout, cwd) {
146
151
  return { name, status: 'fail', errors };
147
152
  // A missing tool (binary not installed) must NOT pass silently — the gate
148
153
  // cannot certify what it could not run. Treat it as a fail with a clear message.
154
+ //
155
+ // Exit 127 is the POSIX signal for "command not found" and is the only check
156
+ // here that holds across shells and locales: bash writes `command not found`
157
+ // but dash — `/bin/sh` on Debian/Ubuntu, hence most CI runners and containers
158
+ // — writes `not found`, so matching shell text alone read an absent tool as a
159
+ // benign skip. `err.code` does not cover it either: that is ENOENT only when
160
+ // spawning the shell itself fails, not when the shell starts and the command
161
+ // inside it is missing. The ENOBUFS and timeout branches are evaluated above,
162
+ // so reaching here with status 127 means the command did not exist.
163
+ //
164
+ // A wrapper (`npm test`, `npx …`) that exits 127 because a binary it invokes
165
+ // is absent is classified the same way, deliberately: the gate still ran
166
+ // nothing and still cannot certify anything.
149
167
  const lower = raw.toLowerCase();
150
- const toolMissing = err.code === 'ENOENT' || // execSync spawn failure (no shell)
151
- lower.includes('command not found') ||
168
+ const toolMissing = err.status === 127 || // POSIX: command not found
169
+ err.code === 'ENOENT' || // execSync spawn failure (no shell)
170
+ lower.includes('command not found') || // bash, zsh
171
+ // cmd.exe reports an absent binary with exit 1, so 127 does not cover
172
+ // Windows; this exact phrase does. Kept narrow on purpose — a loose
173
+ // `not found` would also match a tool that ran and said "not found"
174
+ // for reasons of its own.
175
+ lower.includes('is not recognized as an internal or external command') ||
152
176
  lower.includes('enoent') ||
153
177
  lower.includes('could not determine executable');
154
178
  if (toolMissing) {
@@ -163,7 +187,10 @@ function runSensor(name, cmd, timeout, cwd) {
163
187
  if (isExitCodeSensor(name)) {
164
188
  return { name, status: 'fail', errors: [{ message: `SENSOR[${name}] failed (exit ${err.status})` }] };
165
189
  }
166
- return { name, status: 'skipped', errors: [], skipReason: `exit ${err.status}: ${raw.slice(0, 200)}` };
190
+ // Residual case: it exited non-zero, the tool exists, and no finding
191
+ // could be parsed. We do not know what happened — say so instead of
192
+ // reporting a benign skip.
193
+ return { name, status: 'inconclusive', errors: [], skipReason: `exit ${err.status}: ${raw.slice(0, 200)}` };
167
194
  }
168
195
  }
169
196
  function runSensors(opts = {}) {
@@ -191,7 +218,9 @@ function runSensors(opts = {}) {
191
218
  continue;
192
219
  }
193
220
  if (!config.cmd) {
194
- results.push({ name, status: 'skipped', errors: [], skipReason: 'no cmd configured' });
221
+ // Enabled but with nothing to run: broken config, not a deliberate
222
+ // opt-out. `enabled: false` is how a sensor is turned off.
223
+ results.push({ name, status: 'inconclusive', errors: [], skipReason: 'no cmd configured' });
195
224
  continue;
196
225
  }
197
226
  const timeout = config.timeout ?? (isFast ? DEFAULT_FAST_TIMEOUT : DEFAULT_SLOW_TIMEOUT);
@@ -200,10 +229,13 @@ function runSensors(opts = {}) {
200
229
  result = applyBaseline(result, baseline[name]);
201
230
  results.push(result);
202
231
  }
232
+ // `fail` outranks `inconclusive`: when something is broken AND something
233
+ // could not be measured, the broken thing is the actionable verdict.
203
234
  let overall = results.some(r => r.status === 'fail') ? 'fail'
204
- : results.length > 0 && results.every(r => r.status === 'skipped') ? 'skipped'
205
- : results.length === 0 ? 'skipped'
206
- : 'pass';
235
+ : results.some(r => r.status === 'inconclusive') ? 'not_certified'
236
+ : results.length > 0 && results.every(r => r.status === 'skipped') ? 'skipped'
237
+ : results.length === 0 ? 'skipped'
238
+ : 'pass';
207
239
  // Honest floor: a benign-green 'skipped' over a tree that clearly HAS a stack
208
240
  // (indicators present) is a false green — the gate ran nothing real. Never green.
209
241
  if (overall === 'skipped' && reconciled.detection.pack !== 'generic') {
@@ -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,
@@ -0,0 +1,237 @@
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 mockExecSyncFn = jest.fn();
10
+ jest.mock('child_process', () => ({
11
+ execSync: (...args) => mockExecSyncFn(...args),
12
+ }));
13
+ /** Sensors run in manifest insertion order, so mocks are queued in that order. */
14
+ const MANIFEST = {
15
+ pack: 'js-ts',
16
+ sensors: {
17
+ typecheck: { cmd: 'npx tsc --noEmit', fast: true },
18
+ security: { cmd: 'semgrep .', fast: false },
19
+ },
20
+ };
21
+ const timeoutError = () => { throw Object.assign(new Error('killed'), { code: 'ETIMEDOUT' }); };
22
+ describe('runSensors — inconclusive: a sensor that could not certify is never green', () => {
23
+ let root;
24
+ let fakeAwmHome;
25
+ let prevAwmHome;
26
+ beforeEach(() => {
27
+ jest.resetModules();
28
+ mockExecSyncFn.mockReset();
29
+ root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-inconclusive-'));
30
+ fs_1.default.mkdirSync(path_1.default.join(root, '.awm'), { recursive: true });
31
+ fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'sensors.json'), JSON.stringify(MANIFEST));
32
+ // CLAUDE.md: no test may reach the real ~/.awm.
33
+ fakeAwmHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-home-'));
34
+ prevAwmHome = process.env.AWM_HOME;
35
+ process.env.AWM_HOME = fakeAwmHome;
36
+ });
37
+ afterEach(() => {
38
+ process.env.AWM_HOME = prevAwmHome;
39
+ fs_1.default.rmSync(root, { recursive: true, force: true });
40
+ fs_1.default.rmSync(fakeAwmHome, { recursive: true, force: true });
41
+ });
42
+ const load = () => require('../../../src/commands/sensors/run');
43
+ it('reports a timed-out sensor as inconclusive, keeping its reason', () => {
44
+ mockExecSyncFn
45
+ .mockReturnValueOnce('') // typecheck: clean
46
+ .mockImplementationOnce(timeoutError); // security: times out
47
+ const { runSensors } = load();
48
+ const out = runSensors({ cwd: root });
49
+ const security = out.sensors.find((s) => s.name === 'security');
50
+ expect(security.status).toBe('inconclusive');
51
+ expect(security.skipReason).toMatch(/timeout/);
52
+ });
53
+ it('does not let a healthy sensor carry the run to pass while another could not certify', () => {
54
+ mockExecSyncFn
55
+ .mockReturnValueOnce('')
56
+ .mockImplementationOnce(timeoutError);
57
+ const { runSensors } = load();
58
+ const out = runSensors({ cwd: root });
59
+ expect(out.sensors.find((s) => s.name === 'typecheck').status).toBe('pass');
60
+ expect(out.overall).toBe('not_certified');
61
+ });
62
+ it('reports a sensor whose output was truncated as inconclusive', () => {
63
+ mockExecSyncFn
64
+ .mockReturnValueOnce('')
65
+ .mockImplementationOnce(() => { throw Object.assign(new Error('too big'), { code: 'ENOBUFS' }); });
66
+ const { runSensors } = load();
67
+ const out = runSensors({ cwd: root });
68
+ const security = out.sensors.find((s) => s.name === 'security');
69
+ expect(security.status).toBe('inconclusive');
70
+ expect(security.skipReason).toMatch(/exceeded/);
71
+ expect(out.overall).toBe('not_certified');
72
+ });
73
+ it('reports an uninterpretable non-zero exit as inconclusive', () => {
74
+ mockExecSyncFn
75
+ .mockReturnValueOnce('')
76
+ .mockImplementationOnce(() => {
77
+ // semgrep formatter yields no findings for non-JSON output, the
78
+ // tool is present (exit 2, not 127), and `security` is not an
79
+ // exit-code sensor — the residual "I don't know" case.
80
+ throw Object.assign(new Error('failed'), {
81
+ stdout: '', stderr: 'internal error: rule engine crashed\n', status: 2,
82
+ });
83
+ });
84
+ const { runSensors } = load();
85
+ const out = runSensors({ cwd: root });
86
+ const security = out.sensors.find((s) => s.name === 'security');
87
+ expect(security.status).toBe('inconclusive');
88
+ expect(security.skipReason).toMatch(/exit 2/);
89
+ expect(out.overall).toBe('not_certified');
90
+ });
91
+ it('reports an enabled sensor with no cmd as inconclusive', () => {
92
+ fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'sensors.json'), JSON.stringify({
93
+ pack: 'js-ts',
94
+ sensors: {
95
+ typecheck: { cmd: 'npx tsc --noEmit', fast: true },
96
+ depcheck: { fast: false }, // enabled, but nothing to run
97
+ },
98
+ }));
99
+ mockExecSyncFn.mockReturnValueOnce(''); // typecheck: clean
100
+ const { runSensors } = load();
101
+ const out = runSensors({ cwd: root });
102
+ const depcheck = out.sensors.find((s) => s.name === 'depcheck');
103
+ expect(depcheck.status).toBe('inconclusive');
104
+ expect(depcheck.skipReason).toBe('no cmd configured');
105
+ expect(out.overall).toBe('not_certified');
106
+ });
107
+ it('reports fail, not not_certified, when something is broken and something could not run', () => {
108
+ mockExecSyncFn
109
+ .mockImplementationOnce(() => {
110
+ throw Object.assign(new Error(), {
111
+ stdout: 'src/a.ts(1,1): error TS0001: Bad type.', stderr: '', status: 1,
112
+ });
113
+ })
114
+ .mockImplementationOnce(timeoutError); // security: times out
115
+ const { runSensors } = load();
116
+ const out = runSensors({ cwd: root });
117
+ expect(out.sensors.find((s) => s.name === 'typecheck').status).toBe('fail');
118
+ expect(out.sensors.find((s) => s.name === 'security').status).toBe('inconclusive');
119
+ expect(out.overall).toBe('fail');
120
+ });
121
+ it('never emits an overall value outside the published domain', () => {
122
+ // `inconclusive` is a per-sensor status only. External consumers (the
123
+ // registry skills) read `overall`, whose domain must not grow — this
124
+ // pins that invariant at runtime on a three-sensor pass+fail+inconclusive
125
+ // mix, a combination neither R8 (pass+inconclusive) nor R9
126
+ // (fail+inconclusive) exercises. R9's own assertion already catches a
127
+ // fail/inconclusive precedence regression specifically; what this test
128
+ // adds is runtime coverage of the domain claim itself, on a fixture
129
+ // neither of those covers — not independent detection of every
130
+ // aggregation mutation.
131
+ fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'sensors.json'), JSON.stringify({
132
+ pack: 'js-ts',
133
+ sensors: {
134
+ typecheck: { cmd: 'npx tsc --noEmit', fast: true },
135
+ lint: { cmd: 'npx eslint . --format json', fast: true },
136
+ security: { cmd: 'semgrep .', fast: false },
137
+ },
138
+ }));
139
+ const DOMAIN = ['pass', 'fail', 'skipped', 'not_certified'];
140
+ mockExecSyncFn
141
+ .mockImplementationOnce(() => {
142
+ throw Object.assign(new Error(), {
143
+ stdout: 'src/a.ts(1,1): error TS0001: Bad type.', stderr: '', status: 1,
144
+ });
145
+ })
146
+ .mockReturnValueOnce('') // lint: clean → pass
147
+ .mockImplementationOnce(timeoutError); // security: times out → inconclusive
148
+ const { runSensors } = load();
149
+ const out = runSensors({ cwd: root });
150
+ expect(DOMAIN).toContain(out.overall);
151
+ expect(out.overall).not.toBe('inconclusive');
152
+ });
153
+ it('keeps a deliberately disabled sensor apart from one that could not certify', () => {
154
+ fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'sensors.json'), JSON.stringify({
155
+ pack: 'js-ts',
156
+ sensors: {
157
+ security: { cmd: 'semgrep .', fast: false },
158
+ mutation: { cmd: 'npx stryker run', enabled: false },
159
+ },
160
+ }));
161
+ mockExecSyncFn.mockImplementationOnce(timeoutError); // security: times out
162
+ // mutation: never invoked
163
+ const { runSensors } = load();
164
+ const out = runSensors({ cwd: root });
165
+ // Same run, two different meanings — the whole point of the split.
166
+ expect(out.sensors.find((s) => s.name === 'mutation').status).toBe('skipped');
167
+ expect(out.sensors.find((s) => s.name === 'mutation').skipReason).toBe('disabled');
168
+ expect(out.sensors.find((s) => s.name === 'security').status).toBe('inconclusive');
169
+ expect(out.overall).toBe('not_certified');
170
+ });
171
+ it('does not degrade the verdict for a disabled sensor alongside healthy ones', () => {
172
+ fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'sensors.json'), JSON.stringify({
173
+ pack: 'js-ts',
174
+ sensors: {
175
+ typecheck: { cmd: 'npx tsc --noEmit', fast: true },
176
+ mutation: { cmd: 'npx stryker run', enabled: false },
177
+ },
178
+ }));
179
+ mockExecSyncFn.mockReturnValueOnce('');
180
+ const { runSensors } = load();
181
+ const out = runSensors({ cwd: root });
182
+ expect(out.overall).toBe('pass');
183
+ });
184
+ it('still refuses to certify a tree whose sensors are all disabled', () => {
185
+ fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'sensors.json'), JSON.stringify({
186
+ pack: 'js-ts',
187
+ sensors: {
188
+ typecheck: { cmd: 'npx tsc --noEmit', enabled: false },
189
+ security: { cmd: 'semgrep .', enabled: false },
190
+ },
191
+ }));
192
+ fs_1.default.writeFileSync(path_1.default.join(root, 'package.json'), '{}'); // real stack indicator
193
+ const { runSensors } = load();
194
+ const out = runSensors({ cwd: root });
195
+ expect(out.sensors.every((s) => s.status === 'skipped')).toBe(true);
196
+ expect(out.overall).toBe('not_certified');
197
+ expect(mockExecSyncFn).not.toHaveBeenCalled();
198
+ });
199
+ it('leaves an inconclusive result untouched when a baseline is applied', () => {
200
+ const { writeBaseline } = require('../../../src/commands/sensors/baseline');
201
+ writeBaseline(root, { security: ['some-accepted-fingerprint'] });
202
+ mockExecSyncFn
203
+ .mockReturnValueOnce('')
204
+ .mockImplementationOnce(timeoutError);
205
+ const { runSensors } = load();
206
+ const out = runSensors({ cwd: root });
207
+ const security = out.sensors.find((s) => s.name === 'security');
208
+ expect(security.status).toBe('inconclusive');
209
+ expect(security.baselineCount).toBeUndefined();
210
+ expect(out.overall).toBe('not_certified');
211
+ });
212
+ it('applyBaseline leaves an inconclusive result untouched even if it somehow carried findings', () => {
213
+ // Every current `inconclusive` producer sets `errors: []`, so a test built
214
+ // on the public `runSensors()` API can't tell "the explicit guard fired"
215
+ // apart from "fell through to partition() and incidentally suppressed 0
216
+ // findings." This unit-tests applyBaseline directly, with a hand-built
217
+ // result that has `errors` populated, to prove the guard itself — not an
218
+ // accidental empty-array interaction — is what keeps inconclusive inert.
219
+ const { applyBaseline } = load();
220
+ const { buildBaseline } = require('../../../src/commands/sensors/baseline');
221
+ const result = {
222
+ name: 'security',
223
+ status: 'inconclusive',
224
+ errors: [{ message: 'hypothetical finding that should never be ratcheted', rule: 'some-rule', file: 'src/x.ts' }],
225
+ skipReason: 'timeout after 10000ms',
226
+ };
227
+ // Build a baseline that partition() WOULD genuinely match/suppress for
228
+ // this exact finding, so the old code (without the inconclusive guard)
229
+ // would have mutated the result — proving the new guard, not an
230
+ // incidental "suppressed === 0", is what keeps it untouched.
231
+ const accepted = buildBaseline([{ name: result.name, errors: result.errors }])[result.name];
232
+ const out = applyBaseline(result, accepted);
233
+ expect(out).toBe(result);
234
+ expect(out.status).toBe('inconclusive');
235
+ expect(out.baselineCount).toBeUndefined();
236
+ });
237
+ });
@@ -0,0 +1,69 @@
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 run_1 = require("../../../src/commands/sensors/run");
10
+ /**
11
+ * These tests deliberately do NOT mock `child_process`: the defect they pin only
12
+ * exists against a real shell. On Debian/Ubuntu `/bin/sh` is dash, which writes
13
+ * `not found` — not bash's `command not found` — so a string-matching heuristic
14
+ * reads a missing binary as a benign skip. What is being fixed here is the
15
+ * invariant "a tool that could not run is never green", NOT the wording of any
16
+ * particular shell, so no assertion below matches shell text.
17
+ *
18
+ * The sensor is named `security` on purpose. Sensors with a structured formatter
19
+ * (semgrep/tsc/eslint) return zero findings for unparseable shell noise and fall
20
+ * through to the tool-missing branch; a sensor with the generic formatter turns
21
+ * any stderr into a finding and never reaches it.
22
+ */
23
+ const MISSING_BIN = 'awm-nonexistent-binary-xyz';
24
+ function mkProject(sensors) {
25
+ const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-tool-missing-'));
26
+ fs_1.default.mkdirSync(path_1.default.join(root, '.awm'));
27
+ fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'sensors.json'), JSON.stringify({ pack: 'js-ts', sensors }));
28
+ return root;
29
+ }
30
+ describe('runSensors — an absent tool never reads as green (real /bin/sh)', () => {
31
+ const roots = [];
32
+ afterAll(() => { for (const r of roots)
33
+ fs_1.default.rmSync(r, { recursive: true, force: true }); });
34
+ const project = (sensors) => {
35
+ const root = mkProject(sensors);
36
+ roots.push(root);
37
+ return root;
38
+ };
39
+ it('marks a sensor whose binary is absent as fail, not skipped', () => {
40
+ const root = project({ security: { cmd: `${MISSING_BIN} .`, fast: true } });
41
+ const out = (0, run_1.runSensors)({ cwd: root });
42
+ const security = out.sensors.find(s => s.name === 'security');
43
+ expect(security.status).toBe('fail');
44
+ expect(security.errors[0].message).toMatch(/not available/i);
45
+ });
46
+ it('does not let a healthy sensor carry the run to pass while another tool is absent', () => {
47
+ const root = project({
48
+ typecheck: { cmd: 'node -e ""', fast: true },
49
+ security: { cmd: `${MISSING_BIN} .`, fast: true },
50
+ });
51
+ const out = (0, run_1.runSensors)({ cwd: root });
52
+ expect(out.sensors.find(s => s.name === 'typecheck').status).toBe('pass');
53
+ expect(out.overall).toBe('fail');
54
+ });
55
+ it('does not misread a tool that ran and merely printed "not found" as an absent tool', () => {
56
+ // Exits 1, not 127: the binary existed and reported something of its own.
57
+ // Classifying this as a missing tool would be a false accusation. It also
58
+ // must not read as a benign 'skipped': the formatter parsed no findings
59
+ // from a genuine non-zero exit, which is the residual "I don't know" case
60
+ // (Task 3) — 'inconclusive', not 'fail' and not 'skipped'.
61
+ const root = project({
62
+ security: { cmd: `node -e "console.error('rule pack not found'); process.exit(1)"`, fast: true },
63
+ });
64
+ const out = (0, run_1.runSensors)({ cwd: root });
65
+ const security = out.sensors.find(s => s.name === 'security');
66
+ expect(security.status).toBe('inconclusive');
67
+ expect(security.errors).toEqual([]);
68
+ });
69
+ });
@@ -68,13 +68,13 @@ describe('runSensors', () => {
68
68
  expect(tc.status).toBe('fail');
69
69
  expect(tc.errors[0].message).toMatch('SENSOR[typecheck]');
70
70
  });
71
- it('marks sensor as skipped on timeout', () => {
71
+ it('marks sensor as inconclusive on timeout', () => {
72
72
  mockExecSyncFn.mockImplementationOnce(() => { throw Object.assign(new Error('killed'), { code: 'ETIMEDOUT' }); });
73
73
  mockExecSyncFn.mockReturnValueOnce('');
74
74
  const { runSensors } = load();
75
75
  const result = runSensors({ fast: true, cwd: tmpDir });
76
76
  const tc = result.sensors.find((s) => s.name === 'typecheck');
77
- expect(tc.status).toBe('skipped');
77
+ expect(tc.status).toBe('inconclusive');
78
78
  expect(tc.skipReason).toMatch('timeout');
79
79
  });
80
80
  it('skips disabled sensors', () => {
@@ -134,26 +134,34 @@ describe('runSensors — missing tool is a fail, not a skip', () => {
134
134
  });
135
135
  beforeEach(() => {
136
136
  mockExecSyncFn.mockReset();
137
- // Simulate shell exit 127 "command not found" matches what Node's execSync
138
- // captures in err.stderr when the binary does not exist on PATH.
137
+ // What Node's execSync actually throws when the binary is absent and `/bin/sh`
138
+ // is dash (Debian/Ubuntu, hence most CI runners): status 127, `not found`
139
+ // rather than bash's `command not found`, and no `code` — ENOENT is set only
140
+ // when spawning the shell itself fails, not the command inside it.
139
141
  mockExecSyncFn.mockImplementation(() => {
140
- throw Object.assign(new Error('Command failed: awm-nonexistent-binary-xyz --check'), {
142
+ throw Object.assign(new Error('Command failed: awm-nonexistent-binary-xyz .'), {
141
143
  stdout: '',
142
- stderr: '/bin/sh: awm-nonexistent-binary-xyz: command not found\n',
144
+ stderr: '/bin/sh: 1: awm-nonexistent-binary-xyz: not found\n',
143
145
  status: 127,
144
146
  });
145
147
  });
146
148
  });
149
+ // The sensor is named `security` so it uses the semgrep formatter, which returns
150
+ // zero findings for unparseable shell noise and lets execution reach the
151
+ // tool-missing branch. Under the generic formatter any stderr becomes a finding,
152
+ // so a sensor named `ghost` would report `fail` without that branch ever running —
153
+ // green for a reason unrelated to what this test claims to cover.
147
154
  it('marks a sensor whose binary is missing as fail', () => {
148
155
  root = mkTmp();
149
156
  fs_1.default.mkdirSync(path_1.default.join(root, '.awm'));
150
157
  fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'sensors.json'), JSON.stringify({
151
- pack: 'test',
152
- sensors: { ghost: { cmd: 'awm-nonexistent-binary-xyz --check', fast: true } },
158
+ pack: 'js-ts',
159
+ sensors: { security: { cmd: 'awm-nonexistent-binary-xyz .', fast: true } },
153
160
  }));
154
161
  const out = (0, run_1.runSensors)({ cwd: root });
155
- const ghost = out.sensors.find((s) => s.name === 'ghost');
156
- expect(ghost?.status).toBe('fail');
162
+ const security = out.sensors.find((s) => s.name === 'security');
163
+ expect(security?.status).toBe('fail');
164
+ expect(security?.errors[0].message).toMatch(/not available/i);
157
165
  expect(out.overall).toBe('fail');
158
166
  });
159
167
  });
@@ -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 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-workflow-manager",
3
- "version": "3.2.1",
3
+ "version": "3.3.0",
4
4
  "main": "dist/src/index.js",
5
5
  "bin": {
6
6
  "awm": "./dist/src/index.js"