agentic-workflow-manager 9.7.1 → 9.8.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 (78) hide show
  1. package/README.md +5 -1
  2. package/dist/src/commands/job/exec-wrapper.js +53 -3
  3. package/dist/src/commands/plan/index.js +124 -6
  4. package/dist/src/commands/sensors/bootstrap.js +36 -5
  5. package/dist/src/commands/sensors/compatibility/contract.js +19 -4
  6. package/dist/src/commands/sensors/compatibility/discovery.js +23 -3
  7. package/dist/src/commands/sensors/compatibility/live.js +18 -1
  8. package/dist/src/commands/sensors/compatibility/probe.js +8 -2
  9. package/dist/src/commands/sensors/compatibility/resolve.js +5 -2
  10. package/dist/src/commands/sensors/coverage/evidence.js +19 -10
  11. package/dist/src/commands/sensors/coverage/index.js +11 -2
  12. package/dist/src/commands/sensors/coverage/resolve.js +10 -0
  13. package/dist/src/commands/sensors/exec.js +16 -1
  14. package/dist/src/commands/sensors/migrate.js +62 -2
  15. package/dist/src/commands/sensors/prepare.js +2 -0
  16. package/dist/src/commands/sensors/result.js +2 -0
  17. package/dist/src/commands/sensors/run.js +64 -1
  18. package/dist/src/commands/sensors/status.js +6 -4
  19. package/dist/src/commands/watch/archive-unused.js +138 -0
  20. package/dist/src/commands/watch/generations.js +32 -9
  21. package/dist/src/commands/watch/index.js +108 -2
  22. package/dist/src/commands/watch/init.js +149 -18
  23. package/dist/src/commands/watch/supervisor.js +167 -13
  24. package/dist/src/commands/watch/teardown-driver.js +13 -13
  25. package/dist/src/commands/watch/track-state.js +59 -0
  26. package/dist/src/commands/watch/tracks.js +38 -102
  27. package/dist/src/core/admission/index.js +181 -0
  28. package/dist/src/core/admission/registry-contracts.js +290 -0
  29. package/dist/src/core/journal/fingerprint.js +35 -1
  30. package/dist/src/core/journal/paths.js +11 -0
  31. package/dist/src/core/journal/store.js +146 -4
  32. package/dist/src/core/journal/types.js +35 -1
  33. package/dist/src/core/migration/historical-provenance.js +18 -0
  34. package/dist/src/core/migration/index.js +322 -0
  35. package/dist/src/core/plan/identity.js +75 -0
  36. package/dist/src/core/plan/validate.js +178 -64
  37. package/dist/tests/commands/job/exec-wrapper.test.js +16 -0
  38. package/dist/tests/commands/job/gate-reconcile.test.js +24 -10
  39. package/dist/tests/commands/pin.test.js +23 -23
  40. package/dist/tests/commands/plan/index.test.js +630 -14
  41. package/dist/tests/commands/sensors/bootstrap.test.js +57 -4
  42. package/dist/tests/commands/sensors/compatibility/contract.test.js +21 -0
  43. package/dist/tests/commands/sensors/compatibility/discovery.test.js +19 -0
  44. package/dist/tests/commands/sensors/compatibility/probe.test.js +14 -1
  45. package/dist/tests/commands/sensors/compatibility/resolve.test.js +17 -0
  46. package/dist/tests/commands/sensors/coverage/evidence.test.js +51 -27
  47. package/dist/tests/commands/sensors/coverage/resolve.test.js +51 -0
  48. package/dist/tests/commands/sensors/exec.test.js +112 -0
  49. package/dist/tests/commands/sensors/run-changed.test.js +11 -0
  50. package/dist/tests/commands/sensors/run.test.js +15 -2
  51. package/dist/tests/commands/sensors/status.test.js +3 -3
  52. package/dist/tests/commands/watch/e2e-crash.test.js +126 -11
  53. package/dist/tests/commands/watch/generations.test.js +113 -4
  54. package/dist/tests/commands/watch/plan-lifecycle.test.js +263 -0
  55. package/dist/tests/commands/watch/supervisor-loop.test.js +247 -49
  56. package/dist/tests/commands/watch/track-finalize.test.js +20 -3
  57. package/dist/tests/commands/watch/track-freeze.test.js +43 -30
  58. package/dist/tests/commands/watch/track-state-dependency.test.js +15 -0
  59. package/dist/tests/commands/watch/watch-init.test.js +244 -0
  60. package/dist/tests/core/admission/index.test.js +87 -0
  61. package/dist/tests/core/journal/fingerprint.test.js +22 -0
  62. package/dist/tests/core/journal/store.test.js +53 -0
  63. package/dist/tests/core/journal/types.test.js +7 -0
  64. package/dist/tests/core/migration/facts.test.js +289 -0
  65. package/dist/tests/core/migration/historical-provenance.test.js +25 -0
  66. package/dist/tests/core/plan/validate.test.js +320 -6
  67. package/dist/tests/integration/pack-e2e.test.js +4 -1
  68. package/dist/tests/integration/preflight-json-pipe.e2e.test.js +7 -3
  69. package/dist/tests/integration/sensor-bootstrap.e2e.test.js +37 -0
  70. package/dist/tests/integration/sensor-structured-run.e2e.test.js +12 -10
  71. package/dist/tests/providers/injection-config.test.js +14 -6
  72. package/dist/tests/structural/jest-environment-is-isolated.test.js +29 -24
  73. package/dist/tests/structural/native-release-artifacts.test.js +16 -0
  74. package/package.json +2 -2
  75. package/prebuilds/darwin-arm64/secure_fs.node +0 -0
  76. package/prebuilds/darwin-x64/secure_fs.node +0 -0
  77. package/prebuilds/win32-arm64/secure_fs.node +0 -0
  78. package/prebuilds/win32-x64/secure_fs.node +0 -0
package/README.md CHANGED
@@ -41,9 +41,13 @@ This generates the runnable output under `dist/index.js`.
41
41
  ### 4. Running the Tests
