agentic-workflow-manager 9.5.0 → 9.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/src/commands/context/index.js +3 -1
  2. package/dist/src/commands/preflight/checks.js +2 -2
  3. package/dist/src/commands/sensors/bootstrap.js +171 -0
  4. package/dist/src/commands/sensors/compatibility/contract.js +18 -15
  5. package/dist/src/commands/sensors/compatibility/manifest.js +29 -1
  6. package/dist/src/commands/sensors/compatibility/materialize.js +256 -54
  7. package/dist/src/commands/sensors/compatibility/safe-file.js +240 -5
  8. package/dist/src/commands/sensors/coverage/index.js +2 -2
  9. package/dist/src/commands/sensors/coverage/resolve.js +20 -10
  10. package/dist/src/commands/sensors/detection.js +31 -0
  11. package/dist/src/commands/sensors/exec.js +116 -11
  12. package/dist/src/commands/sensors/index.js +51 -10
  13. package/dist/src/commands/sensors/init.js +17 -170
  14. package/dist/src/commands/sensors/migrate.js +209 -0
  15. package/dist/src/commands/sensors/run.js +2 -2
  16. package/dist/src/core/discovery.js +79 -4
  17. package/dist/src/core/init/steps.js +24 -11
  18. package/dist/src/core/orchestrators.js +34 -10
  19. package/dist/src/core/secure-fs/native-bridge.js +209 -0
  20. package/dist/src/core/text.js +7 -0
  21. package/dist/tests/commands/context.test.js +9 -1
  22. package/dist/tests/commands/hooks/install.test.js +4 -0
  23. package/dist/tests/commands/hooks/resync.test.js +2 -0
  24. package/dist/tests/commands/sensors/bootstrap-observational.test.js +24 -0
  25. package/dist/tests/commands/sensors/bootstrap.test.js +175 -0
  26. package/dist/tests/commands/sensors/compatibility/contract.test.js +38 -0
  27. package/dist/tests/commands/sensors/compatibility/manifest.test.js +67 -0
  28. package/dist/tests/commands/sensors/compatibility/materialize.test.js +410 -0
  29. package/dist/tests/commands/sensors/compatibility/pack-source.test.js +34 -1
  30. package/dist/tests/commands/sensors/compatibility/safe-file.test.js +52 -0
  31. package/dist/tests/commands/sensors/coverage/index.test.js +20 -0
  32. package/dist/tests/commands/sensors/coverage/resolve.test.js +10 -0
  33. package/dist/tests/commands/sensors/exec.test.js +86 -0
  34. package/dist/tests/commands/sensors/index.test.js +54 -0
  35. package/dist/tests/commands/sensors/init-pack-unavailable.test.js +21 -84
  36. package/dist/tests/commands/sensors/init.test.js +56 -490
  37. package/dist/tests/commands/sensors/migrate.test.js +513 -0
  38. package/dist/tests/core/context/orchestrator.test.js +2 -0
  39. package/dist/tests/core/discovery-multiroot.test.js +5 -0
  40. package/dist/tests/core/discovery-parent-race.test.js +95 -0
  41. package/dist/tests/core/discovery-safe-read.test.js +115 -0
  42. package/dist/tests/core/discovery.test.js +28 -1
  43. package/dist/tests/core/init/orchestrator.test.js +3 -1
  44. package/dist/tests/core/orchestrators.test.js +171 -14
  45. package/dist/tests/core/secure-fs/native-bridge.test.js +733 -0
  46. package/dist/tests/core/text.test.js +10 -0
  47. package/dist/tests/integration/context-orchestrators-e2e.test.js +57 -7
  48. package/dist/tests/integration/sensor-bootstrap.e2e.test.js +165 -0
  49. package/dist/tests/integration/sensor-compatibility.e2e.test.js +10 -2
  50. package/dist/tests/integration/sensor-structured-run.e2e.test.js +3 -1
  51. package/dist/tests/structural/native-release-artifacts.test.js +22 -0
  52. package/dist/tests/structural/release-flags-are-reachable-from-ci.test.js +10 -0
  53. package/dist/tests/structural/release-registry-provenance.test.js +4 -0
  54. package/dist/tests/structural/sensor-portability-contract.test.js +44 -0
  55. package/package.json +6 -2
  56. package/prebuilds/darwin-arm64/secure_fs.node +0 -0
  57. package/prebuilds/darwin-x64/secure_fs.node +0 -0
  58. package/prebuilds/linux-arm64/secure_fs.node +0 -0
  59. package/prebuilds/linux-x64/secure_fs.node +0 -0
  60. package/prebuilds/win32-arm64/secure_fs.node +0 -0
  61. package/prebuilds/win32-x64/secure_fs.node +0 -0
