agentic-workflow-manager 9.16.1 → 9.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/src/commands/preflight/checks.js +63 -13
  2. package/dist/src/commands/sensors/bootstrap.js +2 -1
  3. package/dist/src/commands/sensors/compatibility/contract.js +30 -26
  4. package/dist/src/commands/sensors/compatibility/discovery.js +21 -0
  5. package/dist/src/commands/sensors/compatibility/manifest.js +169 -17
  6. package/dist/src/commands/sensors/compatibility/materialize.js +30 -5
  7. package/dist/src/commands/sensors/compatibility/source.js +3 -2
  8. package/dist/src/commands/sensors/coverage/render.js +8 -2
  9. package/dist/src/commands/sensors/coverage/resolve.js +7 -0
  10. package/dist/src/commands/sensors/init.js +15 -2
  11. package/dist/src/commands/sensors/migrate.js +13 -10
  12. package/dist/src/commands/sensors/prepare.js +53 -6
  13. package/dist/src/commands/sensors/project.js +60 -6
  14. package/dist/src/commands/sensors/result.js +18 -3
  15. package/dist/src/commands/sensors/run.js +124 -16
  16. package/dist/src/commands/sensors/status.js +163 -18
  17. package/dist/src/core/init/steps.js +5 -1
  18. package/dist/tests/commands/preflight/preflight.test.js +121 -0
  19. package/dist/tests/commands/sensors/bootstrap.test.js +10 -0
  20. package/dist/tests/commands/sensors/compatibility/manifest.test.js +204 -0
  21. package/dist/tests/commands/sensors/init.test.js +107 -0
  22. package/dist/tests/commands/sensors/migrate.test.js +4 -3
  23. package/dist/tests/commands/sensors/partial-pack-bootstrap.test.js +1 -1
  24. package/dist/tests/commands/sensors/prepare.test.js +62 -0
  25. package/dist/tests/commands/sensors/result.test.js +66 -0
  26. package/dist/tests/commands/sensors/run.test.js +331 -0
  27. package/dist/tests/commands/sensors/status.test.js +204 -0
  28. package/dist/tests/structural/native-release-artifacts.test.js +4 -2
  29. package/dist/tests/structural/project-declared-sensors-docs-contract.test.js +104 -0
  30. package/package.json +1 -1
  31. package/prebuilds/darwin-arm64/secure_fs.node +0 -0
  32. package/prebuilds/darwin-x64/secure_fs.node +0 -0
  33. package/prebuilds/win32-arm64/secure_fs.node +0 -0
  34. package/prebuilds/win32-x64/secure_fs.node +0 -0
@@ -100,13 +100,18 @@ function checkContextKernel(cwd) {
100
100
  remedy: inspection.remedy,
101
101
  };
102
102
  }
103
+ function isDisabledCheckDetail(detail) {
104
+ return detail === 'disabled' || detail.startsWith('disabled ');
105
+ }
103
106
  function manifestView(status) {
104
- if (!status.pack)
107
+ // Only-project READY has pack null but non-empty checks — still a valid view (RF-2.8).
108
+ // Do not invent a fake pack id; keep pack null and surface sensors from status.checks.
109
+ if (status.mode !== 'project-sensors' && status.mode !== 'legacy-v1')
105
110
  return null;
106
111
  const sensors = Object.fromEntries(Object.entries(status.checks).map(([name, check]) => [name, {
107
- enabled: check.detail !== 'disabled',
112
+ enabled: !isDisabledCheckDetail(check.detail),
108
113
  }]));
109
- return { pack: status.pack, sensors };
114
+ return { pack: status.pack ?? null, sensors };
110
115
  }