42
42
  Before submitting a PR, ensure that the core discovery/executor functionality passes the existing test suite:
43
43
  ```bash
44
+ npm run native:build
44
45
  npm test
45
46
  ```
46
- The goal is to maintain 100% passing tests for any new CLI parameter flags.
47
+ `native:build` creates the ignored, host-specific `secure-fs` artifact required
48
+ by the integration fixtures; it must be run again after a clean checkout or a
49
+ change under `native/`. The goal is to maintain 100% passing tests for any new
50
+ CLI parameter flags.
47
51
 
48
52
  ### 5. Local Execution (Testing changes locally)
49
53
 
@@ -17,19 +17,33 @@ const fs_1 = __importDefault(require("fs"));
17
17
  const path_1 = __importDefault(require("path"));
18
18
  const child_process_1 = require("child_process");
19
19
  const process_1 = require("../../core/journal/process");
20
+ const paths_1 = require("../../core/journal/paths");
21
+ const types_1 = require("../../core/journal/types");
22
+ const native_bridge_1 = require("../../core/secure-fs/native-bridge");
20
23
  const fingerprint_1 = require("../../core/journal/fingerprint");
21
24
  const redact_1 = require("../../core/journal/redact");
22
25
  const atomic_file_1 = require("../../core/atomic-file");
26
+ function validateSidecarIdentity(logsRoot, jobId, nonce) {
27
+ if (typeof logsRoot !== 'string' || logsRoot.length === 0 || logsRoot.length > 4096 || /[\u0000-\u001F\u007F]/.test(logsRoot)
28
+ || typeof jobId !== 'string' || !/^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,127}$/.test(jobId)
29
+ || typeof nonce !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(nonce)
30
+ || jobId.length + nonce.length > 230)
31
+ throw new Error('unsafe or unbounded sidecar identity');
32
+ }
23
33
  function claimPath(logsRoot, jobId, nonce) {
34
+ validateSidecarIdentity(logsRoot, jobId, nonce);
24
35
  return path_1.default.join(logsRoot, `${jobId}.${nonce}.claim`);
25
36
  }
26
37
  function identityPath(logsRoot, jobId, nonce) {
38
+ validateSidecarIdentity(logsRoot, jobId, nonce);
27
39
  return path_1.default.join(logsRoot, `${jobId}.${nonce}.identity.json`);
28
40
  }
29
41
  function resultPath(logsRoot, jobId, nonce) {
42
+ validateSidecarIdentity(logsRoot, jobId, nonce);
30
43
  return path_1.default.join(logsRoot, `${jobId}.${nonce}.result.json`);
31
44
  }
32
45
  function logPath(logsRoot, jobId, nonce) {
46
+ validateSidecarIdentity(logsRoot, jobId, nonce);
33
47
  return path_1.default.join(logsRoot, `${jobId}.${nonce}.log`);
34
48
  }
