agentic-workflow-manager 9.11.1 → 9.11.3

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.
@@ -156,7 +156,6 @@ function spliceGenerated(markdown, generated) {
156
156
  exports.DOC_PATH = path_1.default.join(__dirname, '..', '..', 'docs', 'support-matrix.md');
157
157
  /* istanbul ignore next — entrypoint de CLI, ejercitado por el test via las funciones puras */
158
158
  if (require.main === module) {
159
- // eslint-disable-next-line @typescript-eslint/no-var-requires
160
159
  const fs = require('fs');
161
160
  const current = fs.readFileSync(exports.DOC_PATH, 'utf-8');
162
161
  fs.writeFileSync(exports.DOC_PATH, spliceGenerated(current, renderProviderTables()), 'utf-8');
@@ -155,11 +155,18 @@ function checkManifest(status) {
155
155
  remedy: `registry has no pack for '${manifest.pack}': run \`awm update\` or add a registry that has it`,
156
156
  };
157
157
  }
158
+ // An applicable sensor the project cannot initialize is a real gap, so it is
159
+ // named here rather than left as an absence. It does not flip `ok`: whether an
160
+ // uninitialized sensor should block a handoff is a gate-policy decision, and
161
+ // flipping it would restore the dead end #172 is about — a project whose only
162
+ // gap is one missing tool could then configure sensors and still never pass.
163
+ const uninitialized = Object.keys(status.uninitialized ?? {});
164
+ const gap = uninitialized.length === 0 ? '' : `; ${uninitialized.length} applicable sensor(s) not initialized: ${uninitialized.join(', ')}`;
158
165
  return {
159
166
  id: 'manifest',
160
167
  ok: true,
161
- detail: enabled === 0 ? `pack ${manifest.pack}, all ${total} sensors disabled (deliberate opt-out)`
162
- : `pack ${manifest.pack}, ${enabled}/${total} sensors enabled`,
168
+ detail: (enabled === 0 ? `pack ${manifest.pack}, all ${total} sensors disabled (deliberate opt-out)`
169
+ : `pack ${manifest.pack}, ${enabled}/${total} sensors enabled`) + gap,
163
170
  };
164
171
  }
165
172
  /** Every enabled sensor's command must resolve. This is the check nothing was calling. */
@@ -20,8 +20,8 @@ const safe_file_1 = require("./compatibility/safe-file");
20
20
  const fs_1 = __importDefault(require("fs"));
21
21
  const crypto_1 = require("crypto");
22
22
  const path_1 = __importDefault(require("path"));