111
116
  /**
112
117
  * Total sensor entries and how many are enabled (`enabled !== false`, so a missing
@@ -139,12 +144,32 @@ function checkManifest(status) {
139
144
  };
140
145
  }
141
146
  if (status.mode === 'invalid') {
147
+ if (status.reason === 'schema-unsupported') {
148
+ return {
149
+ id: 'manifest',
150
+ ok: false,
151
+ detail: '.awm/sensors.json uses an unsupported schema',
152
+ remedy: 'fix or regenerate it with `awm sensors init`',
153
+ };
154
+ }
155
+ // JSON.parse succeeded but schema/entry validation failed (RF-4.1/4.2).
156
+ // Never claim "not valid JSON"; name the sensor/field from status.reason.
157
+ if (typeof status.reason === 'string' && (status.reason.startsWith('schema-invalid') || status.reason.includes('Invalid sensor manifest'))) {
158
+ const detail = status.reason.startsWith('schema-invalid:')
159
+ ? status.reason.slice('schema-invalid:'.length).trim()
160
+ : status.reason;
161
+ // RF-3.3: project schema invalidity must not lead with `awm sensors init`.
162
+ return {
163
+ id: 'manifest',
164
+ ok: false,
165
+ detail,
166
+ remedy: 'repair the invalid sensor entry in .awm/sensors.json',
167
+ };
168
+ }
142
169
  return {
143
170
  id: 'manifest',
144
171
  ok: false,
145
- detail: status.reason === 'schema-unsupported'
146
- ? '.awm/sensors.json uses an unsupported schema'
147
- : '.awm/sensors.json is not valid JSON',
172
+ detail: '.awm/sensors.json is not valid JSON',
148
173
  remedy: 'fix or regenerate it with `awm sensors init`',
149
174
  };
150
175
  }
@@ -165,6 +190,14 @@ function checkManifest(status) {
165
190
  // for the detected stack, so `awm sensors init` built an honest, empty manifest
166
191
  // rather than inventing defaults. That must not read as "all sensors disabled".
167
192
  if (total === 0) {
193
+ if (manifest.pack === null) {
194
+ return {
195
+ id: 'manifest',
196
+ ok: false,
197
+ detail: 'only-project manifest has no sensors',
198
+ remedy: 'add source:"project" sensor entries to .awm/sensors.json, or declare a pack',
199
+ };
200
+ }
168
201
  return {
169
202
  id: 'manifest',
170
203
  ok: false,
@@ -179,12 +212,14 @@ function checkManifest(status) {
179
212
  // gap is one missing tool could then configure sensors and still never pass.
180
213
  const uninitialized = Object.keys(status.uninitialized ?? {});
181
214
  const gap = uninitialized.length === 0 ? '' : `; ${uninitialized.length} applicable sensor(s) not initialized: ${uninitialized.join(', ')}`;
182
- return {
183
- id: 'manifest',
184
- ok: true,
185
- detail: (enabled === 0 ? `pack ${manifest.pack}, all ${total} sensors disabled (deliberate opt-out)`
186
- : `pack ${manifest.pack}, ${enabled}/${total} sensors enabled`) + gap,
187
- };
215
+ const summary = manifest.pack === null
216
+ ? (enabled === 0
217
+ ? `only-project, all ${total} sensors disabled (deliberate opt-out)`
218
+ : `only-project, ${enabled}/${total} project-declared sensors enabled`)
219
+ : (enabled === 0
220
+ ? `pack ${manifest.pack}, all ${total} sensors disabled (deliberate opt-out)`
221
+ : `pack ${manifest.pack}, ${enabled}/${total} sensors enabled`);
222
+ return { id: 'manifest', ok: true, detail: summary + gap };
188
223
  }
189
224
  /** Every enabled sensor's command must resolve. This is the check nothing was calling. */
