agentic-workflow-manager 3.2.2 → 3.3.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.
@@ -49,6 +49,7 @@ const bundles_1 = require("../core/bundles");
49
49
  const registries_1 = require("../core/registries");
50
50
  const orchestrator_1 = require("../core/init/orchestrator");
51
51
  const steps_1 = require("../core/init/steps");
52
+ const failure_1 = require("../core/init/failure");
52
53
  const mutation_targets_1 = require("../core/init/mutation-targets");
53
54
  const provider_facts_1 = require("../core/init/provider-facts");
54
55
  const install_transaction_1 = require("../core/install-transaction");
@@ -94,6 +95,35 @@ function renderInitOutcome(o) {
94
95
  lines.push(`status: ${status} · ${pendingCount} steps require an agent (skills above)`);
95
96
  return lines.join('\n');
96
97
  }
98
+ // ---------------------------------------------------------------------------
99
+ // Failure reporting
100
+ // ---------------------------------------------------------------------------
101
+ /**
102
+ * Single exit point for every failed `awm init`. Honours `--json`'s contract on
103
+ * the error path — the whole point of the flag for a headless bootstrap is to
104
+ * learn WHICH step failed — and, in human mode, renders the same evidence
105
+ * through the normal init dashboard instead of discarding it.
106
+ *
107
+ * stdout carries the machine-readable document (JSON mode) or the dashboard
108
+ * (human mode); stderr always carries the one-line summary plus the
109
+ * transaction verdict, so `2>&1`-free scripts still see a reason.
110
+ */
111
+ function reportInitFailure(o) {
112
+ const payload = (0, failure_1.buildInitFailureOutput)({
113
+ error: o.error,
114
+ outcome: o.outcome,
115
+ transaction: o.transaction,
116
+ });
117
+ if (o.json) {
118
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
119
+ }
120
+ else if (o.outcome) {
121
+ process.stdout.write(renderInitOutcome(o.outcome) + '\n');
122
+ }
123
+ process.stderr.write(`awm init: ${payload.error}\n`);
124
+ process.stderr.write(`awm init: ${payload.transaction.note}\n`);
125
+ return 2;
126
+ }
97
127
  async function runInit(opts = {}) {
98
128
  const cwd = opts.cwd ?? process.cwd();
99
129
  const agent = opts.agent === undefined ? 'claude-code' : (0, providers_1.requireAgentTarget)(opts.agent);
@@ -125,6 +155,10 @@ async function runInit(opts = {}) {
125
155
  // and would incorrectly be flagged as a violation.
126
156
  const beforeClaudeFacts = agent === 'claude-code' ? null : (0, provider_facts_1.gatherProviderFacts)('claude-code');
127
157
  let outcome;
158
+ // Populated as soon as each becomes available, so the failure reporter can
159
+ // emit whatever evidence THIS run got far enough to produce.
160
+ let pipelineOutcome;
161
+ let transaction;
128
162
  try {
129
163
  const mergedActions = {
130
164
  ...steps_1.defaultActions,
@@ -155,7 +189,11 @@ async function runInit(opts = {}) {
155
189
  (0, config_1.savePreferences)(nextPreferences);
156
190
  (0, registries_1.seedBaselineRegistry)();
157
191
  if ((0, registries_1.listRegistries)().some((r) => !fs_1.default.existsSync(r.contentRoot))) {
158
- await mergedActions.syncCache();
192
+ // `syncRegistries()` reports per-registry failures as RESULTS,
193
+ // never as throws (core/registries.ts) — swallowing them here
194
+ // let an unavailable registry degrade silently into some later
195
+ // step's failure instead of being reported as its own cause.
196
+ (0, registries_1.assertSyncedRegistriesUsable)((await mergedActions.syncCache()) ?? []);
159
197
  }
160
198
  const bundles = (0, bundles_1.discoverAllBundles)();
161
199
  const ctx = (0, context_1.gatherContext)({ cwd, bundles, agent });
@@ -177,8 +215,13 @@ async function runInit(opts = {}) {
177
215
  confirmExtensions,
178
216
  actions: mergedActions,
179
217
  });
218
+ pipelineOutcome = outcome;
180
219
  if (outcome.failed > 0) {
181
- throw new Error('one or more init steps failed');
220
+ // Typed so the outcome the only record of WHICH step failed
221
+ // and why — survives the rollback below and reaches the
222
+ // reporter. A bare Error here is what made `--json` emit
223
+ // nothing on the one path that most needed it.
224
+ throw new failure_1.InitStepsFailedError(outcome);
182
225
  }
183
226
  if (beforeClaudeFacts) {
184
227
  (0, provider_facts_1.assertClaudeBaselinePreserved)(beforeClaudeFacts, (0, provider_facts_1.gatherProviderFacts)('claude-code'));
@@ -188,21 +231,49 @@ async function runInit(opts = {}) {
188
231
  outcome.modifiedFiles = backup.targetPaths;
189
232
  }
190
233
  catch (error) {
191
- backup.rollback();
234
+ // Rollback is best-effort and must never mask the original failure
235
+ // (same policy as applyInstallPlan's own rollback loop): the
236
+ // operator needs the step that failed, not the restore that also did.
237
+ let rollbackError;
238
+ try {
239
+ backup.rollback();
240
+ }
241
+ catch (e) {
242
+ rollbackError = e instanceof Error ? e.message : String(e);
243
+ }
244
+ transaction = {
245
+ committed: false,
246
+ rolledBack: rollbackError === undefined,
247
+ transactionId: backup.transactionId,
248
+ restoredFiles: backup.targetPaths,
249
+ ...(rollbackError === undefined ? {} : { rollbackError }),
250
+ note: (0, failure_1.transactionNote)(rollbackError === undefined),
251
+ };
192
252
  throw error;
193
253
  }
194
254
  }
195
255
  catch (err) {
196
- process.stderr.write(`awm init: internal error: ${err.message}\n`);
197
- return 2;
256
+ // The typed error carries its own outcome (so it stays self-sufficient
257
+ // for any caller of runInit); `pipelineOutcome` covers the other way a
258
+ // run can fail *after* the pipeline produced one — e.g. the R19
259
+ // Claude-baseline assertion.
260
+ return reportInitFailure({
261
+ error: err,
262
+ outcome: err instanceof failure_1.InitStepsFailedError ? err.outcome : pipelineOutcome,
263
+ transaction,
264
+ json: opts.json,
265
+ });
198
266
  }
267
+ // `result` mirrors the exit code, so a consumer can branch on one field
268
+ // instead of correlating stdout with $?: ok → 0, degraded → 1, failed → 2.
269
+ const result = outcome.after.overall === 'healthy' ? 'ok' : 'degraded';
199
270
  if (opts.json) {
200
- process.stdout.write(JSON.stringify(outcome, null, 2) + '\n');
271
+ process.stdout.write(JSON.stringify({ result, ...outcome }, null, 2) + '\n');
201
272
  }
202
273
  else {
203
274
  process.stdout.write(renderInitOutcome(outcome) + '\n');
204
275
  }
205
- return outcome.after.overall === 'healthy' ? 0 : 1;
276
+ return result === 'ok' ? 0 : 1;
206
277
  }
207
278
  // ---------------------------------------------------------------------------
208
279
  // Extension confirmation factory
@@ -240,7 +311,7 @@ function registerInitCommand(program) {
240
311
  .option('-y, --yes', 'Skip confirmation prompts')
241
312
  .option('-a, --agent <agent>', 'Target agent (default: claude-code)')
242
313
  .option('--machine-only', 'Only run machine-level steps (skip project steps)')
243
- .option('--json', 'Emit the InitOutcome as JSON')
314
+ .option('--json', 'Emit the InitOutcome as JSON — on success and on failure (failed steps + rollback)')
244
315
  .action(async (options) => {
245
316
  (0, paths_1.warnIfUnsupportedPlatform)((m) => console.warn(picocolors_1.default.yellow(`⚠ ${m}`)));
246
317
  const code = await runInit({
@@ -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') {
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ // src/core/init/failure.ts
3
+ //
4
+ // Failure evidence for `awm init`.
5
+ //
6
+ // `awm init` is transactional: any failure rolls every write back. That part
7
+ // was always right — what was missing is the EVIDENCE. `runInitSteps` already
8
+ // records, per step, `{ id, action: 'failed', error }` (orchestrator.ts's
9
+ // `wrapStep`), but commands/init.ts used to collapse the whole outcome into a
10
+ // bare `new Error('one or more init steps failed')`, so `--json` — whose only
11
+ // job is to emit that outcome — printed nothing at all on the exact path an
12
+ // operator needs it: a headless cloud bootstrap that just aborted.
13
+ //
14
+ // `InitStepsFailedError` carries the outcome across the rollback boundary, and
15
+ // `buildInitFailureOutput` shapes it into the JSON envelope the CLI emits.
16
+ // The envelope is deliberately self-describing (`result`, `failedSteps`,
17
+ // `transaction`) so a bootstrap script can branch on it without re-deriving
18
+ // anything from prose on stderr.
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.InitStepsFailedError = void 0;
21
+ exports.failedSteps = failedSteps;
22
+ exports.transactionNote = transactionNote;
23
+ exports.buildInitFailureOutput = buildInitFailureOutput;
24
+ /** Only the steps that actually failed — the subset an operator needs first. */
25
+ function failedSteps(outcome) {
26
+ return outcome.steps.filter((s) => s.action === 'failed');
27
+ }
28
+ /**
29
+ * Thrown when the step pipeline ran to completion but ≥1 step failed. Carries
30
+ * the full `InitOutcome` so the rollback path can still emit it, and builds a
31
+ * message that names every failed step instead of the old generic sentence.
32
+ */
33
+ class InitStepsFailedError extends Error {
34
+ outcome;
35
+ constructor(outcome) {
36
+ const detail = failedSteps(outcome)
37
+ .map((s) => `${s.id}: ${s.error ?? 'no error recorded'}`)
38
+ .join('; ');
39
+ super(`one or more init steps failed — ${detail || 'no failed step recorded'}`);
40
+ this.name = 'InitStepsFailedError';
41
+ this.outcome = outcome;
42
+ }
43
+ }
44
+ exports.InitStepsFailedError = InitStepsFailedError;
45
+ const NO_TRANSACTION_NOTE = 'init failed before a backup session was opened — nothing was written, so there was nothing to roll back.';
46
+ /** The note explaining what a given rollback outcome means for the machine. */
47
+ function transactionNote(rolledBack) {
48
+ return rolledBack
49
+ ? 'the transaction was NOT committed: every path in restoredFiles was restored to its pre-init state. '
50
+ + '`after` reflects the state observed at the end of the step pipeline, BEFORE this rollback ran.'
51
+ : 'the transaction was NOT committed and the rollback did not complete — see rollbackError and '
52
+ + 'restore manually with `awm backup restore <transactionId>`.';
53
+ }
54
+ function buildInitFailureOutput(o) {
55
+ const steps = o.outcome?.steps ?? [];
56
+ return {
57
+ result: 'failed',
58
+ error: o.error.message,
59
+ steps,
60
+ failedSteps: steps.filter((s) => s.action === 'failed'),
61
+ applied: o.outcome?.applied ?? 0,
62
+ pending: o.outcome?.pending ?? 0,
63
+ failed: o.outcome?.failed ?? 0,
64
+ before: o.outcome?.before ?? null,
65
+ after: o.outcome?.after ?? null,
66
+ transaction: o.transaction ?? {
67
+ committed: false,
68
+ rolledBack: false,
69
+ transactionId: null,
70
+ restoredFiles: [],
71
+ note: NO_TRANSACTION_NOTE,
72
+ },
73
+ };
74
+ }
@@ -39,7 +39,7 @@ const codex_agents_1 = require("../context/strategies/codex-agents");
39
39
  // ---------------------------------------------------------------------------
40
40
  const realInjectionOrchestrator = new orchestrator_1.InjectionOrchestrator();
41
41
  exports.defaultActions = {
42
- syncCache: async () => { await (0, registries_1.syncRegistries)(); },
42
+ syncCache: async () => (0, registries_1.syncRegistries)(),
43
43
  installHook: (o) => (0, install_1.installHook)({
44
44
  agent: o.agent,
45
45
  registryRoot: o.registryRoot,
@@ -135,14 +135,30 @@ async function stepCache(d) {
135
135
  const needsSync = !registryCache.present || registryCache.gitState === 'behind';
136
136
  if (!needsSync)
137
137
  return ok('machine.cache', 'machine', 'skipped');
138
+ let results;
138
139
  try {
139
- await d.actions.syncCache();
140
- return ok('machine.cache', 'machine', 'applied');
140
+ results = (await d.actions.syncCache()) ?? [];
141
141
  }
142
142
  catch (e) {
143
143
  const msg = e instanceof Error ? e.message : String(e);
144
144
  return failed('machine.cache', 'machine', msg);
145
145
  }
146
+ // `syncRegistries()` reports per-registry failures as RESULTS, not throws
147
+ // (registries.ts), so the `try` above catches none of them. Ignoring them
148
+ // is what let an unavailable base registry degrade silently into some
149
+ // later step's failure — with its own cause already gone.
150
+ const errors = (0, registries_1.registrySyncErrors)(results);
151
+ if (errors.length === 0)
152
+ return ok('machine.cache', 'machine', 'applied');
153
+ // A registry that errored and has no content on disk is unusable, and every
154
+ // later step that reads it will fail for a reason that no longer names this
155
+ // cause — so machine.cache owns it here. One that errored but still has
156
+ // content is stale, not broken: record it and carry on.
157
+ const unusable = (0, registries_1.unusableSyncedRegistries)(results);
158
+ if (unusable.length > 0) {
159
+ return failed('machine.cache', 'machine', `registry unavailable — ${(0, registries_1.describeRegistrySyncErrors)(unusable)}`);
160
+ }
161
+ return ok('machine.cache', 'machine', 'applied', `stale registries — ${(0, registries_1.describeRegistrySyncErrors)(errors)}`);
146
162
  }
147
163
  /** Step 2 – Install the session-start hook for the target agent. */
148
164
  function stepHook(d) {
@@ -13,6 +13,10 @@ exports.contentRoots = contentRoots;
13
13
  exports.capabilityRoot = capabilityRoot;
14
14
  exports.validateRegistryLayout = validateRegistryLayout;
15
15
  exports.syncRegistries = syncRegistries;
16
+ exports.registrySyncErrors = registrySyncErrors;
17
+ exports.describeRegistrySyncErrors = describeRegistrySyncErrors;
18
+ exports.unusableSyncedRegistries = unusableSyncedRegistries;
19
+ exports.assertSyncedRegistriesUsable = assertSyncedRegistriesUsable;
16
20
  exports.readRegistryManifest = readRegistryManifest;
17
21
  exports.registryNameForPath = registryNameForPath;
18
22
  exports.verifyMinCliVersions = verifyMinCliVersions;
@@ -144,6 +148,46 @@ async function syncRegistries() {
144
148
  }
145
149
  return results;
146
150
  }
151
+ /** The errored entries of a `syncRegistries()` run, in `listRegistries()` order. */
152
+ function registrySyncErrors(results) {
153
+ return results
154
+ .filter((r) => r.action === 'error')
155
+ .map((r) => ({ name: r.name, error: r.error }));
156
+ }
157
+ /** `name: reason; name: reason` — the shape both init and stepCache report errors in. */
158
+ function describeRegistrySyncErrors(errors) {
159
+ return errors.map((e) => `${e.name}: ${e.error}`).join('; ');
160
+ }
161
+ /**
162
+ * Registries that both errored during sync AND have no content on disk
163
+ * afterwards — i.e. genuinely unusable, as opposed to merely stale.
164
+ *
165
+ * `syncRegistries()` deliberately reports per-registry failures as results
166
+ * rather than throwing, so a flaky secondary registry never aborts a whole
167
+ * run. Callers that go on to READ registry content (init) still need to know
168
+ * whether what they are about to read exists: otherwise a missing registry
169
+ * resurfaces much later as an unrelated step's failure, with its real cause
170
+ * already discarded. Note that "usable" is deliberately about content on disk,
171
+ * not about being a healthy git clone — a seeded content root with no `.git`
172
+ * fails to sync every time and is still perfectly readable.
173
+ */
174
+ function unusableSyncedRegistries(results) {
175
+ const errors = registrySyncErrors(results);
176
+ if (errors.length === 0)
177
+ return [];
178
+ const roots = new Map(listRegistries().map((r) => [r.name, r.contentRoot]));
179
+ return errors.filter((e) => {
180
+ const root = roots.get(e.name);
181
+ return root === undefined || !fs_1.default.existsSync(root);
182
+ });
183
+ }
184
+ /** `unusableSyncedRegistries` as a guard: throws naming every unusable registry. */
185
+ function assertSyncedRegistriesUsable(results) {
186
+ const unusable = unusableSyncedRegistries(results);
187
+ if (unusable.length === 0)
188
+ return;
189
+ throw new Error(`registry sync failed and left no content on disk — ${describeRegistrySyncErrors(unusable)}`);
190
+ }
147
191
  exports.REGISTRY_MANIFEST_NAME = 'awm-registry.json';
148
192
  function readRegistryManifest(root) {
149
193
  const file = path_1.default.join(root, exports.REGISTRY_MANIFEST_NAME);
@@ -91,6 +91,7 @@ describe('runInit', () => {
91
91
  const parsed = JSON.parse(written);
92
92
  expect(Array.isArray(parsed.steps)).toBe(true);
93
93
  expect(parsed.after.overall).toBe('degraded');
94
+ expect(parsed.result).toBe('degraded'); // success envelope is self-describing too
94
95
  expect(code).toBe(1);
95
96
  });
96
97
  const prefsFile = () => path_1.default.join(process.env.AWM_HOME, 'preferences.json');
@@ -291,4 +292,85 @@ describe('runInit', () => {
291
292
  // rollback must restore its exact pre-run content, not just leave it be.
292
293
  expect(JSON.parse(fs_1.default.readFileSync(settingsPath, 'utf8'))).toEqual({ pristine: true, unrelated: 'keep' });
293
294
  });
295
+ // -----------------------------------------------------------------------
296
+ // Failure evidence — `--json` must honour its contract on the ERROR path
297
+ // -----------------------------------------------------------------------
298
+ describe('failure evidence', () => {
299
+ let errSpy;
300
+ beforeEach(() => {
301
+ errSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
302
+ });
303
+ afterEach(() => errSpy.mockRestore());
304
+ const stdout = () => writeSpy.mock.calls.map((c) => c[0]).join('');
305
+ const stderr = () => errSpy.mock.calls.map((c) => c[0]).join('');
306
+ /** InitActions whose `machine.hook` step blows up inside the step pipeline. */
307
+ function actionsWithFailingHook() {
308
+ const actions = fakeActions([]);
309
+ actions.installHook = () => { throw new Error('hook boom'); };
310
+ return actions;
311
+ }
312
+ it('--json emits parseable JSON carrying the failed step id and error', async () => {
313
+ const { runInit } = require('../../src/commands/init');
314
+ const code = await runInit({
315
+ cwd: tmpHome,
316
+ yes: true,
317
+ json: true,
318
+ actions: actionsWithFailingHook(),
319
+ });
320
+ expect(code).not.toBe(0);
321
+ expect(code).toBe(2);
322
+ const parsed = JSON.parse(stdout());
323
+ expect(parsed.result).toBe('failed');
324
+ const failedStep = parsed.steps.find((s) => s.action === 'failed');
325
+ expect(failedStep.id).toBe('machine.hook');
326
+ expect(failedStep.error).toBe('hook boom');
327
+ expect(parsed.failedSteps).toEqual([failedStep]);
328
+ expect(parsed.failed).toBe(1);
329
+ // The top-level message names the step too — no generic "internal error".
330
+ expect(parsed.error).toContain('machine.hook');
331
+ expect(parsed.error).toContain('hook boom');
332
+ // `before` is the pre-run snapshot the issue asks for.
333
+ expect(parsed.before.results.length).toBeGreaterThan(0);
334
+ });
335
+ it('--json reports the transaction as not committed and rolled back', async () => {
336
+ const { runInit } = require('../../src/commands/init');
337
+ await runInit({ cwd: tmpHome, yes: true, json: true, actions: actionsWithFailingHook() });
338
+ const { transaction } = JSON.parse(stdout());
339
+ expect(transaction.committed).toBe(false);
340
+ expect(transaction.rolledBack).toBe(true);
341
+ expect(typeof transaction.transactionId).toBe('string');
342
+ expect(Array.isArray(transaction.restoredFiles)).toBe(true);
343
+ expect(transaction.note).toBeTruthy();
344
+ // The backup manifest on disk agrees: nothing was committed.
345
+ const { listBackups } = require('../../src/core/install-transaction');
346
+ const backups = listBackups();
347
+ expect(backups.length).toBeGreaterThan(0);
348
+ expect(backups.every((b) => b.committed)).toBe(false);
349
+ // …and the run really did not persist anything.
350
+ expect(fs_1.default.existsSync(prefsFile())).toBe(false);
351
+ });
352
+ it('--json still emits a failure envelope when init fails before the step pipeline', async () => {
353
+ const actions = fakeActions([]);
354
+ actions.syncCache = async () => { throw new Error('registry unreachable'); };
355
+ const { runInit } = require('../../src/commands/init');
356
+ const code = await runInit({ cwd: tmpHome, yes: true, json: true, actions });
357
+ expect(code).toBe(2);
358
+ const parsed = JSON.parse(stdout());
359
+ expect(parsed.result).toBe('failed');
360
+ expect(parsed.error).toContain('registry unreachable');
361
+ expect(parsed.steps).toEqual([]);
362
+ expect(parsed.before).toBeNull();
363
+ expect(parsed.transaction.committed).toBe(false);
364
+ });
365
+ it('human mode renders the failed outcome instead of swallowing it', async () => {
366
+ const { runInit } = require('../../src/commands/init');
367
+ const code = await runInit({ cwd: tmpHome, yes: true, actions: actionsWithFailingHook() });
368
+ expect(code).toBe(2);
369
+ expect(stdout()).toContain('AWM · init');
370
+ expect(stdout()).toContain('machine.hook');
371
+ expect(stdout()).toContain('hook boom');
372
+ expect(stderr()).toContain('machine.hook');
373
+ expect(stderr()).toContain('hook boom');
374
+ });
375
+ });
294
376
  });
@@ -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
  });
@@ -0,0 +1,109 @@
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
+ // stepCache vs. syncRegistries()'s RESULT-shaped failures.
7
+ //
8
+ // `syncRegistries()` never throws on a per-registry failure — it returns
9
+ // `{ action: 'error', error }` for that registry and keeps going. stepCache
10
+ // used to `await` it and report a blanket 'applied', so a registry that never
11
+ // landed on disk degraded silently into some later step's failure with its own
12
+ // cause already gone.
13
+ //
14
+ // Isolated in its own file (not steps.test.ts) because these assertions reach
15
+ // `listRegistries()`, which resolves AWM_HOME at module require time — the env
16
+ // override therefore has to happen before the module is required, which means
17
+ // `jest.resetModules()` + `require`, not a static import.
18
+ const fs_1 = __importDefault(require("fs"));
19
+ const os_1 = __importDefault(require("os"));
20
+ const path_1 = __importDefault(require("path"));
21
+ describe('stepCache — registry sync error results', () => {
22
+ let tmpHome;
23
+ let originalHome;
24
+ let originalAwmHome;
25
+ beforeEach(() => {
26
+ tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-stepcache-'));
27
+ originalHome = process.env.HOME;
28
+ originalAwmHome = process.env.AWM_HOME;
29
+ process.env.HOME = tmpHome;
30
+ process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
31
+ jest.resetModules();
32
+ });
33
+ afterEach(() => {
34
+ fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
35
+ if (originalHome === undefined)
36
+ delete process.env.HOME;
37
+ else
38
+ process.env.HOME = originalHome;
39
+ if (originalAwmHome === undefined)
40
+ delete process.env.AWM_HOME;
41
+ else
42
+ process.env.AWM_HOME = originalAwmHome;
43
+ });
44
+ const awmHome = () => process.env.AWM_HOME;
45
+ /** Declares `names` in registries.json; creates a content root only for `withContent`. */
46
+ function configureRegistries(names, withContent) {
47
+ fs_1.default.mkdirSync(awmHome(), { recursive: true });
48
+ fs_1.default.writeFileSync(path_1.default.join(awmHome(), 'registries.json'), JSON.stringify(names.map((name) => ({ name, remote: `https://example.com/${name}.git` })), null, 2));
49
+ for (const name of withContent) {
50
+ const skills = path_1.default.join(awmHome(), 'registries', name, 'skills');
51
+ fs_1.default.mkdirSync(skills, { recursive: true });
52
+ fs_1.default.writeFileSync(path_1.default.join(skills, 'placeholder.md'), '# placeholder\n');
53
+ }
54
+ }
55
+ /** InitDeps just complete enough for stepCache: a machine with no registry cache yet. */
56
+ function deps(results) {
57
+ const ctx = {
58
+ machine: {
59
+ registryCache: { present: false },
60
+ hook: { present: true, degraded: false },
61
+ devCore: { present: true, brokenLinks: [] },
62
+ ambient: { wanted: [], installed: [] },
63
+ contextInjection: [],
64
+ globalSkills: { valid: [], repairable: [], dead: [] },
65
+ },
66
+ project: null,
67
+ };
68
+ const actions = { syncCache: async () => results };
69
+ return {
70
+ cwd: tmpHome, ctx, bundles: [], agent: 'claude-code', enabledAgents: ['claude-code'],
71
+ installMethod: 'symlink', registryRoot: '', contentDir: '', sensorPacksRoot: '',
72
+ confirmExtensions: async (p) => p, actions,
73
+ };
74
+ }
75
+ async function run(results) {
76
+ const { stepCache } = require('../../../src/core/init/steps');
77
+ return stepCache(deps(results));
78
+ }
79
+ it('fails, naming the registry, when a sync error left no content on disk', async () => {
80
+ configureRegistries(['baseline'], []);
81
+ const r = await run([{ name: 'baseline', action: 'error', error: 'could not clone' }]);
82
+ expect(r.action).toBe('failed');
83
+ expect(r.error).toContain('baseline');
84
+ expect(r.error).toContain('could not clone');
85
+ });
86
+ it('stays applied but records the error when content is already on disk', async () => {
87
+ configureRegistries(['baseline'], ['baseline']);
88
+ const r = await run([{ name: 'baseline', action: 'error', error: 'pull timed out' }]);
89
+ expect(r.action).toBe('applied');
90
+ expect(r.detail).toContain('baseline');
91
+ expect(r.detail).toContain('pull timed out');
92
+ });
93
+ it('fails on a secondary registry that never landed, even when the base one synced', async () => {
94
+ configureRegistries(['baseline', 'documentation'], ['baseline']);
95
+ const r = await run([
96
+ { name: 'baseline', action: 'pulled', version: 'v1.0.0' },
97
+ { name: 'documentation', action: 'error', error: 'host unreachable' },
98
+ ]);
99
+ expect(r.action).toBe('failed');
100
+ expect(r.error).toContain('documentation');
101
+ expect(r.error).not.toContain('baseline');
102
+ });
103
+ it('reports applied with no detail when every registry synced', async () => {
104
+ configureRegistries(['baseline'], ['baseline']);
105
+ const r = await run([{ name: 'baseline', action: 'pulled', version: 'v1.0.0' }]);
106
+ expect(r.action).toBe('applied');
107
+ expect(r.detail).toBeUndefined();
108
+ });
109
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-workflow-manager",
3
- "version": "3.2.2",
3
+ "version": "3.3.1",
4
4
  "main": "dist/src/index.js",
5
5
  "bin": {
6
6
  "awm": "./dist/src/index.js"