@@ -15,7 +15,9 @@ function runContextOrchestrators(collected, opts) {
15
15
  // un usuario que tipee el nombre declarado exacto (con un caracter que el saneo
16
16
  // quita, p.ej. `_` o un byte de control) se lleva un falso "not composed" (R3.5/R3.6).
17
17
  const verifyNormalized = (0, text_1.sanitizeDeclaredField)(opts.verify);
18
- const found = composed.some((o) => o.name === verifyNormalized);
18
+ // A dropped raw identity must not borrow a retained declaration's sanitized name.
19
+ const found = !collected.droppedNames.includes(opts.verify)
20
+ && composed.some((o) => o.name === verifyNormalized);
19
21
  if (!found) {
20
22
  const available = composed.map((o) => o.name).join(', ') || '(none)';
21
23
  return {
@@ -9,7 +9,7 @@ const child_process_1 = require("child_process");
9
9
  const fs_1 = __importDefault(require("fs"));
10
10
  const path_1 = __importDefault(require("path"));
11
11
  const status_1 = require("../sensors/status");
12
- const init_1 = require("../sensors/init");
12
+ const detection_1 = require("../sensors/detection");
13
13
  const baseline_1 = require("../sensors/baseline");
14
14
  const run_1 = require("../sensors/run");
15
15
  const paths_1 = require("../../core/paths");
@@ -203,7 +203,7 @@ function checkPack(cwd, status) {
203
203
  const manifest = manifestView(status);
204
204
  if (!manifest)
205
205
  return { id: 'pack', ok: true, detail: 'skipped (no manifest)' };
206
- const detection = (0, init_1.detectStack)(cwd);
206
+ const detection = (0, detection_1.detectStack)(cwd);
207
207
  if (manifest.pack === 'generic' && detection.pack !== 'generic') {
208
208
  return {
209
209
  id: 'pack',
@@ -0,0 +1,171 @@
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
+ exports.planSensorBootstrap = planSensorBootstrap;
7
+ exports.applySensorBootstrap = applySensorBootstrap;
8
+ const registries_1 = require("../../core/registries");
9
+ const contract_1 = require("./compatibility/contract");
10
+ const manifest_1 = require("./compatibility/manifest");
11
+ const pack_source_1 = require("./compatibility/pack-source");
12
+ const live_1 = require("./compatibility/live");
13
+ const source_1 = require("./compatibility/source");
14
+ const detection_1 = require("./detection");
15
+ const migrate_1 = require("./migrate");
16
+ const project_1 = require("./project");
17
+ const materialize_1 = require("./compatibility/materialize");
18
+ const migrate_2 = require("./migrate");
19
+ const safe_file_1 = require("./compatibility/safe-file");
20
+ const fs_1 = __importDefault(require("fs"));
21
+ const crypto_1 = require("crypto");
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 } : {}) };
25
+ }
26
+ function options(input) {
27
+ if (input === undefined)
28
+ return { dryRun: false };
29
+ if (!input || typeof input !== 'object' || Array.isArray(input))
30
+ throw new Error('bootstrap options must be an object');
31
+ const value = input;
32
+ if (Object.keys(value).some(key => !['mode', 'reason', 'dryRun', 'registryRoot', 'configure', 'pack', 'packageRoot'].includes(key)))
33
+ throw new Error('bootstrap options contain an unknown field');
34
+ if (value.mode !== undefined && value.mode !== 'project-sensors' && value.mode !== 'native-gate' && value.mode !== 'opt-out')
35
+ throw new Error('bootstrap mode must be project-sensors, native-gate, or opt-out');
36
+ if (value.reason !== undefined && (typeof value.reason !== 'string' || value.reason.trim() === '' || value.reason.length > 512 || /[\0\r\n]/.test(value.reason)))
37
+ throw new Error('bootstrap reason must be a nonempty bounded single-line string');
38
+ if (value.dryRun !== undefined && typeof value.dryRun !== 'boolean')
39
+ throw new Error('bootstrap dryRun must be a boolean');
40
+ if (value.configure !== undefined && typeof value.configure !== 'boolean')
41
+ throw new Error('bootstrap configure must be a boolean');
42
+ if (value.registryRoot !== undefined && (typeof value.registryRoot !== 'string' || (!path_1.default.isAbsolute(value.registryRoot) && !path_1.default.win32.isAbsolute(value.registryRoot)) || /[\0\r\n]/.test(value.registryRoot)))
43
+ throw new Error('bootstrap registryRoot must be an absolute single-line path');
44
+ if (value.pack !== undefined && (typeof value.pack !== 'string' || !/^[a-z][a-z0-9-]*$/.test(value.pack)))
45
+ throw new Error('bootstrap pack must be a stable lowercase id');
46
+ if (value.packageRoot !== undefined && (typeof value.packageRoot !== 'string' || value.packageRoot.length === 0 || value.packageRoot.includes('\\') || value.packageRoot.split('/').some(part => part === '' || part === '.' || part === '..')))
47
+ throw new Error('bootstrap packageRoot must be a contained relative path');
48
+ return { mode: value.mode, reason: value.reason, dryRun: value.dryRun === true, registryRoot: value.registryRoot, configure: value.configure !== false, pack: value.pack, packageRoot: value.packageRoot };
49
+ }
50
+ function declaration(mode, reason) {
51
+ return { schemaVersion: 3, mode, reason };
52
+ }
53
+ function digest(bytes) { return (0, crypto_1.createHash)('sha256').update(bytes).digest('hex'); }
54
+ async function projectSensors(projectRoot, registryRoot, requestedPack, packageRoot) {
55
+ const detectionRoot = packageRoot ? path_1.default.resolve(projectRoot, packageRoot) : projectRoot;
56
+ const detected = (0, detection_1.detectStack)(detectionRoot);
57
+ const pack = requestedPack ?? detected.pack;
58
+ const registries = (0, registries_1.listRegistries)();
59
+ const selected = registryRoot === undefined ? registries : registries.filter(registry => registry.contentRoot === registryRoot);
60
+ if (registryRoot !== undefined && selected.length !== 1)
61
+ return { reason: 'registry-root-not-configured', remedy: 'configure-the-registry-before-bootstrap' };
62
+ const sources = (0, pack_source_1.listPackSources)(pack, { registries: selected });
63
+ if (sources.length === 0)
64
+ return { reason: 'source-unavailable', remedy: 'install-registry-or-run-awm-update' };
65
+ if (sources.length !== 1)
66
+ return { reason: 'source-ambiguous', remedy: 'configure-one-logical-registry', candidates: sources.map(item => item.registry.name).sort().slice(0, 32) };
67
+ const source = sources[0];
68
+ let parsed;
69
+ try {
70
+ parsed = (0, contract_1.parseSensorPack)(JSON.parse(source.content), source.path);
71
+ }
72
+ catch {
73
+ return { reason: 'source-invalid', remedy: 'repair-or-run-awm-update' };
74
+ }
75
+ if (parsed.kind !== 'v2')
76
+ return { reason: 'source-unsupported', remedy: 'install-a-v2-sensor-pack' };
77
+ let live;
78
+ try {
79
+ live = await (0, live_1.resolveParsedPackCompatibility)(detectionRoot, parsed.pack);
80
+ }
81
+ catch {
82
+ return { reason: 'compatibility-unresolvable', remedy: 'repair-project-tools-or-select-another-mode' };
83
+ }
84
+ const sensors = {};
85
+ for (const [name, packSensor] of Object.entries(live.pack.sensors)) {
86
+ const evidence = live.sensors[name];
87
+ const variant = evidence.variantId === null ? undefined : packSensor.variants.find(item => item.id === evidence.variantId);
88
+ if (!variant)
89
+ return { reason: 'sensor-variant-unresolvable', remedy: 'repair-project-tools-or-select-another-mode' };
90
+ 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 };
91
+ }
92
+ const manifest = (0, manifest_1.parseSensorManifest)({ schemaVersion: 3, mode: 'project-sensors', pack, source: { registry: source.registry.name }, ...(packageRoot ? { packageRoot } : {}), sensors }, 'bootstrap candidate');
93
+ if (manifest.kind !== 'v3' || manifest.pack.mode !== 'project-sensors')
94
+ throw new Error('bootstrap project-sensors candidate is invalid');
95
+ (0, manifest_1.serializeManifestV3)(manifest.pack);
96
+ const assets = [...new Set(Object.values(sensors).flatMap(sensor => sensor.assets ?? []))].sort();
97
+ return { manifest: manifest.pack, source, changes: [{ path: '.awm/sensors.json', action: 'create' }, ...assets.map(asset => ({ path: packageRoot ? `${packageRoot}/${asset}` : asset, action: 'create' }))] };
98
+ }
99
+ /** Plan a one-time portable bootstrap. This function never writes project or machine state. */
100
+ async function planSensorBootstrap(cwd = process.cwd(), input = {}) {
101
+ const opts = options(input);
102
+ const project = (0, project_1.resolveSensorProject)(cwd);
103
+ if (project.state === 'invalid')
104
+ return blocked(project.projectRoot, project.manifestPath, opts.dryRun, 'manifest-invalid', 'repair-sensor-manifest');
105
+ if (project.state === 'configured') {
106
+ if (project.manifest.kind === 'v3') {
107
+ if (opts.mode !== undefined && opts.mode !== project.manifest.pack.mode) {
108
+ return blocked(project.projectRoot, project.manifestPath, opts.dryRun, 'mode-conflicts-with-existing-declaration', 'run-bootstrap-without-mode');
109
+ }
110
+ return { kind: 'noop', projectRoot: project.projectRoot, manifestPath: project.manifestPath, changes: [], dryRun: opts.dryRun };
111
+ }
112
+ if (project.manifest.kind === 'legacy')
113
+ return blocked(project.projectRoot, project.manifestPath, opts.dryRun, 'legacy-v1-preserved', 'migrate-the-manifest-explicitly');
114
+ let source;
115
+ try {
116
+ source = (0, source_1.resolveSensorSource)(project.manifest, { registries: (0, registries_1.listRegistries)() });
117
+ }
118
+ catch {
119
+ return blocked(project.projectRoot, project.manifestPath, opts.dryRun, 'source-invalid', 'repair-or-run-awm-update');
120
+ }
121
+ if (!('source' in source))
122
+ return blocked(project.projectRoot, project.manifestPath, opts.dryRun, source.reason, source.remedy);
123
+ try {
124
+ const original = fs_1.default.readFileSync(project.manifestPath);
125
+ return { kind: 'migrate', projectRoot: project.projectRoot, manifestPath: project.manifestPath, changes: [{ path: '.awm/sensors.json', action: 'replace' }], dryRun: opts.dryRun, migration: (0, migrate_1.planV2Migration)({ manifest: project.manifest.pack, source }), source, originalDigest: digest(original) };
126
+ }
127
+ catch {
128
+ return blocked(project.projectRoot, project.manifestPath, opts.dryRun, 'migration-not-equivalent', 'repair-or-select-one-compatible-registry');
129
+ }
130
+ }
131
+ if (!opts.mode)
132
+ return blocked(project.projectRoot, project.manifestPath, opts.dryRun, 'mode-required', 'choose-project-sensors-native-gate-or-opt-out');
133
+ if (opts.mode !== 'project-sensors') {
134
+ if (!opts.reason)
135
+ return blocked(project.projectRoot, project.manifestPath, opts.dryRun, 'reason-required', 'provide-a-versioned-reason');
136
+ return { kind: 'create', projectRoot: project.projectRoot, manifestPath: project.manifestPath, changes: [{ path: '.awm/sensors.json', action: 'create' }], dryRun: opts.dryRun, manifest: declaration(opts.mode, opts.reason), configure: opts.configure };
137
+ }
138
+ const planned = await projectSensors(project.projectRoot, opts.registryRoot, opts.pack, opts.packageRoot);
139
+ if ('reason' in planned)
140
+ return blocked(project.projectRoot, project.manifestPath, opts.dryRun, planned.reason, planned.remedy, 'candidates' in planned ? planned.candidates : undefined);
141
+ return { kind: 'create', projectRoot: project.projectRoot, manifestPath: project.manifestPath, changes: planned.changes, dryRun: opts.dryRun, manifest: planned.manifest, source: planned.source, configure: opts.configure };
142
+ }
143
+ /** Apply only a validated, non-dry-run create or migration plan. */
144
+ function applySensorBootstrap(plan) {
145
+ if (!plan || typeof plan !== 'object')
146
+ throw new Error('bootstrap plan is required');
147
+ if (plan.kind !== 'create' && plan.kind !== 'migrate')
148
+ throw new Error('only a create or migrate bootstrap plan can be applied');
149
+ if (plan.dryRun)
150
+ throw new Error('a dry-run bootstrap plan cannot be applied');
151
+ if (plan.kind === 'migrate') {
152
+ const current = fs_1.default.readFileSync(plan.manifestPath);
153
+ if (digest(current) !== plan.originalDigest)
154
+ throw new Error('bootstrap migration plan is stale: manifest changed after planning');
155
+ (0, migrate_2.replaceV2ManifestWithV3)(plan.manifestPath, plan.migration.candidate, plan.source);
156
+ return 'migrated';
157
+ }
158
+ if (plan.manifest.mode === 'project-sensors') {
159
+ if (!plan.source)
160
+ throw new Error('project-sensors bootstrap plan requires its resolved source');
161
+ (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 });
162
+ return 'created';
163
+ }
164
+ const parsed = (0, manifest_1.parseSensorManifest)(plan.manifest, 'bootstrap declaration');
165
+ if (parsed.kind !== 'v3' || parsed.pack.mode === 'project-sensors')
166
+ throw new Error('bootstrap declaration plan is invalid');
167
+ (0, safe_file_1.withProjectLease)(plan.projectRoot, () => {
168
+ (0, safe_file_1.writeProjectFile)(plan.projectRoot, '.awm/sensors.json', Buffer.from((0, manifest_1.serializeManifestV3)(parsed.pack), 'utf8'), { mode: 'create', createParents: true });
169
+ });
170
+ return 'created';
171
+ }
@@ -12,6 +12,7 @@ const semver_1 = __importDefault(require("semver"));
12
12
  const fs_1 = __importDefault(require("fs"));