23
- function blocked(projectRoot, manifestPath, dryRun, reason, remedy, candidates) {
24
- return { kind: 'blocked', projectRoot, manifestPath, changes: [], dryRun, reason, remedy, ...(candidates ? { candidates } : {}) };
23
+ function blocked(projectRoot, manifestPath, dryRun, reason, remedy, candidates, unresolved) {
24
+ return { kind: 'blocked', projectRoot, manifestPath, changes: [], dryRun, reason, remedy, ...(candidates ? { candidates } : {}), ...(unresolved && unresolved.length > 0 ? { unresolved } : {}) };
25
25
  }
26
26
  function options(input) {
27
27
  if (input === undefined)
@@ -84,21 +84,40 @@ async function projectSensors(projectRoot, registryRoot, requestedPack, packageR
84
84
  return { reason: 'compatibility-unresolvable', remedy: 'repair-project-tools-or-select-another-mode' };
85
85
  }
86
86
  const sensors = {};
87
+ const unresolved = [];
88
+ let applicable = 0;
87
89
  for (const [name, packSensor] of Object.entries(live.pack.sensors)) {
88
90
  const evidence = live.sensors[name];
89
91
  if (evidence.state === 'not-applicable')
90
92
  continue;
93
+ applicable += 1;
91
94
  const variant = evidence.variantId === null ? undefined : packSensor.variants.find(item => item.id === evidence.variantId);
92
- if (!variant)
93
- return { reason: 'sensor-variant-unresolvable', remedy: 'repair-project-tools-or-select-another-mode' };
95
+ // One applicable sensor whose tool is absent used to abort the whole
96
+ // manifest, including the sensors that DID resolve, so a project whose
97
+ // only gap was (say) semgrep could not configure sensors at all — and so
98
+ // could not pass preflight, and so could not be handed off. A missing tool
99
+ // is a gap in one sensor, not grounds to refuse the pack. It is skipped
100
+ // and REPORTED by name; nothing is invented for it, because the v3 entry
101
+ // shape requires a real variant, command and probe evidence. See #172.
102
+ if (!variant) {
103
+ unresolved.push({ sensor: name, state: evidence.state, reason: evidence.reason });
104
+ continue;
105
+ }
94
106
  sensors[name] = { enabled: true, ...(packSensor.fast === undefined ? {} : { fast: packSensor.fast }), ...(packSensor.timeout === undefined ? {} : { timeout: packSensor.timeout }), variantId: variant.id, command: variant.command, ...(variant.assets.length === 0 ? {} : { assets: variant.assets }), ...(variant.policyRef ? { policyRef: variant.policyRef } : {}), initializedCompatibility: evidence };
95
107
  }
108
+ // A pack with NO applicable sensor still writes an honest empty manifest: that
109
+ // is the distinct "the registry has no pack for this stack" signal preflight
110
+ // already reports. But a pack where sensors apply and none of them resolve is
111
+ // a different condition, and writing an empty manifest would collide with it.
112
+ if (applicable > 0 && Object.keys(sensors).length === 0) {
113
+ return { reason: 'no-sensor-resolvable', remedy: 'repair-project-tools-or-select-another-mode', unresolved };
114
+ }
96
115
  const manifest = (0, manifest_1.parseSensorManifest)({ schemaVersion: 3, mode: 'project-sensors', pack, source: { registry: source.registry.name }, ...(packageRoot ? { packageRoot } : {}), sensors }, 'bootstrap candidate');
97
116
  if (manifest.kind !== 'v3' || manifest.pack.mode !== 'project-sensors')
98
117
  throw new Error('bootstrap project-sensors candidate is invalid');
99
118
  (0, manifest_1.serializeManifestV3)(manifest.pack);
100
119
  const assets = [...new Set(Object.values(sensors).flatMap(sensor => sensor.assets ?? []))].sort();
101
- return { manifest: manifest.pack, source, changes: [{ path: '.awm/sensors.json', action: 'create' }, ...assets.map(asset => ({ path: packageRoot ? `${packageRoot}/${asset}` : asset, action: 'create' }))] };
120
+ return { manifest: manifest.pack, source, changes: [{ path: '.awm/sensors.json', action: 'create' }, ...assets.map(asset => ({ path: packageRoot ? `${packageRoot}/${asset}` : asset, action: 'create' }))], unresolved };
102
121
  }
103
122
  /** Plan a one-time portable bootstrap. This function never writes project or machine state. */
104
123
  async function planSensorBootstrap(cwd = process.cwd(), input = {}) {
@@ -125,7 +144,7 @@ async function planSensorBootstrap(cwd = process.cwd(), input = {}) {
125
144
  // activate v2 sensors which require an explicit project opt-in.
126
145
  const planned = await projectSensors(project.projectRoot, opts.registryRoot, project.manifest.pack.pack, undefined, false);
127
146
  if ('reason' in planned)
128
- return blocked(project.projectRoot, project.manifestPath, opts.dryRun, planned.reason, planned.remedy, 'candidates' in planned ? planned.candidates : undefined);
147
+ return blocked(project.projectRoot, project.manifestPath, opts.dryRun, planned.reason, planned.remedy, 'candidates' in planned ? planned.candidates : undefined, planned.unresolved);
129
148
  try {
130
149
  const original = fs_1.default.readFileSync(project.manifestPath);
131
150
  return {
@@ -133,6 +152,7 @@ async function planSensorBootstrap(cwd = process.cwd(), input = {}) {
133
152
  changes: planned.changes.map((change, index) => index === 0 ? { ...change, action: 'replace' } : change),
134
153
  dryRun: opts.dryRun, migration: { kind: 'legacy-v1', manifest: planned.manifest }, source: planned.source,
135
154
  originalDigest: digest(original),
155
+ ...(planned.unresolved.length > 0 ? { unresolved: planned.unresolved } : {}),
136
156
  };
137
157
  }
138
158
  catch {
@@ -165,8 +185,8 @@ async function planSensorBootstrap(cwd = process.cwd(), input = {}) {
165
185
  }
166
186
  const planned = await projectSensors(project.projectRoot, opts.registryRoot, opts.pack, opts.packageRoot);
167
187
  if ('reason' in planned)
168
- return blocked(project.projectRoot, project.manifestPath, opts.dryRun, planned.reason, planned.remedy, 'candidates' in planned ? planned.candidates : undefined);
169
- return { kind: 'create', projectRoot: project.projectRoot, manifestPath: project.manifestPath, changes: planned.changes, dryRun: opts.dryRun, manifest: planned.manifest, source: planned.source, configure: opts.configure };
188
+ return blocked(project.projectRoot, project.manifestPath, opts.dryRun, planned.reason, planned.remedy, 'candidates' in planned ? planned.candidates : undefined, planned.unresolved);
189
+ return { kind: 'create', projectRoot: project.projectRoot, manifestPath: project.manifestPath, changes: planned.changes, dryRun: opts.dryRun, manifest: planned.manifest, source: planned.source, configure: opts.configure, ...(planned.unresolved.length > 0 ? { unresolved: planned.unresolved } : {}) };
170
190
  }
171
191
  /** Apply only a validated, non-dry-run create or migration plan. */
172
192
  function applySensorBootstrap(plan) {
@@ -16,6 +16,7 @@ const render_1 = require("./coverage/render");
16
16
  const registries_1 = require("../../core/registries");
17
17
  const verdict_1 = require("./verdict");
18
18
  const bootstrap_1 = require("./bootstrap");
19
+ const unresolved_1 = require("./unresolved");
19
20
  /** Commander coercion for coverage recurrence emphasis. It deliberately runs
20
21
  * before the action, so an invalid value cannot trigger ledger I/O. */
21
22
  function parsePositiveSafeInteger(value) {
@@ -72,8 +73,10 @@ function registerSensorsCommand(program) {
72
73
  const registryRoot = opts.registryRoot ?? (0, registries_1.capabilityRoot)('sensor-packs') ?? undefined;
73
74
  try {
74
75
  const plan = await (0, bootstrap_1.planSensorBootstrap)(process.cwd(), { mode: 'project-sensors', registryRoot, configure: opts.configure, pack: opts.pack, packageRoot: opts.packageRoot });
75
- if (plan.kind === 'blocked')
76
- throw new Error(`${plan.reason}: ${plan.remedy}`);
76
+ if (plan.kind === 'blocked') {
77
+ const detail = (0, unresolved_1.describeUnresolved)(plan.unresolved);
78
+ throw new Error(`${plan.reason}: ${plan.remedy}${detail === '' ? '' : ` (${detail})`}`);
79
+ }
77
80
  if (plan.kind === 'noop') {
78
81
  prompts_1.log.info('already-configured');
79
82
  return;
@@ -83,8 +86,12 @@ function registerSensorsCommand(program) {
83
86
  const result = (0, bootstrap_1.applySensorBootstrap)(plan);
84
87
  const detected = plan.manifest.mode === 'project-sensors' ? plan.manifest.pack : 'none';
85
88
  prompts_1.log.success(`Detected: ${detected}`);
86
- // Said BEFORE "Wrote .awm/sensors.json": the manifest about to be
87
- // reported as written is not the one the detection implied.
89
+ // Said BEFORE "Wrote .awm/sensors.json": a partially resolved pack
90
+ // must not be reported as a clean write, and the manifest about to
91
+ // be reported as written is not the one the detection implied.
92
+ const skipped = (0, unresolved_1.describeUnresolved)(plan.unresolved);
93
+ if (skipped !== '')
94
+ prompts_1.log.warn(`not initialized (tool unavailable here): ${skipped}`);
88
95
  prompts_1.log.success(`${result} .awm/sensors.json`);
89
96
  }
90
97
  catch (e) {
@@ -102,7 +109,8 @@ function registerSensorsCommand(program) {
102
109
  try {
103
110
  const plan = await (0, bootstrap_1.planSensorBootstrap)(process.cwd(), { mode: opts.mode, reason: opts.reason, dryRun: opts.dryRun });
104
111
  if (plan.kind === 'blocked') {
105
- prompts_1.log.error(`${plan.reason}: ${plan.remedy}`);
112
+ const detail = (0, unresolved_1.describeUnresolved)(plan.unresolved);
113
+ prompts_1.log.error(`${plan.reason}: ${plan.remedy}${detail === '' ? '' : ` (${detail})`}`);
106
114
  process.exitCode = 1;
107
115
  return;
108
116
  }
@@ -154,6 +162,12 @@ function registerSensorsCommand(program) {
154
162
  const mark = check.ok ? picocolors_1.default.green('✔') : picocolors_1.default.red('✘');
155
163
  console.log(` ${mark} ${name.padEnd(12)} ${check.detail}`);
156
164
  }
165
+ // A pack sensor that applies here but has no manifest entry is otherwise
166
+ // indistinguishable from one the pack never applied. It does not change
167
+ // the verdict; it is named so the gap is attributable. See #172.
168
+ for (const [name, gap] of Object.entries(status.uninitialized ?? {})) {
169
+ console.log(` ${picocolors_1.default.yellow('–')} ${name.padEnd(12)} not initialized: ${gap.state}/${gap.reason}`);
170
+ }
157
171
  console.log('');
158
172
  if (status.overall !== 'READY')
159
173
  process.exit(1);
@@ -11,6 +11,7 @@ const fs_1 = __importDefault(require("fs"));
11
11
  const path_1 = __importDefault(require("path"));
12
12
  const pack_source_1 = require("./compatibility/pack-source");
13
13
  const bootstrap_1 = require("./bootstrap");
14
+ const unresolved_1 = require("./unresolved");
14
15
  /** Read a pack only through the shared source resolver. Init is a registry
15
16
  * boundary: a pack path that is absent is harmless, but a symlink or escaping
16
17
  * claimed source is never silently downgraded to an empty manifest. */
@@ -155,8 +156,10 @@ async function initSensors(opts = {}) {
155
156
  mode: 'project-sensors', registryRoot: opts.registryRoot, configure: opts.configure,
156
157
  pack: opts.pack, packageRoot: opts.packageRoot,
157
158
  });
158
- if (plan.kind === 'blocked')
159
- throw new Error(`${plan.reason}: ${plan.remedy}`);
159
+ if (plan.kind === 'blocked') {
160
+ const detail = (0, unresolved_1.describeUnresolved)(plan.unresolved);
161
+ throw new Error(`${plan.reason}: ${plan.remedy}${detail === '' ? '' : ` (${detail})`}`);
162
+ }
160
163
  if (plan.kind === 'migrate')
161
164
  throw new Error('initSensors does not migrate existing v2 manifests; run awm sensors bootstrap');
162
165
  if (plan.kind === 'noop')
@@ -166,5 +169,5 @@ async function initSensors(opts = {}) {
166
169
  manifest: {}, detection: detectStack(detectionCwd), configured: [], status: 'already-configured',
167
170
  };
168
171
  (0, bootstrap_1.applySensorBootstrap)(plan);
169
- return { manifest: plan.manifest, detection: detectStack(detectionCwd), configured: [], status: 'created' };
172
+ return { manifest: plan.manifest, detection: detectStack(detectionCwd), configured: [], status: 'created', ...(plan.unresolved && plan.unresolved.length > 0 ? { unresolved: plan.unresolved } : {}) };
170
173
  }
@@ -124,9 +124,40 @@ function resolveStaticV2Compatibility(cwd, manifest, source) {
124
124
  : (0, resolve_1.resolveSensorCompatibility)(sensor, { ...resolutionEvidence, probe: { status: probeStatus } }, { pack: executionPack.name, sensor: name })];
125
125
  }));
126
126
  }
127
+ /** Applicable pack sensors with no manifest entry, so the gap is attributable in
128
+ * every `awm sensors status`, not only in the output of the `init` that skipped it. */
129
+ function uninitializedSensors(manifest, compatibility) {
130
+ const entries = Object.entries(compatibility)
131
+ .filter(([name, evidence]) => evidence !== undefined && evidence.state !== 'not-applicable' && !Object.prototype.hasOwnProperty.call(manifest.sensors, name))
132
+ .sort(([left], [right]) => left.localeCompare(right))
133
+ .map(([name, evidence]) => [name, { state: evidence.state, reason: evidence.reason }]);
134
+ return entries.length === 0 ? undefined : Object.fromEntries(entries);
135
+ }
136
+ /**
137
+ * Status never executes, so `discoverProjectEvidence` is called here without
138
+ * `pathToolVersion` and a `resolution: 'path'` tool is structurally invisible to
139
+ * it: `npm` on PATH resolves as `missing-tool`/`tool-not-found` whether it is
140
+ * installed or not. Reporting that as "compatibility drift" asserts something
141
+ * this command did not measure. `checkStructuredCommand` DOES consult PATH for
142
+ * exactly these commands, so defer to it rather than claim drift. Any other live
143
+ * state, or a genuinely different resolved variant, is still real drift.
144
+ *
145
+ * Today `missing-tool`/`tool-not-found` is the ONLY verdict a `path` tool can
146
+ * receive here, because discovery cannot see its version at all — so the state
147
+ * and reason conditions are unreachable as written. They are kept because every
148
+ * one of them can only make this guard NARROWER: a combination they reject falls
149
+ * through to the drift report, never past it. A future discovery that does read
150
+ * PATH would make them load-bearing rather than silently permissive.
151
+ */
152
+ function unobservableOnPath(sensor, live) {
153
+ return sensor.command.resolution === 'path' && live.variantId === null
154
+ && live.state === 'missing-tool' && live.reason === 'tool-not-found';
155
+ }
127
156
  function staticCompatibilityCheck(sensor, live) {
128
157
  if (!live)
129
158
  return { ok: false, detail: 'live compatibility unavailable' };
159
+ if (unobservableOnPath(sensor, live))
160
+ return null;
130
161
  if (live.variantId !== sensor.variantId) {
131
162
  return { ok: false, detail: `compatibility drift: initialized ${sensor.variantId}, resolved ${live.variantId ?? live.state}` };
132
163
  }
@@ -226,9 +257,11 @@ async function computeSensorStatus(cwd = process.cwd()) {
226
257
  checks[name] = sensor.enabled === false ? { ok: true, detail: 'disabled' }
227
258
  : staticCompatibilityCheck(sensor, compatibility[name]) ?? checkStructuredCommand(sensor.command, packageRoot, sensor.assets);
228
259
  }
260
+ const uninitialized = uninitializedSensors(manifest, compatibility);
229
261
  return {
230
262
  overall: Object.keys(checks).length > 0 && Object.values(checks).every(check => check.ok) ? 'READY' : 'DEGRADED',
231
263
  pack: manifest.pack, checks,
264
+ ...(uninitialized ? { uninitialized } : {}),
232
265
  ...projectMetadata(project, 'project-sensors', sourceMeta.reason, { ...sourceMeta }),
233
266
  };
234
267
  }
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.describeUnresolved = describeUnresolved;
4
+ /**
5
+ * `sensor-a: missing-tool/tool-not-found; sensor-b: incompatible/...` — which
6
+ * sensor and why. Before #172 an unresolvable sensor produced one code for the
7
+ * whole pack, so an operator could not tell that the other sensors were fine.
8
+ *
9
+ * Its own module so the command wiring and `initSensors` share one formatting,
10
+ * and so neither has to import the other to get it.
11
+ */
12
+ function describeUnresolved(unresolved) {
13
+ if (!unresolved || unresolved.length === 0)
14
+ return '';
15
+ return [...unresolved]
16
+ .sort((left, right) => left.sensor.localeCompare(right.sensor))
17
+ .map(item => `${item.sensor}: ${item.state}/${item.reason}`)
18
+ .join('; ');
19
+ }
@@ -40,7 +40,6 @@ const defaultDeps = {
40
40
  applyInstallPlan: install_transaction_1.applyInstallPlan,
41
41
  resyncInstalledHooks: resync_1.resyncInstalledHooks,
42
42
  offerSelfUpdate: async (mode) => {
43
- // eslint-disable-next-line @typescript-eslint/no-var-requires
44
43
  const { offerSelfUpdate: real } = require('../core/update-check');
45
44
  await real({ mode });
46
45
  },
@@ -128,12 +128,16 @@ describe('planSensorBootstrap', () => {
128
128
  await (0, bootstrap_1.planSensorBootstrap)(root, { mode: 'project-sensors', packageRoot: 'packages/web' });
129
129
  expect(live_1.resolveParsedPackCompatibility).toHaveBeenCalledWith(path_1.default.resolve(root, 'packages/web'), pack);
130
130
  });
131
- it('blocks project-sensors when compatibility cannot select every declared sensor variant', async () => {
131
+ // #172 narrowed this: one unresolvable sensor no longer aborts a pack whose
132
+ // other sensors resolved (see partial-pack-bootstrap.test.ts). What still
133
+ // blocks is a pack where sensors apply and NONE of them resolve, which is a
134
+ // different condition from a pack with no applicable sensor at all.
135
+ it('blocks project-sensors when sensors apply and compatibility can select no variant at all', async () => {
132
136
  const variant = { id: 'eslint-9', command: { executable: 'eslint', resolution: 'node-modules-bin', args: ['.'] }, assets: [] };
133
137
  pack_source_1.listPackSources.mockReturnValue([source]);
134
138
  contract_1.parseSensorPack.mockReturnValue({ kind: 'v2', pack: { schemaVersion: 2, name: 'js-ts', sensors: { lint: { variants: [variant] } } } });
135
- live_1.resolveParsedPackCompatibility.mockResolvedValue({ pack: { schemaVersion: 2, name: 'js-ts', sensors: { lint: { variants: [variant] } } }, sensors: { lint: { variantId: null } } });
136
- await expect((0, bootstrap_1.planSensorBootstrap)(root, { mode: 'project-sensors' })).resolves.toMatchObject({ kind: 'blocked', reason: 'sensor-variant-unresolvable', changes: [] });
139
+ live_1.resolveParsedPackCompatibility.mockResolvedValue({ pack: { schemaVersion: 2, name: 'js-ts', sensors: { lint: { variants: [variant] } } }, sensors: { lint: { state: 'missing-tool', reason: 'tool-not-found', variantId: null } } });
140
+ await expect((0, bootstrap_1.planSensorBootstrap)(root, { mode: 'project-sensors' })).resolves.toMatchObject({ kind: 'blocked', reason: 'no-sensor-resolvable', changes: [] });
137
141
  });
138
142
  it('omits explicit-opt-in sensors that are not applicable while retaining mandatory gates', async () => {
139
143
  const variant = { id: 'main', command: { executable: 'tool', resolution: 'path', args: ['run'] }, assets: [] };
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- jest.mock('@clack/prompts', () => ({ log: { success: jest.fn(), info: jest.fn(), error: jest.fn() } }));
3
+ jest.mock('@clack/prompts', () => ({ log: { success: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() } }));
4
4
  jest.mock('picocolors', () => ({ green: (s) => s, yellow: (s) => s, red: (s) => s }));
5
5
  jest.mock('../../../src/commands/sensors/run', () => ({ runSensors: jest.fn() }));
6
6
  jest.mock('../../../src/commands/sensors/init', () => ({ initSensors: jest.fn() }));
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ jest.mock('../../../src/commands/sensors/project', () => ({ resolveSensorProject: jest.fn() }));
4
+ jest.mock('../../../src/commands/sensors/detection', () => ({ detectStack: jest.fn() }));
5
+ jest.mock('../../../src/core/registries', () => ({ listRegistries: jest.fn(() => []) }));
6
+ jest.mock('../../../src/commands/sensors/compatibility/pack-source', () => ({ listPackSources: jest.fn() }));
7
+ jest.mock('../../../src/commands/sensors/compatibility/contract', () => ({ ...jest.requireActual('../../../src/commands/sensors/compatibility/contract'), parseSensorPack: jest.fn() }));
8
+ jest.mock('../../../src/commands/sensors/compatibility/live', () => ({ resolveParsedPackCompatibility: jest.fn() }));
9
+ jest.mock('../../../src/commands/sensors/compatibility/materialize', () => ({ materializePortableSensors: jest.fn() }));
10
+ jest.mock('../../../src/commands/sensors/compatibility/safe-file', () => ({ writeProjectFile: jest.fn(), withProjectLease: jest.fn((_root, operation) => operation()) }));
11
+ const bootstrap_1 = require("../../../src/commands/sensors/bootstrap");
12
+ const unresolved_1 = require("../../../src/commands/sensors/unresolved");
13
+ const project_1 = require("../../../src/commands/sensors/project");
14
+ const detection_1 = require("../../../src/commands/sensors/detection");
15
+ const pack_source_1 = require("../../../src/commands/sensors/compatibility/pack-source");
16
+ const contract_1 = require("../../../src/commands/sensors/compatibility/contract");
17
+ const live_1 = require("../../../src/commands/sensors/compatibility/live");
18
+ // #172. One applicable sensor whose tool is absent used to abort the entire
19
+ // manifest, including the sensors that DID resolve — so a project whose only gap
20
+ // was semgrep could not configure sensors, could not pass preflight, and could
21
+ // not be handed off. A missing tool is a gap in one sensor, not grounds to refuse
22
+ // the pack.
23
+ const root = '/project';
24
+ const missing = { state: 'missing', projectRoot: root, manifestPath: `${root}/.awm/sensors.json` };
25
+ const source = { path: '/registry/sensor-packs/js-ts/pack.json', content: '{}', registry: { name: 'baseline', remote: 'local', contentRoot: '/registry' } };
26
+ const lintVariant = { id: 'eslint-10', command: { executable: 'eslint', resolution: 'node-modules-bin', args: ['.'] }, assets: [] };
27
+ const securityVariant = { id: 'semgrep-js-ts', command: { executable: 'semgrep', resolution: 'path', args: ['--config', '.semgrep.awm.yml'] }, assets: ['.semgrep.awm.yml'] };
28
+ function resolvedLint() {
29
+ return { state: 'certified', reason: 'range-and-probe', variantId: 'eslint-10', toolVersion: '10.4.1', runtimeVersion: '24.0.0', certifiedRange: '>=10 <11', evidence: [] };
30
+ }
31
+ function unresolvedSensor(state, reason) {
32
+ return { state, reason, variantId: null, toolVersion: null, runtimeVersion: null, certifiedRange: null, evidence: [] };
33
+ }
34
+ /** Wire the mocked pack + live resolution for one planning run. */
35
+ function pack(sensors, live) {
36
+ const parsed = { schemaVersion: 2, name: 'js-ts', sensors };
37
+ pack_source_1.listPackSources.mockReturnValue([source]);
38
+ contract_1.parseSensorPack.mockReturnValue({ kind: 'v2', pack: parsed });
39
+ live_1.resolveParsedPackCompatibility.mockResolvedValue({ pack: parsed, sensors: live });
40
+ }
41
+ describe('project-sensors bootstrap with a partially resolvable pack', () => {
42
+ beforeEach(() => {
43
+ jest.clearAllMocks();
44
+ project_1.resolveSensorProject.mockReturnValue(missing);
45
+ detection_1.detectStack.mockReturnValue({ pack: 'js-ts', indicators: ['package.json'] });
46
+ });
47
+ it('writes the sensors that resolved and reports by name the applicable one that did not', async () => {
48
+ pack({ lint: { variants: [lintVariant] }, security: { variants: [securityVariant] } }, { lint: resolvedLint(), security: unresolvedSensor('missing-tool', 'tool-not-found') });
49
+ const plan = await (0, bootstrap_1.planSensorBootstrap)(root, { mode: 'project-sensors' });
50
+ expect(plan.kind).toBe('create');
51
+ const created = plan;
52
+ const manifest = created.manifest;
53
+ // The manifest contents are the assertion, not the exit code.
54
+ expect(Object.keys(manifest.sensors)).toEqual(['lint']);
55
+ expect(manifest.sensors.lint.variantId).toBe('eslint-10');
56
+ expect(created.unresolved).toEqual([{ sensor: 'security', state: 'missing-tool', reason: 'tool-not-found' }]);
57
+ // Nothing is invented for the skipped sensor: no entry, and none of its assets.
58
+ expect(created.changes.map(change => change.path)).toEqual(['.awm/sensors.json']);
59
+ });
60
+ it('carries a sensor with no manifest entry rather than a disabled entry with fabricated evidence', async () => {
61
+ pack({ lint: { variants: [lintVariant] }, security: { variants: [securityVariant] } }, { lint: resolvedLint(), security: unresolvedSensor('incompatible', 'no-operational-variant') });
62
+ const plan = await (0, bootstrap_1.planSensorBootstrap)(root, { mode: 'project-sensors' });
63
+ const manifest = plan.manifest;
64
+ expect(manifest.sensors).not.toHaveProperty('security');
65
+ expect(JSON.stringify(manifest)).not.toContain('semgrep');
66
+ });
67
+ it('still blocks, naming every sensor, when sensors apply and none of them resolve', async () => {
68
+ pack({ lint: { variants: [lintVariant] }, security: { variants: [securityVariant] } }, { lint: unresolvedSensor('missing-tool', 'tool-not-found'), security: unresolvedSensor('missing-tool', 'tool-not-found') });
69
+ const plan = await (0, bootstrap_1.planSensorBootstrap)(root, { mode: 'project-sensors' });
70
+ expect(plan).toMatchObject({ kind: 'blocked', reason: 'no-sensor-resolvable', changes: [] });
71
+ expect(plan.unresolved).toEqual([
72
+ { sensor: 'lint', state: 'missing-tool', reason: 'tool-not-found' },
73
+ { sensor: 'security', state: 'missing-tool', reason: 'tool-not-found' },
74
+ ]);
75
+ });
76
+ it('keeps the distinct honest-empty manifest when the pack declares no applicable sensor', async () => {
77
+ // This is NOT the same condition as "nothing resolved": it is the registry
78
+ // having no sensor for this stack, which preflight reports with its own
79
+ // remedy. Writing an empty manifest for an unresolvable pack would collide
80
+ // with that signal.
81
+ pack({ lint: { variants: [lintVariant] } }, { lint: unresolvedSensor('not-applicable', 'applicability-not-met') });
82
+ const plan = await (0, bootstrap_1.planSensorBootstrap)(root, { mode: 'project-sensors' });
83
+ const manifest = plan.manifest;
84
+ expect(plan.kind).toBe('create');
85
+ expect(manifest.sensors).toEqual({});
86
+ expect(plan.unresolved).toBeUndefined();
87
+ });
88
+ it('omits the unresolved field entirely when every applicable sensor resolved', async () => {
89
+ pack({ lint: { variants: [lintVariant] } }, { lint: resolvedLint() });
90
+ const plan = await (0, bootstrap_1.planSensorBootstrap)(root, { mode: 'project-sensors' });
91
+ expect(plan.unresolved).toBeUndefined();
92
+ });
93
+ });
94
+ describe('describeUnresolved', () => {
95
+ it('names each sensor with its state and reason, in a stable order', () => {
96
+ expect((0, unresolved_1.describeUnresolved)([
97
+ { sensor: 'security', state: 'missing-tool', reason: 'tool-not-found' },
98
+ { sensor: 'depcheck', state: 'incompatible', reason: 'no-operational-variant' },
99
+ ])).toBe('depcheck: incompatible/no-operational-variant; security: missing-tool/tool-not-found');
100
+ });
101
+ it('is empty for nothing unresolved, so callers add no punctuation for an empty gap', () => {
102
+ expect((0, unresolved_1.describeUnresolved)(undefined)).toBe('');
103
+ expect((0, unresolved_1.describeUnresolved)([])).toBe('');
104
+ });
105
+ });
@@ -0,0 +1,174 @@
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 status_1 = require("../../../src/commands/sensors/status");
10
+ const checks_1 = require("../../../src/commands/preflight/checks");
11
+ // #172. `checks` only ever covered what the manifest declares, so a pack sensor
12
+ // that applies to this project and could not be initialized was simply absent —
13
+ // indistinguishable from one the pack never applied. These tests hold the gap
14
+ // visible and attributable in every `awm sensors status`, not only in the output
15
+ // of the `init` that skipped it.
16
+ //
17
+ // Isolated HOME/AWM_HOME per test: nothing here may read or write the real
18
+ // `~/.awm`, which belongs to the installer.
19
+ const ESLINT_VARIANT = {
20
+ id: 'eslint-10',
21
+ priority: 100,
22
+ requirements: { tool: 'eslint', toolRange: '>=10.0.0 <12.0.0', runtime: 'node', runtimeRange: '>=20.0.0' },
23
+ certifiedRange: '>=10.0.0 <11.0.0',
24
+ command: { executable: 'eslint', resolution: 'node-modules-bin', args: ['.'] },
25
+ assets: [],
26
+ formatter: 'eslint',
27
+ probe: { kind: 'config-present' },
28
+ };
29
+ const SEMGREP_VARIANT = {
30
+ id: 'semgrep-js-ts',
31
+ priority: 10,
32
+ requirements: { tool: 'semgrep', toolRange: '>=1.0.0', runtime: 'node', runtimeRange: '>=20.0.0' },
33
+ certifiedRange: '>=1.0.0',
34
+ command: { executable: 'semgrep', resolution: 'path', args: ['--config', '.semgrep.awm.yml'] },
35
+ assets: [],
36
+ formatter: 'semgrep',
37
+ probe: { kind: 'version' },
38
+ };
39
+ function packJson(sensors) {
40
+ const detector = Object.keys(sensors)[0];
41
+ return JSON.stringify({
42
+ schemaVersion: 2, name: 'js-ts', description: '#172 fixture', detects: ['package.json'], sensors,
43
+ coverage: {
44
+ schemaVersion: 1,
45
+ classes: { 'lint-errors': { description: 'Findings', detectors: [{ sensor: detector }], remedy: { summary: 'Configure', command: 'awm sensors init --pack js-ts' } } },
46
+ },
47
+ });
48
+ }
49
+ function lintEntry() {
50
+ return {
51
+ enabled: true, variantId: 'eslint-10',
52
+ command: { executable: 'eslint', resolution: 'node-modules-bin', args: ['.'] },
53
+ initializedCompatibility: { state: 'certified', reason: 'range-and-probe', variantId: 'eslint-10', toolVersion: '10.4.1', runtimeVersion: '24.0.0', certifiedRange: '>=10.0.0 <11.0.0', evidence: [] },
54
+ };
55
+ }
56
+ function semgrepEntry() {
57
+ return {
58
+ enabled: true, variantId: 'semgrep-js-ts',
59
+ command: { executable: 'semgrep', resolution: 'path', args: ['--config', '.semgrep.awm.yml'] },
60
+ initializedCompatibility: { state: 'compatible-unverified', reason: 'operational-range-and-probe', variantId: 'semgrep-js-ts', toolVersion: '1.173.0', runtimeVersion: '24.0.0', certifiedRange: '>=1.0.0', evidence: [] },
61
+ };
62
+ }
63
+ describe('sensor status for a partially initialized pack', () => {
64
+ let root;
65
+ let project;
66
+ let pathDir;
67
+ let previous;
68
+ beforeEach(() => {
69
+ root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-172-'));
70
+ project = path_1.default.join(root, 'project');
71
+ pathDir = path_1.default.join(root, 'bin');
72
+ const registry = path_1.default.join(root, 'awm-home', 'registries', 'baseline', 'sensor-packs', 'js-ts');
73
+ fs_1.default.mkdirSync(registry, { recursive: true });
74
+ fs_1.default.mkdirSync(path_1.default.join(project, '.awm'), { recursive: true });
75
+ fs_1.default.mkdirSync(pathDir, { recursive: true });
76
+ fs_1.default.writeFileSync(path_1.default.join(root, 'awm-home', 'registries.json'), JSON.stringify([{ name: 'baseline', remote: 'fixture' }]));
77
+ fs_1.default.writeFileSync(path_1.default.join(project, 'package.json'), JSON.stringify({ name: 'fixture-172', private: true }));
78
+ fs_1.default.writeFileSync(path_1.default.join(project, 'eslint.config.mjs'), 'export default [];\n');
79
+ fs_1.default.mkdirSync(path_1.default.join(project, 'node_modules', 'eslint'), { recursive: true });
80
+ fs_1.default.writeFileSync(path_1.default.join(project, 'node_modules', 'eslint', 'package.json'), JSON.stringify({ name: 'eslint', version: '10.4.1' }));
81
+ fs_1.default.mkdirSync(path_1.default.join(project, 'node_modules', '.bin'), { recursive: true });
82
+ fs_1.default.writeFileSync(path_1.default.join(project, 'node_modules', '.bin', 'eslint'), '');
83
+ fs_1.default.writeFileSync(path_1.default.join(registry, 'pack.json'), packJson({
84
+ lint: { applicability: { allFiles: ['package.json'] }, variants: [ESLINT_VARIANT] },
85
+ security: { applicability: { allFiles: ['package.json'] }, variants: [SEMGREP_VARIANT] },
86
+ }));
87
+ previous = { home: process.env.HOME, awmHome: process.env.AWM_HOME, path: process.env.PATH };
88
+ process.env.HOME = path_1.default.join(root, 'operator-home');
89
+ process.env.AWM_HOME = path_1.default.join(root, 'awm-home');
90
+ process.env.PATH = pathDir;
91
+ });
92
+ afterEach(() => {
93
+ for (const [key, value] of [['HOME', previous.home], ['AWM_HOME', previous.awmHome], ['PATH', previous.path]]) {
94
+ if (value === undefined)
95
+ delete process.env[key];
96
+ else
97
+ process.env[key] = value;
98
+ }
99
+ fs_1.default.rmSync(root, { recursive: true, force: true });
100
+ });
101
+ function writeManifest(sensors) {
102
+ fs_1.default.writeFileSync(path_1.default.join(project, '.awm', 'sensors.json'), JSON.stringify({
103
+ schemaVersion: 3, mode: 'project-sensors', pack: 'js-ts', source: { registry: 'baseline' }, sensors,
104
+ }));
105
+ }
106
+ function installOnPath(tool) {
107
+ const file = path_1.default.join(pathDir, tool);
108
+ fs_1.default.writeFileSync(file, '#!/bin/sh\nexit 0\n');
109
+ fs_1.default.chmodSync(file, 0o755);
110
+ }
111
+ it('names the applicable sensor that has no manifest entry, with its live reason', async () => {
112
+ writeManifest({ lint: lintEntry() });
113
+ const status = await (0, status_1.computeSensorStatus)(project);
114
+ expect(Object.keys(status.checks)).toEqual(['lint']);
115
+ expect(status.uninitialized).toEqual({ security: { state: 'missing-tool', reason: 'tool-not-found' } });
116
+ });
117
+ it('does not let an uninitialized sensor change the verdict, which stays a gate-policy decision', async () => {
118
+ writeManifest({ lint: lintEntry() });
119
+ const status = await (0, status_1.computeSensorStatus)(project);
120
+ expect(status.overall).toBe('READY');
121
+ });
122
+ it('omits the field when every applicable pack sensor has an entry', async () => {
123
+ installOnPath('semgrep');
124
+ writeManifest({ lint: lintEntry(), security: semgrepEntry() });
125
+ const status = await (0, status_1.computeSensorStatus)(project);
126
+ expect(status.uninitialized).toBeUndefined();
127
+ });
128
+ it('reports a not-applicable pack sensor as neither a check nor a gap', async () => {
129
+ const registry = path_1.default.join(root, 'awm-home', 'registries', 'baseline', 'sensor-packs', 'js-ts');
130
+ fs_1.default.writeFileSync(path_1.default.join(registry, 'pack.json'), packJson({
131
+ lint: { applicability: { allFiles: ['package.json'] }, variants: [ESLINT_VARIANT] },
132
+ security: { applicability: { allFiles: ['never-present.toml'] }, variants: [SEMGREP_VARIANT] },
133
+ }));
134
+ writeManifest({ lint: lintEntry() });
135
+ const status = await (0, status_1.computeSensorStatus)(project);
136
+ expect(status.uninitialized).toBeUndefined();
137
+ expect(status.checks).not.toHaveProperty('security');
138
+ });
139
+ it('preflight names the gap and still passes the manifest check, so one missing tool is not a dead end', async () => {
140
+ fs_1.default.mkdirSync(path_1.default.join(project, '.git'));
141
+ writeManifest({ lint: lintEntry() });
142
+ const report = await (0, checks_1.preflight)(project);
143
+ const manifest = report.checks.find(check => check.id === 'manifest');
144
+ expect(manifest).toMatchObject({ ok: true });
145
+ expect(manifest?.detail).toContain('1 applicable sensor(s) not initialized: security');
146
+ });
147
+ // `discoverProjectEvidence` is called here without `pathToolVersion` because
148
+ // status never executes, so a `resolution: 'path'` tool is invisible to it.
149
+ // Calling that "drift" asserts something this command did not measure.
150
+ it('does not claim drift for a PATH tool it cannot observe, and defers to the PATH check', async () => {
151
+ installOnPath('semgrep');
152
+ fs_1.default.writeFileSync(path_1.default.join(project, '.semgrep.awm.yml'), 'rules: []\n');
153
+ writeManifest({ security: semgrepEntry() });
154
+ const status = await (0, status_1.computeSensorStatus)(project);
155
+ expect(status.checks.security).toEqual({ ok: true, detail: 'semgrep' });
156
+ expect(status.overall).toBe('READY');
157
+ });
158
+ it('still fails a PATH sensor whose executable is genuinely absent from PATH', async () => {
159
+ writeManifest({ security: semgrepEntry() });
160
+ const status = await (0, status_1.computeSensorStatus)(project);
161
+ expect(status.checks.security.ok).toBe(false);
162
+ expect(status.checks.security.detail).toMatch(/semgrep not found in PATH/);
163
+ });
164
+ it('still reports real drift when the live resolution selects a different variant', async () => {
165
+ const registry = path_1.default.join(root, 'awm-home', 'registries', 'baseline', 'sensor-packs', 'js-ts');
166
+ fs_1.default.writeFileSync(path_1.default.join(registry, 'pack.json'), packJson({
167
+ lint: { applicability: { allFiles: ['package.json'] }, variants: [{ ...ESLINT_VARIANT, id: 'eslint-10-flat' }] },
168
+ }));
169
+ writeManifest({ lint: lintEntry() });
170
+ const status = await (0, status_1.computeSensorStatus)(project);
171
+ expect(status.checks.lint.ok).toBe(false);
172
+ expect(status.checks.lint.detail).toMatch(/compatibility drift: initialized eslint-10, resolved eslint-10-flat/);
173
+ });
174
+ });
@@ -77,7 +77,6 @@ describe('runExport (engine end-to-end)', () => {
77
77
  // La lectura sale exclusivamente de roots: un root vacío no resuelve nada.
78
78
  // Control negativo real: contentRoots() (el fallback al registry instalado)
79
79
  // no debe invocarse en absoluto cuando opts.roots viene dado explícitamente.
80
- // eslint-disable-next-line @typescript-eslint/no-var-requires
81
80
  const registries = require('../../../src/core/registries');
82
81
  const spy = jest.spyOn(registries, 'contentRoots');
83
82
  const empty = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-empty-root-'));
@@ -7,7 +7,6 @@ const fs_1 = __importDefault(require("fs"));
7
7
  const path_1 = __importDefault(require("path"));
8
8
  const os_1 = __importDefault(require("os"));
9
9
  const pack_1 = require("../../../src/core/export/pack");
10
- // eslint-disable-next-line @typescript-eslint/no-var-requires
11
10
  const childProcess = require('child_process');
12
11
  const okZip = (cwd, zipName) => {
13
12
  fs_1.default.writeFileSync(path_1.default.join(cwd, zipName), 'fake-zip');
@@ -0,0 +1,56 @@
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 child_process_1 = require("child_process");
7
+ const path_1 = __importDefault(require("path"));
8
+ // #171: the lint gate linted 14 files — all js/mjs — and exited 0 on a
9
+ // TypeScript CLI. `eslint.config.awm.mjs` imports a project config that does
10
+ // not exist, swallows the failure, and its remaining objects declare no
11
+ // `files`, so eslint's default universe (js/mjs/cjs) left every .ts out.
12
+ //
13
+ // The gate was green for months and the `lint` sensor certified on it, which
14
+ // `plan admit --verify-sensors` then consumes as the unattended handoff gate.
15
+ //
16
+ // This asserts on the SET OF FILES LINTED, never on the exit code. An exit-code
17
+ // assertion is exactly what let this survive: a lint that examines nothing
18
+ // exits 0.
19
+ const CLI_ROOT = path_1.default.resolve(__dirname, '../..');
20
+ /** One eslint run for the whole suite. Invoked through node against eslint's own
21
+ * js entrypoint rather than `npx`: on Windows `npx` resolves to `npx.cmd`, and
22
+ * `execFileSync` cannot execute a `.cmd` without a shell — the exact trap this
23
+ * repo already documents in tests/integration/published-doctor-evidence.e2e.test.ts,
24
+ * and the one this test hit on both Windows legs of its first CI run. Going
25
+ * through `process.execPath` needs no shell, so no argument escaping either. */
26
+ function lint() {
27
+ const raw = (0, child_process_1.execFileSync)(process.execPath, [
28
+ path_1.default.join(CLI_ROOT, 'node_modules', 'eslint', 'bin', 'eslint.js'),
29
+ '.', '--config', 'eslint.config.awm.mjs', '--format', 'json',
30
+ ], { cwd: CLI_ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
31
+ return JSON.parse(raw);
32
+ }
33
+ describe('the lint gate covers TypeScript (#171)', () => {
34
+ jest.setTimeout(10 * 60_000);
35
+ const results = lint();
36
+ const files = results.map(entry => entry.filePath);
37
+ const typescript = files.filter(file => file.endsWith('.ts'));
38
+ test('lints the TypeScript sources, not only the js/mjs tooling', () => {
39
+ // A floor, not a lock: adding sources must not fail this.
40
+ expect(typescript.length).toBeGreaterThan(400);
41
+ expect(typescript.some(file => file.includes(`${path_1.default.sep}src${path_1.default.sep}`))).toBe(true);
42
+ expect(typescript.some(file => file.includes(`${path_1.default.sep}tests${path_1.default.sep}`))).toBe(true);
43
+ });
44
+ test('lints this very file, so the assertion cannot be vacuous', () => {
45
+ expect(files).toContain(path_1.default.join(CLI_ROOT, 'tests', 'structural', 'lint-covers-typescript.test.ts'));
46
+ });
47
+ test('reports no unresolved rule references', () => {
48
+ // Four stale `eslint-disable @typescript-eslint/no-var-requires` comments
49
+ // survived precisely because .ts was never linted: eslint errors on a rule
50
+ // it cannot resolve, and nobody ever saw it. A new one must fail here
51
+ // rather than wait for the next person to turn the gate on.
52
+ const unresolved = results.flatMap(entry => entry.messages.filter(m => /Definition for rule .* was not found/.test(m.message))
53
+ .map(m => `${entry.filePath}: ${m.message}`));
54
+ expect(unresolved).toEqual([]);
55
+ });
56
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-workflow-manager",
3
- "version": "9.11.1",
3
+ "version": "9.11.3",
4
4
  "main": "dist/src/index.js",
5
5
  "bin": {
6
6
  "awm": "./dist/src/index.js"
@@ -59,6 +59,7 @@
59
59
  "@types/js-yaml": "4.0.9",
60
60
  "@types/node": "^25.3.0",
61
61
  "@types/semver": "^7.7.1",
62
+ "@typescript-eslint/parser": "^8.70.0",
62
63
  "dependency-cruiser": "16.10.4",
63
64
  "eslint": "10.8.1",
64
65
  "jest": "^30.2.0",
Binary file
Binary file