190
225
  async function checkTools(cwd, status) {
@@ -198,11 +233,14 @@ async function checkTools(cwd, status) {
198
233
  // already guards against; this defends the invariant independently rather than
199
234
  // relying solely on `checkManifest`'s gate to catch this exact manifest shape.
200
235
  if (Object.keys(status.checks).length === 0) {
236
+ const packId = typeof status.pack === 'string' && status.pack.length > 0 ? status.pack : null;
201
237
  return {
202
238
  id: 'tools',
203
239
  ok: false,
204
240
  detail: 'no sensors configured to check (0 sensor entries in the manifest)',
205
- remedy: `registry has no pack for '${status.pack}': run \`awm update\` or add a registry that has it`,
241
+ remedy: packId === null
242
+ ? 'add source:"project" sensor entries to .awm/sensors.json, or declare a pack'
243
+ : `registry has no pack for '${packId}': run \`awm update\` or add a registry that has it`,
206
244
  };
207
245
  }
208
246
  const broken = Object.entries(status.checks).filter(([, c]) => !c.ok);
@@ -227,6 +265,9 @@ function checkPack(cwd, status) {
227
265
  const manifest = manifestView(status);
228
266
  if (!manifest)
229
267
  return { id: 'pack', ok: true, detail: 'skipped (no manifest)' };
268
+ if (manifest.pack === null) {
269
+ return { id: 'pack', ok: true, detail: 'no pack (project-declared sensors only)' };
270
+ }
230
271
  const detection = (0, detection_1.detectStack)(cwd);
231
272
  if (manifest.pack === 'generic' && detection.pack !== 'generic') {
232
273
  return {
@@ -327,6 +368,15 @@ async function checkSensorExecution(cwd) {
327
368
  try {
328
369
  const output = await (0, run_1.runSensors)({ cwd, all: true });
329
370
  if (output.overall === 'pass') {
371
+ const invalid = output.invalidEntries ?? [];
372
+ if (invalid.length > 0) {
373
+ return {
374
+ id: 'sensors-execution',
375
+ ok: false,
376
+ detail: `sensors passed but ${invalid.length} invalidEntries remain: ${invalid.map(entry => `${entry.name} (${entry.reason})`).join('; ')}`,
377
+ remedy: 'repair the invalid sensor entries in .awm/sensors.json, then rerun `awm preflight --verify-sensors`',
378
+ };
379
+ }
330
380
  return { id: 'sensors-execution', ok: true, detail: 'all selected sensors completed with pass' };
331
381
  }
332
382
  return {
@@ -209,7 +209,8 @@ function applySensorBootstrap(plan) {
209
209
  if (plan.manifest.mode === 'project-sensors') {
210
210
  if (!plan.source)
211
211
  throw new Error('project-sensors bootstrap plan requires its resolved source');
212
- (0, materialize_1.materializePortableSensors)({ projectRoot: plan.projectRoot, pack: plan.manifest.pack, source: plan.source, ...(plan.manifest.packageRoot ? { packageRoot: plan.manifest.packageRoot } : {}), sensors: plan.manifest.sensors, configure: plan.configure !== false });
212
+ const packBound = (0, manifest_1.asPackBoundProjectSensors)(plan.manifest);
213
+ (0, materialize_1.materializePortableSensors)({ projectRoot: plan.projectRoot, pack: packBound.pack, source: plan.source, ...(packBound.packageRoot ? { packageRoot: packBound.packageRoot } : {}), sensors: packBound.sensors, configure: plan.configure !== false });
213
214
  return 'created';
214
215
  }
215
216
  const parsed = (0, manifest_1.parseSensorManifest)(plan.manifest, 'bootstrap declaration');
@@ -137,61 +137,65 @@ function resolveSemgrepPolicy(policyRef, source, location = 'policy') {
137
137
  }
138
138
  return parseSemgrepPolicy(parsed, candidate);
139
139
  }
140
- function parseStructuredCommand(input, source) {
141
- const value = record(input, source, 'command');
142
- fields(value, ['executable', 'resolution', 'args', 'packageManager', 'environment', 'fileInput', 'pythonEnvironmentRoot'], source, 'command');
143
- const executable = text(value.executable, source, 'command.executable');
140
+ function parseStructuredCommand(input, source, location = 'command') {
141
+ const value = record(input, source, location);
142
+ fields(value, ['executable', 'resolution', 'args', 'packageManager', 'environment', 'fileInput', 'pythonEnvironmentRoot'], source, location);
143
+ const executable = text(value.executable, source, `${location}.executable`);
144
144
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(executable) || SHELL_EXECUTABLES.has(executable.toLowerCase().replace(/\.exe$/, ''))) {
145
- invalid(source, 'command.executable must not be a shell or path');
145
+ invalid(source, `${location}.executable must not be a shell or path`);
146
146
  }
147
147
  if (value.resolution !== 'node-modules-bin' && value.resolution !== 'python-environment' && value.resolution !== 'path') {
148
- invalid(source, 'command.resolution must be a supported resolution');
148
+ invalid(source, `${location}.resolution must be a supported resolution`);
149
149
  }
150
- const args = stringArray(value.args, source, 'command.args');
150
+ const args = stringArray(value.args, source, `${location}.args`);
151
151
  for (const [index, arg] of args.entries()) {
152
152
  if (arg.includes('{files}') && arg !== '{files}')
153
- invalid(source, `command.args[${index}] must not embed {files}`);
153
+ invalid(source, `${location}.args[${index}] must not embed {files}`);
154
154
  }
155
155
  const command = { executable, resolution: value.resolution, args };
156
156
  if ('pythonEnvironmentRoot' in value) {
157
- if (value.resolution !== 'python-environment' || (value.pythonEnvironmentRoot !== '.venv' && value.pythonEnvironmentRoot !== 'venv'))
158
- invalid(source, 'command.pythonEnvironmentRoot must name the selected .venv or venv Python environment');
157
+ if (value.resolution !== 'python-environment' || (value.pythonEnvironmentRoot !== '.venv' && value.pythonEnvironmentRoot !== 'venv')) {
158
+ invalid(source, `${location}.pythonEnvironmentRoot must name the selected .venv or venv Python environment`);
159
+ }
159
160
  command.pythonEnvironmentRoot = value.pythonEnvironmentRoot;
160
161
  }
161
162
  const executablePackageManager = normalizedPackageManager(executable);
162
- if (PACKAGE_MANAGERS.has(executablePackageManager) && !('packageManager' in value))
163
- invalid(source, 'command.packageManager is required for a package-manager executable');
163
+ if (PACKAGE_MANAGERS.has(executablePackageManager) && !('packageManager' in value)) {
164
+ invalid(source, `${location}.packageManager is required for a package-manager executable`);
165
+ }
164
166
  if ('packageManager' in value) {
165
- if (typeof value.packageManager !== 'string' || !PACKAGE_MANAGERS.has(normalizedPackageManager(value.packageManager)))
166
- invalid(source, 'command.packageManager must be npm, pnpm, yarn, or bun');
167
+ if (typeof value.packageManager !== 'string' || !PACKAGE_MANAGERS.has(normalizedPackageManager(value.packageManager))) {
168
+ invalid(source, `${location}.packageManager must be npm, pnpm, yarn, or bun`);
169
+ }
167
170
  const packageManager = normalizedPackageManager(value.packageManager);
168
171
  if (packageManager !== executablePackageManager)
169
- invalid(source, 'command.packageManager must match executable');
172
+ invalid(source, `${location}.packageManager must match executable`);
170
173
  command.packageManager = packageManager;
171
174
  }
172
175
  if ('environment' in value) {
173
- const environment = record(value.environment, source, 'command.environment');
174
- fields(environment, ['ESLINT_USE_FLAT_CONFIG'], source, 'command.environment');
175
- if ((environment.ESLINT_USE_FLAT_CONFIG !== 'true' && environment.ESLINT_USE_FLAT_CONFIG !== 'false') || Object.keys(environment).length !== 1)
176
- invalid(source, 'command.environment must be the exact allowlisted ESLINT_USE_FLAT_CONFIG=true or false mapping');
176
+ const environment = record(value.environment, source, `${location}.environment`);
177
+ fields(environment, ['ESLINT_USE_FLAT_CONFIG'], source, `${location}.environment`);
178
+ if ((environment.ESLINT_USE_FLAT_CONFIG !== 'true' && environment.ESLINT_USE_FLAT_CONFIG !== 'false') || Object.keys(environment).length !== 1) {
179
+ invalid(source, `${location}.environment must be the exact allowlisted ESLINT_USE_FLAT_CONFIG=true or false mapping`);
180
+ }
177
181
  command.environment = { ESLINT_USE_FLAT_CONFIG: environment.ESLINT_USE_FLAT_CONFIG };
178
182
  }
179
183
  if ('fileInput' in value) {
180
- const fileInput = record(value.fileInput, source, 'command.fileInput');
181
- fields(fileInput, ['placeholder', 'extensions'], source, 'command.fileInput');
184
+ const fileInput = record(value.fileInput, source, `${location}.fileInput`);
185
+ fields(fileInput, ['placeholder', 'extensions'], source, `${location}.fileInput`);
182
186
  if (fileInput.placeholder !== '{files}')
183
- invalid(source, 'command.fileInput.placeholder must be {files}');
184
- const extensions = stringArray(fileInput.extensions, source, 'command.fileInput.extensions');
187
+ invalid(source, `${location}.fileInput.placeholder must be {files}`);
188
+ const extensions = stringArray(fileInput.extensions, source, `${location}.fileInput.extensions`);
185
189
  for (const [index, extension] of extensions.entries()) {
186
190
  if (!/^\.[A-Za-z0-9]+$/.test(extension))
187
- invalid(source, `command.fileInput.extensions[${index}] must be an extension`);
191
+ invalid(source, `${location}.fileInput.extensions[${index}] must be an extension`);
188
192
  }
189
193
  if (args.filter(arg => arg === '{files}').length !== 1)
190
- invalid(source, 'command.fileInput requires exactly one {files} argument');
194
+ invalid(source, `${location}.fileInput requires exactly one {files} argument`);
191
195
  command.fileInput = { placeholder: '{files}', extensions };
192
196
  }
193
197
  else if (args.includes('{files}')) {
194
- invalid(source, 'command {files} argument requires fileInput');
198
+ invalid(source, `${location} {files} argument requires fileInput`);
195
199
  }
196
200
  return command;
197
201
  }
@@ -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.discoverPresentMarkers = discoverPresentMarkers;
6
7
  exports.discoverProjectEvidence = discoverProjectEvidence;
7
8
  const fs_1 = __importDefault(require("fs"));
8
9
  const path_1 = __importDefault(require("path"));
@@ -21,6 +22,26 @@ function safeFile(root, relative) {
21
22
  return false;
22
23
  }
23
24
  }
25
+ /**
26
+ * Present contained marker files under cwd — same `safeFile` rules pack discovery
27
+ * uses when evaluating applicability allFiles/anyFiles.
28
+ */
29
+ function discoverPresentMarkers(cwd, markers) {
30
+ if (typeof cwd !== 'string' || cwd.trim() === '')
31
+ throw new Error('cwd must be a non-empty path');
32
+ if (!Array.isArray(markers))
33
+ throw new Error('markers must be an array');
34
+ let root;
35
+ try {
36
+ root = fs_1.default.realpathSync(cwd);
37
+ if (!fs_1.default.statSync(root).isDirectory())
38
+ throw new Error();
39
+ }
40
+ catch {
41
+ throw new Error(`cwd is not a readable project directory: ${cwd}`);
42
+ }
43
+ return [...new Set(markers.filter((marker) => typeof marker === 'string' && safeFile(root, marker)))].sort();
44
+ }
24
45
  function safeParts(parts) {
25
46
  return parts.length > 0 && parts.every(part => Boolean(part) && part !== '.' && part !== '..' && !part.includes('/') && !part.includes('\\'));
26
47
  }
@@ -3,7 +3,11 @@ 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.isProjectDeclaredSensor = isProjectDeclaredSensor;
7
+ exports.isPackBoundManifestSensor = isPackBoundManifestSensor;
8
+ exports.asPackBoundProjectSensors = asPackBoundProjectSensors;
6
9
  exports.legacyCompatibility = legacyCompatibility;
10
+ exports.parseSensorManifestWithIssues = parseSensorManifestWithIssues;
7
11
  exports.parseSensorManifest = parseSensorManifest;
8
12
  exports.serializeManifestV2 = serializeManifestV2;
9
13
  exports.serializeManifestV3 = serializeManifestV3;
@@ -11,6 +15,28 @@ const contract_1 = require("./contract");
11
15
  const timeout_1 = require("./timeout");
12
16
  const semver_1 = __importDefault(require("semver"));
13
17
  const path_1 = __importDefault(require("path"));
18
+ /** Registered CLI formatter ids plus exit-code (default for project-declared sensors). */
19
+ const PROJECT_FORMATTERS = new Set([
20
+ 'tsc', 'eslint-llm', 'semgrep', 'test', 'mypy', 'ruff', 'shellcheck', 'generic', 'exit-code',
21
+ ]);
22
+ function isProjectDeclaredSensor(sensor) {
23
+ return 'source' in sensor && sensor.source === 'project';
24
+ }
25
+ function isPackBoundManifestSensor(sensor) {
26
+ return !isProjectDeclaredSensor(sensor);
27
+ }
28
+ /** Narrow a parsed v3 manifest to the pack-bound shape expected by migrate/materialize. */
29
+ function asPackBoundProjectSensors(manifest) {
30
+ if (typeof manifest.pack !== 'string' || manifest.pack.length === 0 || !manifest.source) {
31
+ throw new Error('project-sensors manifest requires a nonempty pack and source.registry');
32
+ }
33
+ for (const [name, sensor] of Object.entries(manifest.sensors)) {
34
+ if (isProjectDeclaredSensor(sensor)) {
35
+ throw new Error(`sensors.${name} is project-declared; pack-bound operation required`);
36
+ }
37
+ }
38
+ return manifest;
39
+ }
14
40
  function isRecord(value) {
15
41
  return typeof value === 'object' && value !== null && !Array.isArray(value);
16
42
  }
@@ -93,6 +119,9 @@ function parseLegacySensor(input, source, location) {
93
119
  if (typeof input === 'string')
94
120
  return { cmd: text(input, source, `${location}.cmd`) };
95
121
  const value = record(input, source, location);
122
+ if (value.source === 'project') {
123
+ invalid(source, `${location}.source "project" requires schemaVersion 3; migrate-to-v3`);
124
+ }
96
125
  fields(value, ['cmd', 'fast', 'enabled', 'timeout', 'changedCmd', 'changedExtensions', 'formatter'], source, location);
97
126
  const sensor = {};
98
127
  if ('cmd' in value)
@@ -138,14 +167,96 @@ function parseLegacyManifest(value, source) {
138
167
  }
139
168
  return manifest;
140
169
  }
170
+ function parseApplicability(input, source, location) {
171
+ const applicabilityInput = record(input, source, location);
172
+ fields(applicabilityInput, ['allFiles', 'anyFiles', 'kind'], source, location);
173
+ const applicability = {};
174
+ if ('allFiles' in applicabilityInput) {
175
+ applicability.allFiles = stringArray(applicabilityInput.allFiles, source, `${location}.allFiles`, false)
176
+ .map((file, index) => asset(file, source, `${location}.allFiles[${index}]`));
177
+ }
178
+ if ('anyFiles' in applicabilityInput) {
179
+ applicability.anyFiles = stringArray(applicabilityInput.anyFiles, source, `${location}.anyFiles`, false)
180
+ .map((file, index) => asset(file, source, `${location}.anyFiles[${index}]`));
181
+ }
182
+ if ('kind' in applicabilityInput) {
183
+ const kind = text(applicabilityInput.kind, source, `${location}.kind`);
184
+ if (kind !== 'explicit-or-supported-language' && kind !== 'explicit-opt-in') {
185
+ invalid(source, `${location}.kind must be a supported applicability kind`);
186
+ }
187
+ applicability.kind = kind;
188
+ }
189
+ if (Object.keys(applicability).length === 0)
190
+ invalid(source, `${location} must declare a condition`);
191
+ return applicability;
192
+ }
193
+ function parseProjectSensor(input, source, location) {
194
+ const value = record(input, source, location);
195
+ if ('variantId' in value)
196
+ invalid(source, `${location}.variantId is forbidden on source:"project" entries`);
197
+ if ('initializedCompatibility' in value)
198
+ invalid(source, `${location}.initializedCompatibility is forbidden on source:"project" entries`);
199
+ fields(value, ['source', 'enabled', 'command', 'formatter', 'fast', 'timeout', 'assets', 'applicability', 'description'], source, location);
200
+ if (value.source !== 'project')
201
+ invalid(source, `${location}.source must be "project"`);
202
+ if (typeof value.enabled !== 'boolean')
203
+ invalid(source, `${location}.enabled must be a boolean`);
204
+ const sensor = {
205
+ source: 'project',
206
+ enabled: value.enabled,
207
+ command: (0, contract_1.parseStructuredCommand)(value.command, source, `${location}.command`),
208
+ formatter: 'exit-code',
209
+ };
210
+ if ('formatter' in value) {
211
+ const formatter = text(value.formatter, source, `${location}.formatter`);
212
+ if (!PROJECT_FORMATTERS.has(formatter))
213
+ invalid(source, `${location}.formatter is not a registered formatter id`);
214
+ sensor.formatter = formatter;
215
+ }
216
+ if ('assets' in value) {
217
+ sensor.assets = stringArray(value.assets, source, `${location}.assets`, true)
218
+ .map((entry, index) => asset(entry, source, `${location}.assets[${index}]`));
219
+ }
220
+ if ('applicability' in value)
221
+ sensor.applicability = parseApplicability(value.applicability, source, `${location}.applicability`);
222
+ if ('description' in value)
223
+ sensor.description = text(value.description, source, `${location}.description`);
224
+ if ('fast' in value) {
225
+ if (typeof value.fast !== 'boolean')
226
+ invalid(source, `${location}.fast must be a boolean`);
227
+ sensor.fast = value.fast;
228
+ }
229
+ if ('timeout' in value) {
230
+ try {
231
+ sensor.timeout = (0, timeout_1.positiveTimeout)(value.timeout, `${location}.timeout`);
232
+ }
233
+ catch (error) {
234
+ invalid(source, error instanceof Error ? error.message : `${location}.timeout must be a positive safe integer`);
235
+ }
236
+ }
237
+ return sensor;
238
+ }
239
+ function parseV3SensorEntry(input, source, location) {
240
+ const value = record(input, source, location);
241
+ if (value.source === 'project')
242
+ return parseProjectSensor(value, source, location);
243
+ const unmarkedCustom = !('variantId' in value) && !('initializedCompatibility' in value) && 'command' in value;
244
+ if (unmarkedCustom) {
245
+ invalid(source, `${location}.source is required for project-declared sensors; add source:"project"`);
246
+ }
247
+ return parseV2Sensor(input, source, location);
248
+ }
141
249
  function parseV2Sensor(input, source, location) {
142
250
  const value = record(input, source, location);
251
+ if (value.source === 'project') {
252
+ invalid(source, `${location}.source "project" requires schemaVersion 3; migrate-to-v3`);
253
+ }
143
254
  fields(value, ['enabled', 'fast', 'timeout', 'variantId', 'command', 'assets', 'policyRef', 'initializedCompatibility'], source, location);
144
255
  if (typeof value.enabled !== 'boolean')
145
256
  invalid(source, `${location}.enabled must be a boolean`);
146
257
  const sensor = {
147
258
  enabled: value.enabled, variantId: id(value.variantId, source, `${location}.variantId`),
148
- command: (0, contract_1.parseStructuredCommand)(value.command, source),
259
+ command: (0, contract_1.parseStructuredCommand)(value.command, source, `${location}.command`),
149
260
  initializedCompatibility: parseCompatibilityEvidence(value.initializedCompatibility, source, `${location}.initializedCompatibility`),
150
261
  };
151
262
  if (sensor.initializedCompatibility.variantId !== null && sensor.initializedCompatibility.variantId !== sensor.variantId)
@@ -226,24 +337,28 @@ function parseV2Manifest(value, source) {
226
337
  }
227
338
  return manifest;
228
339
  }
229
- function parseV3ProjectManifest(value, source) {
340
+ function parseV3ProjectManifestRoot(value, source) {
230
341
  fields(value, ['schemaVersion', 'mode', 'pack', 'packSelection', 'source', 'packageRoot', 'sensors', 'concurrency'], source, 'root');
231
- const sourceValue = record(value.source, source, 'source');
232
- fields(sourceValue, ['registry'], source, 'source');
233
- const sensorsInput = record(value.sensors, source, 'sensors');
234
- const sensors = {};
235
- for (const name of Object.keys(sensorsInput))
236
- sensors[id(name, source, 'sensor id')] = parseV2Sensor(sensorsInput[name], source, `sensors.${name}`);
342
+ const hasPackKey = 'pack' in value;
343
+ const pack = !hasPackKey ? undefined : value.pack === null ? null : id(value.pack, source, 'pack');
237
344
  const manifest = {
238
345
  schemaVersion: 3,
239
346
  mode: 'project-sensors',
240
- pack: id(value.pack, source, 'pack'),
241
- source: { registry: id(sourceValue.registry, source, 'source.registry') },
242
- sensors,
347
+ ...(hasPackKey ? { pack } : {}),
243
348
  };
349
+ if (typeof pack === 'string' && pack.length > 0) {
350
+ const sourceValue = record(value.source, source, 'source');
351
+ fields(sourceValue, ['registry'], source, 'source');
352
+ manifest.source = { registry: id(sourceValue.registry, source, 'source.registry') };
353
+ }
354
+ else if ('source' in value) {
355
+ invalid(source, 'source requires a nonempty pack id');
356
+ }
244
357
  if ('packSelection' in value) {
245
358
  if (value.packSelection !== 'explicit')
246
359
  invalid(source, 'packSelection must be "explicit" when present');
360
+ if (typeof pack !== 'string' || pack.length === 0)
361
+ invalid(source, 'packSelection requires a nonempty pack id');
247
362
  manifest.packSelection = 'explicit';
248
363
  }
249
364
  if ('packageRoot' in value)
@@ -255,26 +370,63 @@ function parseV3ProjectManifest(value, source) {
255
370
  }
256
371
  return manifest;
257
372
  }
373
+ function parseV3ProjectSensorsMap(sensorsInput, source, soft) {
374
+ const sensors = {};
375
+ const invalidEntries = [];
376
+ for (const name of Object.keys(sensorsInput)) {
377
+ const sensorName = id(name, source, 'sensor id');
378
+ try {
379
+ sensors[sensorName] = parseV3SensorEntry(sensorsInput[name], source, `sensors.${sensorName}`);
380
+ }
381
+ catch (error) {
382
+ const reason = error instanceof Error ? error.message : String(error);
383
+ if (!soft)
384
+ throw error instanceof Error ? error : new Error(reason);
385
+ invalidEntries.push({ name: sensorName, reason });
386
+ }
387
+ }
388
+ return { sensors, invalidEntries };
389
+ }
390
+ function parseV3ProjectManifest(value, source, soft) {
391
+ const root = parseV3ProjectManifestRoot(value, source);
392
+ const sensorsInput = record(value.sensors, source, 'sensors');
393
+ const { sensors, invalidEntries } = parseV3ProjectSensorsMap(sensorsInput, source, soft);
394
+ return { pack: { ...root, sensors }, invalidEntries };
395
+ }
258
396
  function parseV3Manifest(value, source) {
259
397
  if (value.schemaVersion !== 3)
260
398
  invalid(source, `unsupported manifest schemaVersion ${String(value.schemaVersion)}; supported: legacy, 2, 3; upgrade or migrate the manifest`);
261
399
  if (value.mode === 'project-sensors')
262
- return parseV3ProjectManifest(value, source);
400
+ return parseV3ProjectManifest(value, source, false).pack;
263
401
  if (value.mode !== 'native-gate' && value.mode !== 'opt-out')
264
402
  invalid(source, 'schemaVersion 3 mode must be "project-sensors", "native-gate", or "opt-out"');
265
403
  fields(value, ['schemaVersion', 'mode', 'reason'], source, 'root');
266
404
  return { schemaVersion: 3, mode: value.mode, reason: text(value.reason, source, 'reason') };
267
405
  }
268
- function parseSensorManifest(input, source) {
406
+ /** Soft per-entry parse for v3 project-sensors; document-level errors still throw. */
407
+ function parseSensorManifestWithIssues(input, source) {
269
408
  const value = record(input, source, 'root');
270
409
  if (!('schemaVersion' in value))
271
- return { kind: 'legacy', pack: parseLegacyManifest(value, source) };
410
+ return { kind: 'legacy', pack: parseLegacyManifest(value, source), invalidEntries: [] };
272
411
  if (value.schemaVersion === 2)
273
- return { kind: 'v2', pack: parseV2Manifest(value, source) };
274
- if (value.schemaVersion === 3)
275
- return { kind: 'v3', pack: parseV3Manifest(value, source) };
412
+ return { kind: 'v2', pack: parseV2Manifest(value, source), invalidEntries: [] };
413
+ if (value.schemaVersion === 3) {
414
+ if (value.mode === 'project-sensors') {
415
+ const parsed = parseV3ProjectManifest(value, source, true);
416
+ return { kind: 'v3', pack: parsed.pack, invalidEntries: parsed.invalidEntries };
417
+ }
418
+ return { kind: 'v3', pack: parseV3Manifest(value, source), invalidEntries: [] };
419
+ }
276
420
  invalid(source, `unsupported manifest schemaVersion ${String(value.schemaVersion)}; supported: legacy, 2, 3; upgrade or migrate the manifest`);
277
421
  }
422
+ function parseSensorManifest(input, source) {
423
+ const withIssues = parseSensorManifestWithIssues(input, source);
424
+ if (withIssues.invalidEntries.length > 0) {
425
+ throw new Error(withIssues.invalidEntries[0].reason);
426
+ }
427
+ const { invalidEntries: _invalidEntries, ...parsed } = withIssues;
428
+ return parsed;
429
+ }
278
430
  function serializeManifestV2(input) {
279
431
  const parsed = parseSensorManifest(input, 'manifest serialization');
280
432
  if (parsed.kind !== 'v2')
@@ -217,6 +217,23 @@ function priorAssets(projectRoot) {
217
217
  return Object.values(parsedManifest.pack.sensors).flatMap(sensor => sensor.assets ?? []);
218
218
  return [];
219
219
  }
220
+ /** Existing `"source": "project"` entries to keep when regenerating pack sensors (RF-3.1). */
221
+ function priorProjectSensors(projectRoot) {
222
+ const manifestPath = path_1.default.join(projectRoot, '.awm', 'sensors.json');
223
+ try {
224
+ const stat = fs_1.default.lstatSync(manifestPath, { bigint: true });
225
+ if (!stat.isFile() || stat.isSymbolicLink())
226
+ return {};
227
+ // Soft-parse so one invalid sibling does not drop every valid project entry.
228
+ const parsed = (0, manifest_1.parseSensorManifestWithIssues)(JSON.parse((0, safe_file_1.readInspectedBoundedFile)(manifestPath, stat, MAX_ASSET_BYTES, () => new Error('prior manifest unreadable')).toString('utf8')), manifestPath);
229
+ if (parsed.kind !== 'v3' || parsed.pack.mode !== 'project-sensors')
230
+ return {};
231
+ return Object.fromEntries(Object.entries(parsed.pack.sensors).filter(([, sensor]) => (0, manifest_1.isProjectDeclaredSensor)(sensor)));
232
+ }
233
+ catch {
234
+ return {};
235
+ }
236
+ }
220
237
  /** Materialize a previously selected logical registry source into a portable v3 manifest. */
221
238
  function materializePortableSensors(input) {
222
239
  if (!input || typeof input !== 'object')
@@ -237,18 +254,25 @@ function materializePortableSensors(input) {
237
254
  const parsed = (0, manifest_1.parseSensorManifest)(candidate, 'portable materialized manifest');
238
255
  if (parsed.kind !== 'v3' || parsed.pack.mode !== 'project-sensors')
239
256
  throw new Error('portable materialized manifest must be v3 project-sensors');
240
- const manifest = parsed.pack;
241
- for (const [name, sensor] of Object.entries(manifest.sensors)) {
242
- if (sensor.policyRef)
257
+ const packBound = (0, manifest_1.asPackBoundProjectSensors)(parsed.pack);
258
+ for (const [name, sensor] of Object.entries(packBound.sensors)) {
259
+ if ((0, manifest_1.isPackBoundManifestSensor)(sensor) && sensor.policyRef)
243
260
  (0, contract_1.resolveSemgrepPolicy)(sensor.policyRef, path_1.default.join(packRoot, 'pack.json'), `sensors.${name}`);
244
261
  }
245
- const selected = [...new Set(Object.values(manifest.sensors).flatMap(sensor => sensor.assets ?? []))].map((asset, index) => containedAsset(asset, `assets[${index}]`)).sort();
262
+ const priorProject = priorProjectSensors(projectRoot);
263
+ // Project entries win on name collision so regen never drops them (RF-3.1).
264
+ const manifest = {
265
+ ...packBound,
266
+ sensors: { ...packBound.sensors, ...priorProject },
267
+ };
268
+ const selected = [...new Set(Object.values(packBound.sensors).flatMap(sensor => sensor.assets ?? []))].map((asset, index) => containedAsset(asset, `assets[${index}]`)).sort();
246
269
  // Establish every registry authority and read every selected byte before the
247
270
  // first project mutation. A bad later asset must not strand an earlier one.
248
271
  const publications = input.configure === false ? [] : selected.map(asset => {
249
272
  const selectedSource = selectedRegistryAsset(packRoot, asset);
250
273
  return { asset, content: readSelectedRegistryAsset(packRoot, selectedSource, asset) };
251
274
  });
275
+ const manifestExists = fs_1.default.existsSync(path_1.default.join(projectRoot, '.awm', 'sensors.json'));
252
276
  return (0, safe_file_1.withProjectLease)(projectRoot, () => {
253
277
  const configRoot = manifest.packageRoot ? containedPackageRoot(projectRoot, manifest.packageRoot) : projectRoot;
254
278
  const prior = priorAssets(projectRoot);
@@ -273,7 +297,8 @@ function materializePortableSensors(input) {
273
297
  preserved.push(publication.asset);
274
298
  }
275
299
  }
276
- atomicWrite(projectRoot, '.awm/sensors.json', (0, manifest_1.serializeManifestV3)(manifest), true);
300
+ // Replace when regenerating over an existing manifest so project entries can be re-merged.
301
+ atomicWrite(projectRoot, '.awm/sensors.json', (0, manifest_1.serializeManifestV3)(manifest), !manifestExists);
277
302
  }
278
303
  catch (error) {
279
304
  for (const entry of created.reverse()) {
@@ -49,10 +49,11 @@ function resolveSensorSource(manifest, deps) {
49
49
  || !v3.source || typeof v3.source.registry !== 'string') {
50
50
  throw new Error('sensor source resolution requires a valid project-sensors manifest');
51
51
  }
52
- if (!/^[a-z][a-z0-9-]*$/.test(v3.source.registry)) {
52
+ const registryName = v3.source.registry;
53
+ if (!/^[a-z][a-z0-9-]*$/.test(registryName)) {
53
54
  throw new Error('sensor source registry must be a stable lowercase id');
54
55
  }
55
- const matches = registries.filter(entry => entry?.name === v3.source.registry);
56
+ const matches = registries.filter(entry => entry?.name === registryName);
56
57
  if (matches.length !== 1)
57
58
  return unavailable('registry-not-installed');
58
59
  const source = (0, pack_source_1.listPackSources)(v3.pack, { registries: matches })[0];
@@ -225,8 +225,9 @@ function assertCoverageEnvelope(report, renderer) {
225
225
  return;
226
226
  }
227
227
  if (staticReport.reason === 'no_reference') {
228
- if (report.overall !== 'inconclusive' || !isNonBlankString(report.pack) || !isNonBlankString(report.registry)
229
- || staticReport.classes.length !== 0) {
228
+ const packReferenced = isNonBlankString(report.pack) && isNonBlankString(report.registry);
229
+ const onlyProject = report.pack === null && report.registry === null;
230
+ if (report.overall !== 'inconclusive' || staticReport.classes.length !== 0 || (!packReferenced && !onlyProject)) {
230
231
  invalidReport(renderer);
231
232
  }
232
233
  if (report.empirical !== undefined) {
@@ -303,6 +304,11 @@ function renderCoverageHuman(report) {
303
304
  return ['Sensor coverage', 'Overall: inconclusive', 'Reason: sensors are not configured', 'Run: awm sensors init', ...(report.empirical ? [empiricalHuman(report)] : []), ''].join('\n');
304
305
  }
305
306
  if (report.static.reason === 'no_reference') {
307
+ if (report.pack === null && report.registry === null) {
308
+ return ['Sensor coverage', 'Pack: none', 'Overall: inconclusive',
309
+ 'No pack coverage reference (project-declared sensors only)',
310
+ ...(report.empirical ? [empiricalHuman(report)] : []), ''].join('\n');
311
+ }
306
312
  return ['Sensor coverage', `Pack: ${safeHumanText(report.pack ?? 'unknown')}`, `Registry: ${safeHumanText(report.registry ?? 'unknown')}`,
307
313
  'Overall: inconclusive', `No coverage reference for pack '${safeHumanText(report.pack ?? 'unknown')}'`, ...(report.empirical ? [empiricalHuman(report)] : []), ''].join('\n');
308
314
  }