13
13
  const path_1 = __importDefault(require("path"));
14
14
  const contract_1 = require("../coverage/contract");
15
+ const safe_file_1 = require("./safe-file");
15
16
  const timeout_1 = require("./timeout");
16
17
  const PACK_SCHEMA_VERSION = 2;
17
18
  const SHELL_EXECUTABLES = new Set(['sh', 'bash', 'cmd', 'powershell']);
@@ -107,32 +108,34 @@ function resolveSemgrepPolicy(policyRef, source, location = 'policy') {
107
108
  invalid(source, `${location}.policyRef escapes sensor-packs root`);
108
109
  let stat;
109
110
  try {
110
- stat = fs_1.default.lstatSync(candidate);
111
+ stat = fs_1.default.lstatSync(candidate, { bigint: true });
111
112
  }
112
113
  catch {
113
114
  invalid(source, `${location}.policyRef does not resolve to a regular policy file`);
114
115
  }
115
116
  if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_POLICY_BYTES)
116
117
  invalid(source, `${location}.policyRef must resolve to a bounded regular policy file`);
117
- let realRoot;
118
- let realPolicy;
119
- try {
120
- realRoot = fs_1.default.realpathSync(sensorPacksRoot);
121
- realPolicy = fs_1.default.realpathSync(candidate);
122
- }
123
- catch {
124
- invalid(source, `${location}.policyRef cannot be canonicalized`);
125
- }
126
- if (!contained(realRoot, realPolicy))
127
- invalid(source, `${location}.policyRef escapes sensor-packs root`);
118
+ const failure = (reason) => {
119
+ if (reason === 'open')
120
+ return new Error(`Invalid sensor pack${sourceSuffix(source)}: ${location}.policyRef cannot be safely opened`);
121
+ if (reason === 'regular')
122
+ return new Error(`Invalid sensor pack${sourceSuffix(source)}: ${location}.policyRef must resolve to a bounded regular policy file`);
123
+ if (reason === 'identity')
124
+ return new Error(`Invalid sensor pack${sourceSuffix(source)}: ${location}.policyRef changed identity during safe open`);
125
+ if (reason === 'size')
126
+ return new Error(`Invalid sensor pack${sourceSuffix(source)}: ${location}.policyRef changed size during safe open`);
127
+ return new Error(`Invalid sensor pack${sourceSuffix(source)}: ${location}.policyRef must resolve to a bounded regular policy file`);
128
+ };
128
129
  let parsed;