35
49
  function replayVerdict(logsRoot, jobId, nonce) {
@@ -45,11 +59,46 @@ const MAX_LOG_BYTES = 1024 * 1024; // retencion acotada (R2.5)
45
59
  // dispara, tipicamente entregados en 1 tick del event loop) sin acercarse a
46
60
  // una espera perceptible si un descendiente hereda los fds y nunca cierra.
47
61
  const STDIO_GRACE_MS = 300;
62
+ /** Only a committed generation intent may suppress controller output. A user
63
+ * job with the same name retains normal verifier logs. Never persist model
64
+ * output (which may include prompts/source bodies) in generation custody. */
65
+ function isCommittedController(logsRoot, repoRoot, jobId, nonce, argv) {
66
+ if (!/^controller-gen-[1-9][0-9]*$/.test(jobId))
67
+ return false;
68
+ try {
69
+ const bytes = native_bridge_1.secureFs.readRegularFile(path_1.default.resolve(logsRoot, '..', 'state.json'), 1024 * 1024).bytes;
70
+ const state = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
71
+ if (!(0, types_1.isWellFormedState)(state))
72
+ throw new Error('controller custody journal invalid');
73
+ const actual = fs_1.default.lstatSync(logsRoot, { bigint: true });
74
+ const expected = fs_1.default.lstatSync((0, paths_1.logsDir)(repoRoot, state.branch), { bigint: true });
75
+ if (!actual.isDirectory() || actual.isSymbolicLink() || !expected.isDirectory() || expected.isSymbolicLink()
76
+ || actual.ino === 0n || actual.dev !== expected.dev || actual.ino !== expected.ino)
77
+ throw new Error('controller custody directory identity invalid');
78
+ const committed = state.generations.some(gen => gen.controllerJobId === jobId && jobId === `controller-gen-${gen.n}`
79
+ && gen.spawnNonce === nonce && gen.resumePrompt === undefined && (0, types_1.isControllerRecoveryAction)(gen.resumeAction)
80
+ && gen.launchArgvDigest === (0, process_1.argvDigest)(argv));
81
+ if (committed)
82
+ return true;
83
+ const ordinary = state.jobs[jobId];
84
+ if (ordinary?.spawnNonce === nonce && (0, process_1.argvDigest)(ordinary.argv) === (0, process_1.argvDigest)(argv))
85
+ return false;
86
+ throw new Error('controller custody launch identity unproven');
87
+ }
88
+ catch {
89
+ throw new Error('controller custody identity unavailable or unsafe: refusing launch before claim');
90
+ }
91
+ }
48
92
  async function runExecWrapper(opts) {
49
93
  const { logsRoot, jobId, nonce, argv, cwd } = opts;
94
+ validateSidecarIdentity(logsRoot, jobId, nonce);
50
95
  const repoRoot = opts.repoRoot ?? process.cwd();
51
- if (argv.length === 0)
52
- throw new Error('argv vacio');
96
+ if (!Array.isArray(argv) || argv.length === 0 || argv.length > 256
97
+ || argv.some(argument => typeof argument !== 'string' || argument.length > 65536 || argument.includes('\0'))
98
+ || argv[0].length === 0 || argv.reduce((bytes, argument) => bytes + Buffer.byteLength(argument, 'utf8'), 0) > 1024 * 1024)
99
+ throw new Error('argv invalid or unbounded');
100
+ const safeCwd = (0, fingerprint_1.resolveWorkingDirectory)(repoRoot, cwd).absolute;
101
+ const omitControllerOutput = isCommittedController(logsRoot, repoRoot, jobId, nonce, argv);
53
102
  fs_1.default.mkdirSync(logsRoot, { recursive: true, mode: 0o700 });
54
103
  // (1) claim exclusivo DURABLE — wx + fsync de archivo y de directorio
55
104
  let fd;
@@ -77,7 +126,6 @@ async function runExecWrapper(opts) {
77
126
  // process group por job, independiente del supervisor (R4.7: shell:false,
78
127
  // argv como array, secretos solo por referencia de entorno).
79
128
  const [exe, ...args] = argv;
80
- const safeCwd = (0, fingerprint_1.resolveWorkingDirectory)(repoRoot, cwd).absolute;
81
129
  const child = (0, child_process_1.spawn)(exe, args, {
82
130
  cwd: safeCwd, shell: false, detached: true,
83
131
  env: { ...process.env, [process_1.NONCE_ENV]: nonce },
@@ -105,6 +153,8 @@ async function runExecWrapper(opts) {
105
153
  const outputChunks = [];
106
154
  const logFile = logPath(logsRoot, jobId, nonce);
107
155
  const capture = (chunk) => {
156
+ if (omitControllerOutput)
157
+ return;
108
158
  if (captured >= MAX_LOG_BYTES)
109
159
  return;
110
160
  const accepted = chunk.subarray(0, MAX_LOG_BYTES - captured);
@@ -1,13 +1,39 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.exitCodeFor = exitCodeFor;
4
7
  exports.formatReport = formatReport;
5
8
  exports.registerPlanCommand = registerPlanCommand;
6
9
  const validate_1 = require("../../core/plan/validate");
10
+ const admission_1 = require("../../core/admission");
11
+ const check_1 = require("../../core/currentness/check");
12
+ const run_1 = require("../sensors/run");
13
+ const config_1 = require("../../utils/config");
14
+ const providers_1 = require("../../providers");
15
+ const registries_1 = require("../../core/registries");
16
+ const registry_contracts_1 = require("../../core/admission/registry-contracts");
17
+ const path_1 = __importDefault(require("path"));
18
+ const child_process_1 = require("child_process");
19
+ const store_1 = require("../../core/journal/store");
20
+ const migration_1 = require("../../core/migration");
7
21
  const SUPPORTED_SCHEMA = 'compact-slices/v1';
8
22
  const MAX_PATH_LENGTH = 4096;
9
23
  const MAX_DIAGNOSTICS = 20;
10
24
  const MAX_DIAGNOSTIC_LENGTH = 4096;
25
+ function journalObservation(cwd) {
26
+ try {
27
+ const branch = (0, child_process_1.execFileSync)('git', ['branch', '--show-current'], { cwd, encoding: 'utf8', stdio: 'pipe' }).trim();
28
+ if (!branch)
29
+ return { journalState: null, journalCorrupt: true };
30
+ const journal = (0, store_1.readJournal)(cwd, branch);
31
+ return { journalState: journal.state, journalCorrupt: journal.corrupt };
32
+ }
33
+ catch {
34
+ return { journalState: null, journalCorrupt: true };
35
+ }
36
+ }
11
37
  function assertText(value, name) {
12
38
  if (typeof value !== 'string' || value.length === 0 || value.length > MAX_PATH_LENGTH || value.startsWith('--') || /[\u0000-\u001F\u007F-\u009F]/.test(value)) {
13
39
  throw new Error(`${name} must be a non-empty path without control characters`);
@@ -19,6 +45,15 @@ function terminalSafe(value) {
19
45
  function assertDependencies(deps) {
20
46
  if (!deps || typeof deps.validatePlanFile !== 'function')
21
47
  throw new Error('validatePlanFile must be a function');
48
+ if (deps.admitPlan !== undefined && typeof deps.admitPlan !== 'function')
49
+ throw new Error('admitPlan must be a function');
50
+ }
51
+ function admissionOutput(report, json) {
52
+ report = (0, admission_1.sanitizeAdmissionReport)(report);
53
+ if (json)
54
+ return `${JSON.stringify(report)}\n`;
55
+ const diagnostic = report.diagnostics[0];
56
+ return `Plan admission: ${report.state}${report.provider ? ` (${terminalSafe(report.provider)})` : ''}${diagnostic ? `\n- ${terminalSafe(diagnostic.code)}: ${terminalSafe(diagnostic.message)}` : ''}\n`;
22
57
  }
23
58
  function boundedDiagnostics(diagnostics) {
24
59
  if (!Array.isArray(diagnostics))
@@ -34,16 +69,42 @@ function boundedDiagnostics(diagnostics) {
34
69
  };
35
70
  });
36
71
  }
72
+ function assertReport(report) {
73
+ if (!report || typeof report !== 'object' || Array.isArray(report))
74
+ throw new Error('plan validator returned an invalid report');
75
+ switch (report.state) {
76
+ case 'valid':
77
+ (0, validate_1.assertVerifiedValidPlanReport)(report);
78
+ return;
79
+ case 'migration-required':
80
+ if (report.reason !== 'unmarked-plan')
81
+ throw new Error('plan validator returned an invalid migration reason');
82
+ return;
83
+ case 'invalid':
84
+ boundedDiagnostics(report.diagnostics);
85
+ return;
86
+ case 'unsupported':
87
+ if (typeof report.schema !== 'string' || report.schema.length === 0 || report.schema.length > MAX_PATH_LENGTH) {
88
+ throw new Error('plan validator returned an invalid unsupported schema');
89
+ }
90
+ boundedDiagnostics(report.diagnostics);
91
+ return;
92
+ default:
93
+ throw new Error('plan validator returned an unknown report state');
94
+ }
95
+ }
37
96
  function reportPayload(report, planPath) {
97
+ assertReport(report);
38
98
  switch (report.state) {
39
99
  case 'valid':
40
100
  return {
41
101
  state: report.state, path: planPath, schema: report.schema, planId: report.manifest.planId,
102
+ planDigest: report.planDigest,
42
103
  requirements: report.manifest.requirements.length, sources: report.manifest.sources.length,
43
104
  commands: report.manifest.commands.length, slices: report.manifest.slices.length, completeOwnership: true,
44
105
  };
45
- case 'legacy':
46
- return { state: report.state, path: planPath, message: 'existing full-quality path applies; compact optimization was not requested.' };
106
+ case 'migration-required':
107
+ return { state: report.state, path: planPath, reason: report.reason };
47
108
  case 'invalid':
48
109
  return { state: report.state, path: planPath, diagnostics: boundedDiagnostics(report.diagnostics) };
49
110
  case 'unsupported':
@@ -56,15 +117,16 @@ function reportPayload(report, planPath) {
56
117
  }
57
118
  }
58
119
  function exitCodeFor(report) {
59
- return report.state === 'valid' || report.state === 'legacy' ? 0 : 2;
120
+ assertReport(report);
121
+ return report.state === 'valid' ? 0 : 2;
60
122
  }
61
123
  function formatReport(report, planPath) {
62
124
  const payload = reportPayload(report, planPath);
63
125
  switch (report.state) {
64
126
  case 'valid':
65
127
  return `Plan validation: valid "${terminalSafe(planPath)}" (${terminalSafe(report.schema)}; ${payload.slices} slices; ${payload.requirements} requirements; complete ownership)\n`;
66
- case 'legacy':
67
- return `Plan validation: legacy "${terminalSafe(planPath)}" — existing full-quality path applies; compact optimization was not requested.\n`;
128
+ case 'migration-required':
129
+ return `Plan validation: migration-required "${terminalSafe(planPath)}" (${terminalSafe(report.reason)})\nMigrate this plan to compact-slices/v1 before execution.\n`;
68
130
  case 'invalid':
69
131
  return `Plan validation: invalid "${terminalSafe(planPath)}"\n${payload.diagnostics.map(diagnostic => `- ${terminalSafe(diagnostic.code)}: ${terminalSafe(diagnostic.message)}${diagnostic.field ? ` (${terminalSafe(diagnostic.field)})` : ''}`).join('\n')}\n`;
70
132
  case 'unsupported':
@@ -73,11 +135,25 @@ function formatReport(report, planPath) {
73
135
  throw new Error('plan validator returned an unknown report state');
74
136
  }
75
137
  }
76
- function registerPlanCommand(program, deps = { validatePlanFile: validate_1.validatePlanFile }) {
138
+ function registerPlanCommand(program, deps = { validatePlanFile: validate_1.validatePlanFile, admitPlan: admission_1.admitPlan }) {
77
139
  if (!program || typeof program.command !== 'function')
78
140
  throw new Error('program must be a Commander command');
79
141
  assertDependencies(deps);
80
142
  const plan = program.command('plan').description('inspect plan contracts');
143
+ plan.command('migration-facts <plan-path>')
144
+ .description('collect read-only durable migration facts')
145
+ .option('--cwd <path>')
146
+ .option('--historical-root <path>', 'admitted sibling #148 worktree')
147
+ .requiredOption('--issue <https-url...>', 'durable issue links, including #126')
148
+ .option('--json')
149
+ .action((planPath, options) => {
150
+ assertText(planPath, 'plan path');
151
+ const cwd = options.cwd ?? process.cwd();
152
+ assertText(cwd, '--cwd');
153
+ const report = options.historicalRoot ? (0, migration_1.collectIssue148HistoricalFacts)(options.historicalRoot, options.issue) : (deps.collectMigrationFacts ?? migration_1.collectMigrationFacts)(planPath, cwd, options.issue);
154
+ process.stdout.write(options.json ? `${JSON.stringify(report)}\n` : `Migration facts: ${report.state}\n`);
155
+ process.exitCode = report.state === 'supported-completion' ? 0 : 2;
156
+ });
81
157
  plan
82
158
  .command('validate <plan-path>')
83
159
  .description('validate a compact plan without modifying it')
@@ -95,4 +171,46 @@ function registerPlanCommand(program, deps = { validatePlanFile: validate_1.vali
95
171
  const code = exitCodeFor(report);
96
172
  process.exitCode = code;
97
173
  });
174
+ plan
175
+ .command('admit <plan-path>')
176
+ .description('read-only fail-closed compact-plan admission')
177
+ .requiredOption('--provider <target>', 'target provider')
178
+ .requiredOption('--cwd <path>', 'repository root for plan containment and source resolution')
179
+ .option('--execution-mode <mode>', 'explicit override: interactivo or desatendido')
180
+ .option('--require-current', 'require authoritative consumed-contract currentness')
181
+ .option('--verify-sensors', 'require an empirical sensor pass')
182
+ .option('--json', 'emit one stable JSON report')
183
+ .action(async (planPath, options) => {
184
+ assertText(planPath, 'plan path');
185
+ assertText(options.cwd, '--cwd');
186
+ assertText(options.provider, '--provider');
187
+ const admission = deps.admitPlan ?? admission_1.admitPlan;
188
+ const preferences = deps.readPreferences ?? config_1.readPreferences;
189
+ const currentnessCheck = deps.checkCurrentness ?? check_1.checkCurrentness;
190
+ const sensorRun = deps.runSensors ?? run_1.runSensors;
191
+ const registryInventory = deps.listRegistries ?? registries_1.listRegistries;
192
+ const planReport = deps.validatePlanFile(planPath, options.cwd);
193
+ // The mode comes from the same authenticated bytes/digest that produced
194
+ // planReport. Never reopen the path to parse a mutable header.
195
+ const executionMode = options.executionMode === undefined
196
+ ? planReport.state === 'valid' ? (planReport.executionMode ?? 'interactivo') : 'interactivo'
197
+ : options.executionMode === 'desatendido' ? 'desatendido' : options.executionMode === 'interactivo' ? 'interactivo' : options.executionMode;
198
+ const journal = executionMode === 'desatendido' ? journalObservation(options.cwd) : {};
199
+ const normalizedPlanPath = path_1.default.relative(options.cwd, path_1.default.resolve(options.cwd, planPath)).replace(/\\/g, '/');
200
+ const enabledAgents = preferences().enabledAgents;
201
+ // The documented order is plan, provider, currentness, sensors, journal,
202
+ // capabilities. Do not call the full composer early: that would resolve a
203
+ // capability before empirical evidence gates have had their ordered turn.
204
+ const earlyBoundary = planReport.state !== 'valid' || !(0, providers_1.isAgentTarget)(options.provider) || !enabledAgents.includes(options.provider);
205
+ if (earlyBoundary) {
206
+ const report = await admission({ plan: planReport, provider: options.provider, cwd: options.cwd, enabledAgents, executionMode, planPath: normalizedPlanPath, ...journal });
207
+ process.stdout.write(admissionOutput(report, options.json === true));
208
+ process.exitCode = 2;
209
+ return;
210
+ }
211
+ const report = await (0, registry_contracts_1.admitRegistryPlan)({ plan: planReport, provider: options.provider, cwd: options.cwd, enabledAgents, executionMode, planPath: normalizedPlanPath, ...journal, requireCurrent: options.requireCurrent === true, verifySensors: options.verifySensors === true }, { admitPlan: admission, listRegistries: registryInventory, checkCurrentness: currentnessCheck, runSensors: sensorRun });
212
+ process.stdout.write(admissionOutput(report, options.json === true));
213
+ if (report.state !== 'admitted')
214
+ process.exitCode = 2;
215
+ });
98
216
  }
@@ -51,7 +51,7 @@ function declaration(mode, reason) {
51
51
  return { schemaVersion: 3, mode, reason };
52
52
  }
53
53
  function digest(bytes) { return (0, crypto_1.createHash)('sha256').update(bytes).digest('hex'); }
54
- async function projectSensors(projectRoot, registryRoot, requestedPack, packageRoot) {
54
+ async function projectSensors(projectRoot, registryRoot, requestedPack, packageRoot, explicitPackSelection = requestedPack !== undefined) {
55
55
  const detectionRoot = packageRoot ? path_1.default.resolve(projectRoot, packageRoot) : projectRoot;
56
56
  const detected = (0, detection_1.detectStack)(detectionRoot);
57
57
  const pack = requestedPack ?? detected.pack;
@@ -76,7 +76,9 @@ async function projectSensors(projectRoot, registryRoot, requestedPack, packageR
76
76
  return { reason: 'source-unsupported', remedy: 'install-a-v2-sensor-pack' };
77
77
  let live;
78
78
  try {
79
- live = await (0, live_1.resolveParsedPackCompatibility)(detectionRoot, parsed.pack);
79
+ live = explicitPackSelection
80
+ ? await (0, live_1.resolveParsedPackCompatibility)(detectionRoot, parsed.pack, { packSelection: 'explicit' })
81
+ : await (0, live_1.resolveParsedPackCompatibility)(detectionRoot, parsed.pack);
80
82
  }
81
83
  catch {
82
84
  return { reason: 'compatibility-unresolvable', remedy: 'repair-project-tools-or-select-another-mode' };
@@ -84,6 +86,8 @@ async function projectSensors(projectRoot, registryRoot, requestedPack, packageR
84
86
  const sensors = {};
85
87
  for (const [name, packSensor] of Object.entries(live.pack.sensors)) {
86
88
  const evidence = live.sensors[name];
89
+ if (evidence.state === 'not-applicable')
90
+ continue;
87
91
  const variant = evidence.variantId === null ? undefined : packSensor.variants.find(item => item.id === evidence.variantId);
88
92
  if (!variant)
89
93
  return { reason: 'sensor-variant-unresolvable', remedy: 'repair-project-tools-or-select-another-mode' };
@@ -109,8 +113,32 @@ async function planSensorBootstrap(cwd = process.cwd(), input = {}) {
109
113
  }
110
114
  return { kind: 'noop', projectRoot: project.projectRoot, manifestPath: project.manifestPath, changes: [], dryRun: opts.dryRun };
111
115
  }
112
- if (project.manifest.kind === 'legacy')
113
- return blocked(project.projectRoot, project.manifestPath, opts.dryRun, 'legacy-v1-preserved', 'migrate-the-manifest-explicitly');
116
+ if (project.manifest.kind === 'legacy') {
117
+ // Legacy manifests can carry arbitrary commands and per-sensor
118
+ // semantics that a v3 pack cannot prove equivalent. Replacing
119
+ // them is therefore a deliberate opt-in, never an automatic
120
+ // side effect of inspection or an unqualified bootstrap.
121
+ if (opts.mode !== 'project-sensors') {
122
+ return blocked(project.projectRoot, project.manifestPath, opts.dryRun, 'legacy-replacement-requires-project-sensors-mode', 'rerun-bootstrap-with-mode-project-sensors');
123
+ }
124
+ // The legacy pack name identifies the source to migrate, but must not
125
+ // activate v2 sensors which require an explicit project opt-in.
126
+ const planned = await projectSensors(project.projectRoot, opts.registryRoot, project.manifest.pack.pack, undefined, false);
127
+ if ('reason' in planned)
128
+ return blocked(project.projectRoot, project.manifestPath, opts.dryRun, planned.reason, planned.remedy, 'candidates' in planned ? planned.candidates : undefined);
129
+ try {
130
+ const original = fs_1.default.readFileSync(project.manifestPath);
131
+ return {
132
+ kind: 'migrate', projectRoot: project.projectRoot, manifestPath: project.manifestPath,
133
+ changes: planned.changes.map((change, index) => index === 0 ? { ...change, action: 'replace' } : change),
134
+ dryRun: opts.dryRun, migration: { kind: 'legacy-v1', manifest: planned.manifest }, source: planned.source,
135
+ originalDigest: digest(original),
136
+ };
137
+ }
138
+ catch {
139
+ return blocked(project.projectRoot, project.manifestPath, opts.dryRun, 'legacy-manifest-unreadable', 'repair-the-manifest-before-bootstrap');
140
+ }
141
+ }
114
142
  let source;
115
143
  try {
116
144
  source = (0, source_1.resolveSensorSource)(project.manifest, { registries: (0, registries_1.listRegistries)() });
@@ -152,7 +180,10 @@ function applySensorBootstrap(plan) {
152
180
  const current = fs_1.default.readFileSync(plan.manifestPath);
153
181
  if (digest(current) !== plan.originalDigest)
154
182
  throw new Error('bootstrap migration plan is stale: manifest changed after planning');
155
- (0, migrate_2.replaceV2ManifestWithV3)(plan.manifestPath, plan.migration.candidate, plan.source);
183
+ if ('kind' in plan.migration)
184
+ (0, migrate_1.replaceLegacyManifestWithV3)(plan.manifestPath, plan.migration.manifest, plan.source);
185
+ else
186
+ (0, migrate_2.replaceV2ManifestWithV3)(plan.manifestPath, plan.migration.candidate, plan.source);
156
187
  return 'migrated';
157
188
  }
158
189
  if (plan.manifest.mode === 'project-sensors') {
@@ -216,10 +216,21 @@ function parseVariant(input, source, location) {
216
216
  if (semver_1.default.validRange(toolRange) === null || semver_1.default.validRange(runtimeRange) === null)
217
217
  invalid(source, `${location}.requirements ranges must be valid semver ranges`);
218
218
  const probe = policy ? undefined : record(value.probe, source, `${location}.probe`);
219
+ let probeScript;
219
220
  if (probe) {
220
- fields(probe, ['kind'], source, `${location}.probe`);
221
+ fields(probe, ['kind', 'script'], source, `${location}.probe`);
221
222
  if (typeof probe.kind !== 'string' || !ALLOWED_PROBES.has(probe.kind))
222
223
  invalid(source, `${location}.probe.kind must be an allowed probe`);
224
+ // Historical schema-v2 packs remain readable. An unnamed probe cannot
225
+ // certify compatibility; the probe runner rejects it as unverifiable.
226
+ if (probe.kind === 'package-script-present' && 'script' in probe) {
227
+ probeScript = text(probe.script, source, `${location}.probe.script`);
228
+ if (!/^[A-Za-z0-9][A-Za-z0-9:_-]*$/.test(probeScript))
229
+ invalid(source, `${location}.probe.script must be a package script name`);
230
+ }
231
+ else if (probe.kind !== 'package-script-present' && 'script' in probe) {
232
+ invalid(source, `${location}.probe.script is only valid for package-script-present`);
233
+ }
223
234
  }
224
235
  const changedCommand = 'changedCommand' in value ? parseStructuredCommand(value.changedCommand, source) : undefined;
225
236
  if (changedCommand && !changedCommand.fileInput)
@@ -234,7 +245,7 @@ function parseVariant(input, source, location) {
234
245
  invalid(source, `${location}.requirements.packageJsonFields[${index}] must be a stable package.json field`); return field; }) } : {}) },
235
246
  assets: assetArray(value.assets, source, `${location}.assets`, true),
236
247
  formatter: text(value.formatter, source, `${location}.formatter`),
237
- probe: { kind: policy?.probe ?? probe.kind },
248
+ probe: { kind: policy?.probe ?? probe.kind, ...(probeScript ? { script: probeScript } : {}) },
238
249
  ...(policy ? { policyRef: SEMGREP_POLICY_REF } : {}),
239
250
  command: parseStructuredCommand(value.command, source),
240
251
  ...(changedCommand ? { changedCommand } : {}),
@@ -295,8 +306,12 @@ function parseSensor(input, source, location, variantIds) {
295
306
  applicability.allFiles = stringArray(applicabilityInput.allFiles, source, `${location}.applicability.allFiles`).map((file, index) => asset(file, source, `${location}.applicability.allFiles[${index}]`));
296
307
  if ('anyFiles' in applicabilityInput)
297
308
  applicability.anyFiles = stringArray(applicabilityInput.anyFiles, source, `${location}.applicability.anyFiles`).map((file, index) => asset(file, source, `${location}.applicability.anyFiles[${index}]`));
298
- if ('kind' in applicabilityInput)
299
- applicability.kind = text(applicabilityInput.kind, source, `${location}.applicability.kind`);
309
+ if ('kind' in applicabilityInput) {
310
+ const kind = text(applicabilityInput.kind, source, `${location}.applicability.kind`);
311
+ if (kind !== 'explicit-or-supported-language' && kind !== 'explicit-opt-in')
312
+ invalid(source, `${location}.applicability.kind must be a supported applicability kind`);
313
+ applicability.kind = kind;
314
+ }
300
315
  if (Object.keys(applicability).length === 0)
301
316
  invalid(source, `${location}.applicability must declare a condition`);
302
317
  if ('fast' in value && typeof value.fast !== 'boolean')
@@ -176,6 +176,7 @@ function discoverProjectEvidence(cwd, pack, dependencies = {}) {
176
176
  const declaredManager = typeof pkg?.packageManager === 'string' ? pkg.packageManager.split('@')[0] : null;
177
177
  if (declaredManager)
178
178
  lockManagers.add(declaredManager);
179
+ const packageManager = declaredManager ?? (lockManagers.size === 1 ? [...lockManagers][0] : null);
179
180
  const configCandidates = new Set(COMMON_CONFIGS);
180
181
  if ('schemaVersion' in pack) {
181
182
  for (const marker of pack.detects)
@@ -193,16 +194,35 @@ function discoverProjectEvidence(cwd, pack, dependencies = {}) {
193
194
  const packageJsonFields = Object.keys(pkg ?? {}).filter(field => /^[A-Za-z][A-Za-z0-9]*$/.test(field)).sort();
194
195
  const declaredToolRanges = { ...stringMap(pkg?.dependencies), ...stringMap(pkg?.devDependencies), ...stringMap(pkg?.peerDependencies) };
195
196
  const tools = new Set();
197
+ const pathTools = new Set();
196
198
  if ('schemaVersion' in pack)
197
199
  for (const sensor of Object.values(pack.sensors))
198
- for (const variant of sensor.variants)
200
+ for (const variant of sensor.variants) {
199
201
  tools.add(variant.requirements.tool);
202
+ if (variant.command?.resolution === 'path' && variant.command.packageManager === variant.requirements.tool)
203
+ pathTools.add(variant.requirements.tool);
204
+ }
200
205
  const environment = pythonEnvironment(root);
201
206
  const sitePackages = environment ? pythonSitePackages(root, environment.rootParts, targetPlatform) : [];
202
- const toolVersions = Object.fromEntries([...tools].sort().map(tool => [tool, pythonToolVersion(root, sitePackages, tool) ?? installedPackageVersion(root, tool)]));
207
+ const toolVersions = {};
208
+ const toolProvenance = {};
209
+ for (const tool of [...tools].sort()) {
210
+ if (pathTools.has(tool) && packageManager === tool) {
211
+ // Discovery/status is read-only. A PATH executable is not inspected or
212
+ // executed here; callers that already possess trusted runtime evidence
213
+ // may inject it explicitly for an execution-time compatibility check.
214
+ toolVersions[tool] = exactVersion(dependencies.pathToolVersion?.(tool) ?? null);
215
+ toolProvenance[tool] = toolVersions[tool] === null ? null : 'path';
216
+ }
217
+ else {
218
+ const pythonVersion = pythonToolVersion(root, sitePackages, tool);
219
+ toolVersions[tool] = pythonVersion ?? installedPackageVersion(root, tool);
220
+ toolProvenance[tool] = pythonVersion ? 'python-environment' : toolVersions[tool] ? 'node-modules-bin' : null;
221
+ }
222
+ }
203
223
  return {
204
224
  cwd: root, os: targetPlatform, runtimeVersions: { node: process.versions.node ?? null, ...(environment ? { python: environment.runtimeVersion } : {}) }, pythonEnvironmentRoot: environment?.rootParts[0] ?? null, declaredToolRanges, toolVersions,
205
- packageManager: declaredManager ?? (lockManagers.size === 1 ? [...lockManagers][0] : null), packageManagerConflict: lockManagers.size > 1,
225
+ toolProvenance, packageManager, packageManagerConflict: lockManagers.size > 1,
206
226
  scripts, configFiles, packageJsonFields, paths: [...new Set([...(safeFile(root, 'package.json') ? ['package.json'] : []), ...locks, ...configFiles])].sort(),
207
227
  };
208
228
  }
@@ -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.bindContainedRuntimeCommands = bindContainedRuntimeCommands;
6
7
  exports.resolveLiveCompatibility = resolveLiveCompatibility;
7
8
  exports.resolveParsedPackCompatibility = resolveParsedPackCompatibility;
8
9
  const pack_source_1 = require("./pack-source");
@@ -11,6 +12,22 @@ const discovery_1 = require("./discovery");
11
12
  const resolve_1 = require("./resolve");
12
13
  const probe_1 = require("./probe");
13
14
  const path_1 = __importDefault(require("path"));
15
+ const child_process_1 = require("child_process");
16
+ const semver_1 = __importDefault(require("semver"));
17
+ /** Execution-time evidence only. Status/discovery never invokes this resolver. */
18
+ function pathPackageManagerVersion(tool) {
19
+ if (!['npm', 'pnpm', 'yarn', 'bun'].includes(tool))
20
+ return null;
21
+ try {
22
+ const result = (0, child_process_1.spawnSync)(tool, ['--version'], { cwd: process.cwd(), shell: false, encoding: 'utf8', timeout: 5_000, maxBuffer: 1024, windowsHide: true, stdio: 'pipe' });
23
+ if (result.error || result.status !== 0 || result.signal || typeof result.stdout !== 'string' || result.stdout.length > 256)
24
+ return null;
25
+ return semver_1.default.valid(result.stdout.trim());
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
14
31
  /**
15
32
  * Python package metadata is evidence only for a contained virtual environment.
16
33
  * A v2 command that names a Python-runtime tool must therefore resolve through
@@ -68,7 +85,7 @@ async function resolveParsedPackCompatibility(cwd, pack, options = {}) {
68
85
  throw new Error('cwd must be a non-empty path');
69
86
  if (!pack || typeof pack !== 'object' || pack.schemaVersion !== 2)
70
87
  throw new Error('pack must be a parsed v2 sensor pack');
71
- const evidence = (0, discovery_1.discoverProjectEvidence)(cwd, pack);
88
+ const evidence = (0, discovery_1.discoverProjectEvidence)(cwd, pack, { pathToolVersion: pathPackageManagerVersion });
72
89
  const executionPack = bindContainedRuntimeCommands(pack, evidence.pythonEnvironmentRoot);
73
90
  const resolutionEvidence = { ...evidence, ...(options.packSelection === 'explicit' ? { packSelection: 'explicit' } : {}) };
74
91
  const initial = (0, resolve_1.resolveProjectCompatibility)(executionPack, resolutionEvidence).sensors;
@@ -48,8 +48,14 @@ async function runCompatibilityProbe(probe, evidence, executor = exec_1.runStruc
48
48
  throw new Error('compatibility probe kind must be allowed');
49
49
  if (!evidence || typeof evidence.cwd !== 'string' || evidence.cwd.trim() === '')
50
50
  throw new Error('probe evidence requires cwd');
51
- if (probe.kind === 'package-script-present')
52
- return { status: (evidence.scripts?.length ?? 0) > 0 ? 'matched' : 'not-matched', reason: 'package-script' };
51
+ if (probe.kind === 'package-script-present') {
52
+ const script = probe.script;
53
+ if (!Object.prototype.hasOwnProperty.call(probe, 'script'))
54
+ return { status: 'unverifiable', reason: 'package-script-name-required' };
55
+ if (typeof script !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9:_-]*$/.test(script))
56
+ throw new Error('package-script-present probe requires a named package script');
57
+ return { status: evidence.scripts?.includes(script) ? 'matched' : 'not-matched', reason: 'package-script' };
58
+ }
53
59
  if (probe.kind === 'config-present')
54
60
  return { status: (evidence.configFiles?.length ?? 0) > 0 ? 'matched' : 'not-matched', reason: 'config-file' };
55
61
  try {
@@ -26,7 +26,7 @@ function result(state, reason, variant, evidence) {
26
26
  return { state, reason, variantId: variant?.id ?? null, toolVersion, runtimeVersion, certifiedRange: variant?.certifiedRange ?? null, evidence: refs.slice(0, 32) };
27
27
  }
28
28
  function applies(sensor, evidence) {
29
- if (sensor.applicability.kind === 'explicit-or-supported-language') {
29
+ if (sensor.applicability.kind === 'explicit-or-supported-language' || sensor.applicability.kind === 'explicit-opt-in') {
30
30
  return evidence.applicable === true || evidence.packSelection === 'explicit';
31
31
  }
32
32
  if (evidence.applicable === false)
@@ -40,7 +40,10 @@ function specificity(variant) {
40
40
  }
41
41
  function validVersion(value) { return typeof value === 'string' && semver_1.default.valid(value) !== null; }
42
42
  function toolFor(variant, evidence) {
43
- return evidence.toolVersions === undefined ? evidence.toolVersion : evidence.toolVersions[variant.requirements.tool];
43
+ const tool = variant.requirements.tool;
44
+ const version = evidence.toolVersions === undefined ? evidence.toolVersion : evidence.toolVersions[tool];
45
+ const provenance = evidence.toolProvenance?.[tool];
46
+ return provenance !== undefined && provenance !== null && provenance !== variant.command.resolution ? null : version;
44
47
  }
45
48
  function runtimeFor(variant, evidence) {
46
49
  return evidence.runtimeVersions === undefined ? evidence.runtimeVersion : evidence.runtimeVersions[variant.requirements.runtime];
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.observeDetector = observeDetector;
7
7
  const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
+ const native_bridge_1 = require("../../../core/secure-fs/native-bridge");
9
10
  const contract_1 = require("./contract");
10
11
  const realIo = {
11
12
  lstatSync: fs_1.default.lstatSync,
@@ -39,23 +40,31 @@ function inspectFile(root, relative, markers, io) {
39
40
  return { status: 'unverifiable', evidence: [{ kind: 'file', path: relative, status: 'unverifiable' }] };
40
41
  }
41
42
  const noFollow = fs_1.default.constants.O_NOFOLLOW;
42
- if (typeof noFollow !== 'number') {
43
+ if (typeof noFollow !== 'number' && io !== realIo) {
43
44
  return { status: 'unverifiable', evidence: [{ kind: 'file', path: relative, status: 'unverifiable' }] };
44
45
  }
45
46
  let fd;
46
47
  let content;
47
48
  try {
48
- fd = (io.openSync ?? realIo.openSync)(absolute, fs_1.default.constants.O_RDONLY | noFollow);
49
- const opened = (io.fstatSync ?? realIo.fstatSync)(fd);
50
- if (!opened.isFile() || opened.size > contract_1.MAX_COVERAGE_FILE_BYTES) {
51
- return { status: 'unverifiable', evidence: [{ kind: 'file', path: relative, status: 'unverifiable' }] };
49
+ if (typeof noFollow !== 'number') {
50
+ // Windows has no O_NOFOLLOW. Its native descriptor-anchored read
51
+ // rejects reparse points in every ancestor and preserves identity.
52
+ // Never replace an injected IO boundary with this native trust.
53
+ content = native_bridge_1.secureFs.readRegularFile(absolute, contract_1.MAX_COVERAGE_FILE_BYTES).bytes.toString('utf8');
52
54
  }
53
- const buffer = Buffer.allocUnsafe(contract_1.MAX_COVERAGE_FILE_BYTES + 1);
54
- const bytesRead = (io.readSync ?? realIo.readSync)(fd, buffer, 0, buffer.length, null);
55
- if (!Number.isSafeInteger(bytesRead) || bytesRead < 0 || bytesRead > contract_1.MAX_COVERAGE_FILE_BYTES) {
56
- return { status: 'unverifiable', evidence: [{ kind: 'file', path: relative, status: 'unverifiable' }] };
55
+ else {
56
+ fd = (io.openSync ?? realIo.openSync)(absolute, fs_1.default.constants.O_RDONLY | noFollow);
57
+ const opened = (io.fstatSync ?? realIo.fstatSync)(fd);
58
+ if (!opened.isFile() || opened.size > contract_1.MAX_COVERAGE_FILE_BYTES) {
59
+ return { status: 'unverifiable', evidence: [{ kind: 'file', path: relative, status: 'unverifiable' }] };
60
+ }
61
+ const buffer = Buffer.allocUnsafe(contract_1.MAX_COVERAGE_FILE_BYTES + 1);
62
+ const bytesRead = (io.readSync ?? realIo.readSync)(fd, buffer, 0, buffer.length, null);
63
+ if (!Number.isSafeInteger(bytesRead) || bytesRead < 0 || bytesRead > contract_1.MAX_COVERAGE_FILE_BYTES) {
64
+ return { status: 'unverifiable', evidence: [{ kind: 'file', path: relative, status: 'unverifiable' }] };
65
+ }
66
+ content = buffer.subarray(0, bytesRead).toString('utf8');
57
67
  }
58
- content = buffer.subarray(0, bytesRead).toString('utf8');
59
68
  }
60
69
  catch {
61
70
  return { status: 'unverifiable', evidence: [{ kind: 'file', path: relative, status: 'unverifiable' }] };