129
130
  try {
130
- parsed = JSON.parse(fs_1.default.readFileSync(realPolicy, 'utf8'));
131
+ parsed = JSON.parse((0, safe_file_1.readInspectedBoundedFile)(candidate, stat, MAX_POLICY_BYTES, failure).toString('utf8'));
131
132
  }
132
- catch {
133
+ catch (error) {
134
+ if (error instanceof Error && error.message.startsWith('Invalid sensor pack'))
135
+ throw error;
133
136
  invalid(source, `${location}.policyRef must contain valid JSON`);
134
137
  }
135
- return parseSemgrepPolicy(parsed, realPolicy);
138
+ return parseSemgrepPolicy(parsed, candidate);
136
139
  }
137
140
  function parseStructuredCommand(input, source) {
138
141
  const value = record(input, source, 'command');
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.legacyCompatibility = legacyCompatibility;
7
7
  exports.parseSensorManifest = parseSensorManifest;
8
8
  exports.serializeManifestV2 = serializeManifestV2;
9
+ exports.serializeManifestV3 = serializeManifestV3;
9
10
  const contract_1 = require("./contract");
10
11
  const timeout_1 = require("./timeout");
11
12
  const semver_1 = __importDefault(require("semver"));
@@ -183,6 +184,22 @@ function provenanceRoot(value, source) {
183
184
  invalid(source, 'registryRoot must be an absolute normalized path');
184
185
  return parsed;
185
186
  }
187
+ /**
188
+ * A v3 declaration is copied between machines, so no nested string may retain
189
+ * a host path. Structured command arguments are intentionally free-form and
190
+ * are therefore the only remaining place an otherwise validated declaration
191
+ * could hide one.
192
+ */
193
+ function containsPhysicalPath(value) {
194
+ if (typeof value === 'string') {
195
+ // Web URLs are portable command values, not host filesystem paths.
196
+ const withoutWebUrls = value.replace(/https?:\/\/[^\s"']+/gi, '');
197
+ return /(?:^|[^A-Za-z0-9_.~\/\\-])["']?(?:\/|\\|[A-Za-z]:|\/\/)/.test(withoutWebUrls);
198
+ }
199
+ return Array.isArray(value)
200
+ ? value.some(containsPhysicalPath)
201
+ : isRecord(value) && Object.values(value).some(containsPhysicalPath);
202
+ }
186
203
  function parseV2Manifest(value, source) {
187
204
  fields(value, ['schemaVersion', 'pack', 'packSelection', 'registryRoot', 'packageRoot', 'sensors', 'concurrency'], source, 'root');
188
205
  if (value.schemaVersion !== 2)
@@ -262,5 +279,16 @@ function serializeManifestV2(input) {
262
279
  const parsed = parseSensorManifest(input, 'manifest serialization');
263
280
  if (parsed.kind !== 'v2')
264
281
  throw new Error('Cannot serialize a legacy sensor manifest as v2');
265
- return JSON.stringify(parsed.pack, null, 2);
282
+ return JSON.stringify(parsed.pack, null, 2) + '\n';
283
+ }
284
+ /** Serialize a validated portable v3 declaration deterministically. */
285
+ function serializeManifestV3(input) {
286
+ const parsed = parseSensorManifest(input, 'manifest serialization');
287
+ if (parsed.kind !== 'v3') {
288
+ throw new Error('Cannot serialize a non-v3 sensor manifest as v3');
289
+ }
290
+ if (parsed.pack.mode === 'project-sensors' && containsPhysicalPath(parsed.pack)) {
291
+ throw new Error('Cannot serialize a v3 declaration containing a physical path');
292
+ }
293
+ return JSON.stringify(parsed.pack, null, 2) + '\n';
266
294
  }