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
@@ -0,0 +1,75 @@
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.EXECUTION_IDENTITY_SCHEMA = exports.MAX_PLAN_SNAPSHOT_BYTES = void 0;
7
+ exports.normalizedPlanSnapshot = normalizedPlanSnapshot;
8
+ exports.fullPlanDigest = fullPlanDigest;
9
+ exports.executionPlanDigest = executionPlanDigest;
10
+ const crypto_1 = __importDefault(require("crypto"));
11
+ exports.MAX_PLAN_SNAPSHOT_BYTES = 1024 * 1024;
12
+ exports.EXECUTION_IDENTITY_SCHEMA = 'awm-plan-execution/v1';
13
+ const START = '<!-- AWM:COMPACT-SLICES:START v1 -->';
14
+ const END = '<!-- AWM:COMPACT-SLICES:END v1 -->';
15
+ /** Only lifecycle syntax actually emitted by the skills is non-executable.
16
+ * Unknown metadata, prose, inline comments and code remain identity-bearing. */
17
+ function lifecycleMarker(line) {
18
+ const match = /^ {0,3}<!-- (awm-(?:qa|docs|retro)-complete)(?:: (\d{4}-\d{2}-\d{2}|Release [A-Za-z0-9][A-Za-z0-9_-]*))? -->$/.exec(line);
19
+ if (!match)
20
+ return undefined;
21
+ const metadata = match[2];
22
+ if (metadata && !metadata.startsWith('Release ')) {
23
+ const parsed = new Date(`${metadata}T00:00:00.000Z`);
24
+ if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== metadata)
25
+ return undefined;
26
+ }
27
+ return `${match[1]}:${metadata?.startsWith('Release ') ? metadata : ''}`;
28
+ }
29
+ function normalizedPlanSnapshot(text) {
30
+ if (typeof text !== 'string' || text.length === 0 || Buffer.byteLength(text, 'utf8') > exports.MAX_PLAN_SNAPSHOT_BYTES || text.includes('\0')) {
31
+ throw new Error('plan snapshot must be nonempty bounded text without NUL');
32
+ }
33
+ return text.replace(/\r\n?/g, '\n');
34
+ }
35
+ function fullPlanDigest(text) {
36
+ return crypto_1.default.createHash('sha256').update(normalizedPlanSnapshot(text), 'utf8').digest('hex');
37
+ }
38
+ /** This versioned identity preserves ALL plan bytes except recognized progress
39
+ * status bytes and unique standalone lifecycle marker lines outside code/JSON.
40
+ * It is never a replacement for the full validated-plan digest. */
41
+ function executionPlanDigest(text) {
42
+ const lines = normalizedPlanSnapshot(text).split('\n');
43
+ let fence;
44
+ let manifest = false;
45
+ const candidates = lines.map(line => {
46
+ const matched = /^(?: {0,3})(`{3,}|~{3,})(.*)$/.exec(line);
47
+ const marker = matched?.[1];
48
+ if (marker && (fence
49
+ ? marker[0] === fence.character && marker.length >= fence.length && /^[ \t]*$/.test(matched[2])
50
+ : marker[0] !== '`' || !matched[2].includes('`'))) {
51
+ fence = fence ? undefined : { character: marker[0], length: marker.length };
52
+ return { line };
53
+ }
54
+ if (fence)
55
+ return { line };
56
+ if (line === START) {
57
+ manifest = true;
58
+ return { line };
59
+ }
60
+ if (line === END) {
61
+ manifest = false;
62
+ return { line };
63
+ }
64
+ if (manifest)
65
+ return { line };
66
+ const lifecycle = lifecycleMarker(line);
67
+ return { line: line.replace(/^( {0,3}[-*+] +)\[[ xX]\]( +\S.*)$/, '$1[ ]$2'), lifecycle };
68
+ });
69
+ const counts = new Map();
70
+ for (const { lifecycle } of candidates)
71
+ if (lifecycle)
72
+ counts.set(lifecycle, (counts.get(lifecycle) ?? 0) + 1);
73
+ const canonical = candidates.filter(({ lifecycle }) => !lifecycle || counts.get(lifecycle) !== 1).map(({ line }) => line).join('\n');
74
+ return crypto_1.default.createHash('sha256').update(`${exports.EXECUTION_IDENTITY_SCHEMA}\0`).update(canonical, 'utf8').digest('hex');
75
+ }
@@ -3,22 +3,51 @@ 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.assertVerifiedValidPlanReport = assertVerifiedValidPlanReport;
7
+ exports.verifiedPlanSnapshot = verifiedPlanSnapshot;
6
8
  exports.validatePlanFile = validatePlanFile;
9
+ exports.validatePlanSnapshot = validatePlanSnapshot;
7
10
  const fs_1 = __importDefault(require("fs"));
8
11
  const path_1 = __importDefault(require("path"));
12
+ const crypto_1 = __importDefault(require("crypto"));
9
13
  const json_1 = require("./json");
14
+ const identity_1 = require("./identity");
10
15
  const START = '<!-- AWM:COMPACT-SLICES:START v1 -->';
11
16
  const END = '<!-- AWM:COMPACT-SLICES:END v1 -->';
12
17
  const MAX_PLAN = 1024 * 1024;
13
18
  const MAX_MANIFEST = 256 * 1024;
14
19
  const MAX_SOURCE = 1024 * 1024;
15
20
  const MAX_STRING = 4096;
16
- const ID = /^[A-Z][A-Z0-9-]{0,63}$/;
21
+ const ENTITY_ID = /^[A-Z][A-Z0-9-]{0,63}$/;
22
+ const REQUIREMENT_ID = /^[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*(?:\.[A-Z0-9]+(?:-[A-Z0-9]+)*)*$/;
17
23
  const PLAN_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
18
24
  const SHELL = new Set([
19
25
  'sh', 'bash', 'zsh', 'dash', 'ksh', 'fish', 'cmd', 'powershell', 'pwsh',
20
26
  'env', 'busybox', 'node', 'nodejs', 'deno', 'bun', 'python', 'python3', 'ruby', 'perl', 'php', 'lua',
21
27
  ]);
28
+ const verifiedValidReports = new WeakSet();
29
+ const verifiedSnapshots = new WeakMap();
30
+ function freezeJson(value) {
31
+ if (value === null || typeof value !== 'object')
32
+ return;
33
+ for (const child of Object.values(value))
34
+ freezeJson(child);
35
+ Object.freeze(value);
36
+ }
37
+ /** Require a process-local, immutable report issued by successful core validation. */
38
+ function assertVerifiedValidPlanReport(report) {
39
+ if (!report || typeof report !== 'object' || report.state !== 'valid' || !verifiedValidReports.has(report)) {
40
+ throw new Error('plan validator returned an invalid valid report');
41
+ }
42
+ }
43
+ /** The exact immutable validator snapshot, never a later reopen of the path. */
44
+ function verifiedPlanSnapshot(report) {
45
+ assertVerifiedValidPlanReport(report);
46
+ const snapshot = verifiedSnapshots.get(report);
47
+ if (snapshot === undefined)
48
+ throw new Error('verified plan snapshot unavailable');
49
+ return snapshot;
50
+ }
22
51
  function isLauncher(program) {
23
52
  const base = path_1.default.posix.basename(program).toLowerCase();
24
53
  return SHELL.has(base.replace(/\.exe$/, '')) || /^(?:node(?:js)?|py(?:thon(?:w)?\d*(?:\.\d+)?)?|deno|bun|ruby(?:\d+(?:\.\d+)*)?|perl(?:\d+(?:\.\d+)*)?|(?:a|ba|c|tc|z|da|k)?sh|busybox|cmd|powershell|pwsh|wscript|cscript)(?:\.exe)?(?:[-.][a-z0-9][a-z0-9.-]*)?$/i.test(base);
@@ -37,39 +66,49 @@ function schemaClassification(candidate) {
37
66
  return diagnostic('PLAN_MARKERS', 'compact markers must occur once in order');
38
67
  }
39
68
  function embeddedJsonObjects(text) {
40
- const objects = [];
41
- for (let start = 0; start < text.length && objects.length < 64; start += 1) {
42
- if (text[start] !== '{')
43
- continue;
44
- let depth = 0;
45
- let quoted = false;
46
- let escaped = false;
47
- let end = start;
48
- for (; end < text.length && end - start <= MAX_MANIFEST; end += 1) {
49
- const character = text[end];
50
- if (quoted) {
51
- if (escaped)
52
- escaped = false;
53
- else if (character === '\\')
54
- escaped = true;
55
- else if (character === '"')
56
- quoted = false;
57
- continue;
58
- }
59
- if (character === '"') {
60
- quoted = true;
61
- continue;
62
- }
69
+ const outer = [];
70
+ const nested = [];
71
+ const starts = [];
72
+ let quoted = false;
73
+ let escaped = false;
74
+ for (let index = 0; index < text.length; index += 1) {
75
+ const character = text[index];
76
+ if (starts.length === 0) {
63
77
  if (character === '{')
64
- depth += 1;
65
- if (character === '}' && --depth === 0) {
66
- objects.push(text.slice(start, end + 1));
67
- start = end;
68
- break;
78
+ starts.push(index);
79
+ continue;
80
+ }
81
+ if (quoted) {
82
+ if (character === '\n') {
83
+ quoted = false;
84
+ escaped = false;
69
85
  }
86
+ else if (escaped)
87
+ escaped = false;
88
+ else if (character === '\\')
89
+ escaped = true;
90
+ else if (character === '"')
91
+ quoted = false;
92
+ continue;
93
+ }
94
+ if (character === '"') {
95
+ quoted = true;
96
+ continue;
97
+ }
98
+ if (character === '{') {
99
+ starts.push(index);
100
+ continue;
70
101
  }
102
+ if (character !== '}')
103
+ continue;
104
+ const start = starts.pop();
105
+ if (start === undefined || index - start > MAX_MANIFEST)
106
+ continue;
107
+ const found = starts.length === 0 ? outer : nested;
108
+ if (found.length < 64)
109
+ found.push(text.slice(start, index + 1));
71
110
  }
72
- return objects;
111
+ return [...outer, ...nested].slice(0, 64);
73
112
  }
74
113
  function markerlessSchemaClassification(text) {
75
114
  const candidates = [text.trim(), ...embeddedJsonObjects(text)];
@@ -123,11 +162,18 @@ function compactSchemaSignal(text) {
123
162
  }
124
163
  return false;
125
164
  }
165
+ function executionModeFromValidatedText(text) {
166
+ const raw = /^\s*\*\*Modo de ejecución:\*\*\s*([^\r\n]+)\s*$/mi.exec(text)?.[1]?.trim().replace(/^`|`$/g, '');
167
+ return raw === 'desatendido' ? 'desatendido' : 'interactivo';
168
+ }
126
169
  function exact(value, fields) {
127
170
  return object(value) && Object.keys(value).length === fields.length && fields.every((field) => Object.prototype.hasOwnProperty.call(value, field));
128
171
  }
129
172
  function allStrings(value, max = MAX_STRING) { return Array.isArray(value) && value.every((item) => typeof item === 'string' && Buffer.byteLength(item, 'utf8') <= max); }
130
- function validId(value) { return typeof value === 'string' && ID.test(value); }
173
+ function validId(value) { return typeof value === 'string' && ENTITY_ID.test(value); }
174
+ function validRequirementId(value) {
175
+ return typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= 64 && REQUIREMENT_ID.test(value);
176
+ }
131
177
  function inside(root, candidate) { const relative = path_1.default.relative(root, candidate); return relative === '' || (!relative.startsWith(`..${path_1.default.sep}`) && relative !== '..' && !path_1.default.isAbsolute(relative)); }
132
178
  function validRelative(value) {
133
179
  return typeof value === 'string' && value.length > 0 && Buffer.byteLength(value, 'utf8') <= MAX_STRING && !path_1.default.isAbsolute(value) && !path_1.default.win32.isAbsolute(value) && !value.includes('\\') && value.split('/').every((part) => part !== '' && part !== '.' && part !== '..') && path_1.default.posix.normalize(value) === value;
@@ -140,30 +186,74 @@ function regularInside(root, relative) {
140
186
  return undefined;
141
187
  const parts = relative.split('/');
142
188
  let current = root;
189
+ let stat;
143
190
  try {
144
191
  for (const part of parts) {
145
192
  current = path_1.default.join(current, part);
146
- if (fs_1.default.lstatSync(current).isSymbolicLink())
193
+ stat = fs_1.default.lstatSync(current, { bigint: true });
194
+ if (stat.isSymbolicLink())
147
195
  return undefined;
148
196
  }
149
- if (!fs_1.default.lstatSync(candidate).isFile())
197
+ if (!stat?.isFile())
150
198
  return undefined;
151
199
  const real = fs_1.default.realpathSync(candidate);
152
- return inside(root, real) ? real : undefined;
200
+ return inside(root, real) ? { path: real, stat } : undefined;
153
201
  }
154
202
  catch {
155
203
  return undefined;
156
204
  }
157
205
  }
158
- function executableInside(root, relative) {
159
- const file = regularInside(root, relative);
206
+ function sameIdentity(inspected, opened) {
207
+ return typeof inspected.dev === 'bigint' && typeof inspected.ino === 'bigint' && inspected.ino !== 0n
208
+ && opened.isFile() && inspected.dev === opened.dev && inspected.ino === opened.ino;
209
+ }
210
+ function readInspected(file, max) {
211
+ if (file.stat.size > BigInt(max))
212
+ return { state: 'limit' };
213
+ let descriptor;
160
214
  try {
161
- return file && (process.platform === 'win32' ? /\.(?:exe|cmd|bat)$/i.test(file) : (fs_1.default.statSync(file).mode & 0o111) !== 0) ? file : undefined;
215
+ descriptor = fs_1.default.openSync(file.path, fs_1.default.constants.O_RDONLY | (fs_1.default.constants.O_NOFOLLOW ?? 0));
216
+ const opened = fs_1.default.fstatSync(descriptor, { bigint: true });
217
+ if (!sameIdentity(file.stat, opened))
218
+ return { state: 'unsafe' };
219
+ if (opened.size > BigInt(max))
220
+ return { state: 'limit' };
221
+ if (opened.size !== file.stat.size)
222
+ return { state: 'unsafe' };
223
+ const bytes = Buffer.alloc(Number(opened.size));
224
+ let offset = 0;
225
+ while (offset < bytes.length) {
226
+ const read = fs_1.default.readSync(descriptor, bytes, offset, bytes.length - offset, offset);
227
+ if (read <= 0)
228
+ return { state: 'read' };
229
+ offset += read;
230
+ }
231
+ const after = fs_1.default.fstatSync(descriptor, { bigint: true });
232
+ if (!sameIdentity(file.stat, after))
233
+ return { state: 'unsafe' };
234
+ if (after.size > BigInt(max) || bytes.length > max)
235
+ return { state: 'limit' };
236
+ if (after.size !== opened.size)
237
+ return { state: 'unsafe' };
238
+ return { state: 'ok', bytes };
162
239
  }
163
- catch {
164
- return undefined;
240
+ catch (error) {
241
+ const code = error.code;
242
+ return { state: code === 'ELOOP' || code === 'ENOENT' || code === 'ENOTDIR' ? 'unsafe' : 'read' };
243
+ }
244
+ finally {
245
+ if (descriptor !== undefined) {
246
+ try {
247
+ fs_1.default.closeSync(descriptor);
248
+ }
249
+ catch { /* best-effort close after a failed inspected read */ }
250
+ }
165
251
  }
166
252
  }
253
+ function executableInside(root, relative) {
254
+ const file = regularInside(root, relative);
255
+ return file && (process.platform === 'win32' ? /\.(?:exe|cmd|bat)$/i.test(file.path) : (file.stat.mode & 73n) !== 0n) ? file.path : undefined;
256
+ }
167
257
  function refs(value, known) { return allStrings(value) && value.every((id) => known.has(id)); }
168
258
  function uniqueRefs(value, known) { return refs(value, known) && new Set(value).size === value.length; }
169
259
  function unsafeCommand(value) { return /[\0\r\n]|[`$]|\$\(|\$\{|[;&|<>*?\[\]{}()]/.test(value); }
@@ -219,8 +309,8 @@ function checkMarkdown(text, slices) {
219
309
  }
220
310
  return undefined;
221
311
  }
222
- /** Read-only validator for a bounded compact-slices/v1 Markdown plan. */
223
- function validatePlanFile(planPath, cwd = process.cwd()) {
312
+ /** Shared parser. A supplied snapshot is already descriptor-anchored by its caller. */
313
+ function validatePlan(planPath, cwd, snapshot) {
224
314
  if (typeof planPath !== 'string' || planPath.length === 0)
225
315
  throw new Error('planPath must be a non-empty path');
226
316
  if (typeof cwd !== 'string' || cwd.length === 0)
@@ -238,21 +328,28 @@ function validatePlanFile(planPath, cwd = process.cwd()) {
238
328
  if (!inside(root, candidate))
239
329
  return diagnostic('PLAN_PATH_UNSAFE', 'plan path must be inside cwd');
240
330
  const relativePlan = path_1.default.relative(root, candidate).replace(/\\/g, '/');
241
- const planFile = regularInside(root, relativePlan);
242
- if (!planFile)
243
- return diagnostic('PLAN_PATH_UNSAFE', 'plan path must be a contained regular non-symlink file');
244
331
  let bytes;
245
- try {
246
- if (fs_1.default.statSync(planFile).size > MAX_PLAN)
332
+ if (snapshot) {
333
+ if (snapshot.length > MAX_PLAN)
247
334
  return diagnostic('PLAN_LIMIT', 'plan exceeds maximum size');
248
- bytes = fs_1.default.readFileSync(planFile);
335
+ bytes = Buffer.from(snapshot);
249
336
  }
250
- catch {
251
- return diagnostic('PLAN_READ', 'plan cannot be read');
337
+ else {
338
+ const planFile = regularInside(root, relativePlan);
339
+ if (!planFile)
340
+ return diagnostic('PLAN_PATH_UNSAFE', 'plan path must be a contained regular non-symlink file');
341
+ const planRead = readInspected(planFile, MAX_PLAN);
342
+ if (planRead.state === 'unsafe')
343
+ return diagnostic('PLAN_PATH_UNSAFE', 'plan path changed after inspection');
344
+ if (planRead.state === 'limit')
345
+ return diagnostic('PLAN_LIMIT', 'plan exceeds maximum size');
346
+ if (planRead.state === 'read')
347
+ return diagnostic('PLAN_READ', 'plan cannot be read');
348
+ bytes = planRead.bytes;
252
349
  }
253
350
  let text;
254
351
  try {
255
- text = normalizeLineEndings(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
352
+ text = normalizeLineEndings(new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes));
256
353
  }
257
354
  catch {
258
355
  return diagnostic('PLAN_ENCODING', 'plan must be valid UTF-8');
@@ -261,7 +358,7 @@ function validatePlanFile(planPath, cwd = process.cwd()) {
261
358
  const ends = count(text, END);
262
359
  const signaled = starts > 0 || ends > 0 || compactSchemaSignal(text);
263
360
  if (!signaled)
264
- return markerlessSchemaClassification(text) ?? { state: 'legacy' };
361
+ return markerlessSchemaClassification(text) ?? { state: 'migration-required', reason: 'unmarked-plan' };
265
362
  if (starts === 0 && ends === 0)
266
363
  return markerlessSchemaClassification(text) ?? diagnostic('PLAN_MARKERS', 'compact markers must occur once in order');
267
364
  if (starts !== 1 || ends !== 1 || text.indexOf(START) > text.indexOf(END)) {
@@ -300,7 +397,7 @@ function validatePlanFile(planPath, cwd = process.cwd()) {
300
397
  return diagnostic('PLAN_LIMIT', 'plan id exceeds maximum string size');
301
398
  if (raw.schema !== 'compact-slices/v1' || typeof raw.planId !== 'string' || !PLAN_ID.test(raw.planId) || !allStrings(raw.requirements) || new Set(raw.requirements).size !== raw.requirements.length)
302
399
  return diagnostic('PLAN_SHAPE', 'manifest scalar fields are invalid');
303
- if (raw.requirements.some((id) => !ID.test(id)) || !allStrings(raw.closureCommands) || new Set(raw.closureCommands).size !== raw.closureCommands.length)
400
+ if (raw.requirements.some((id) => !validRequirementId(id)) || !allStrings(raw.closureCommands) || new Set(raw.closureCommands).size !== raw.closureCommands.length)
304
401
  return diagnostic('PLAN_SHAPE', 'manifest arrays or identifiers are invalid');
305
402
  const sourceIds = new Set();
306
403
  for (const source of raw.sources) {
@@ -310,18 +407,16 @@ function validatePlanFile(planPath, cwd = process.cwd()) {
310
407
  const file = regularInside(root, source.path);
311
408
  if (!file)
312
409
  return diagnostic('PLAN_SOURCE_UNSAFE', 'source path must be a contained regular non-symlink file');
313
- let bytes;
314
- try {
315
- if (fs_1.default.statSync(file).size > MAX_SOURCE)
316
- return diagnostic('PLAN_SOURCE_LIMIT', 'source exceeds maximum size');
317
- bytes = fs_1.default.readFileSync(file);
318
- }
319
- catch {
410
+ const sourceRead = readInspected(file, MAX_SOURCE);
411
+ if (sourceRead.state === 'unsafe')
412
+ return diagnostic('PLAN_SOURCE_UNSAFE', 'source path changed after inspection');
413
+ if (sourceRead.state === 'limit')
414
+ return diagnostic('PLAN_SOURCE_LIMIT', 'source exceeds maximum size');
415
+ if (sourceRead.state === 'read')
320
416
  return diagnostic('PLAN_SOURCE_UNSAFE', 'source cannot be read');
321
- }
322
417
  let contents;
323
418
  try {
324
- contents = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
419
+ contents = new TextDecoder('utf-8', { fatal: true }).decode(sourceRead.bytes);
325
420
  }
326
421
  catch {
327
422
  return diagnostic('PLAN_SOURCE_ENCODING', 'source must be valid UTF-8');
@@ -332,7 +427,7 @@ function validatePlanFile(planPath, cwd = process.cwd()) {
332
427
  const commandIds = new Set();
333
428
  const closureIds = new Set(raw.closureCommands);
334
429
  for (const command of raw.commands) {
335
- if (!exact(command, ['id', 'program', 'args', 'covers']) || !validId(command.id) || commandIds.has(command.id) || typeof command.program !== 'string' || command.program.length === 0 || Buffer.byteLength(command.program, 'utf8') > MAX_STRING || !allStrings(command.args) || command.args.length > 128 || !allStrings(command.covers) || new Set(command.covers).size !== command.covers.length || (command.covers.length === 0 && !closureIds.has(command.id)) || command.covers.some((id) => !raw.requirements.includes(id)))
430
+ if (!exact(command, ['id', 'program', 'args', 'covers']) || !validId(command.id) || commandIds.has(command.id) || typeof command.program !== 'string' || command.program.length === 0 || Buffer.byteLength(command.program, 'utf8') > MAX_STRING || !allStrings(command.args) || command.args.length > 128 || !allStrings(command.covers) || new Set(command.covers).size !== command.covers.length || (command.covers.length === 0 && !closureIds.has(command.id)) || command.covers.some((id) => !validRequirementId(id) || !raw.requirements.includes(id)))
336
431
  return diagnostic('PLAN_COMMAND_SHAPE', 'command fields are invalid');
337
432
  commandIds.add(command.id);
338
433
  const program = command.program;
@@ -344,7 +439,7 @@ function validatePlanFile(planPath, cwd = process.cwd()) {
344
439
  const sliceIds = new Set();
345
440
  const owners = new Map();
346
441
  for (const slice of raw.slices) {
347
- if (!exact(slice, ['id', 'title', 'requirements', 'dependsOn', 'sectionAnchor', 'sources', 'redCommands', 'greenCommands', 'reviewEvidence', 'risk', 'fallback']) || !validId(slice.id) || sliceIds.has(slice.id) || typeof slice.title !== 'string' || slice.title.length === 0 || Buffer.byteLength(slice.title, 'utf8') > MAX_STRING || !allStrings(slice.requirements) || slice.requirements.length === 0 || slice.requirements.some((id) => !raw.requirements.includes(id)) || !allStrings(slice.dependsOn) || typeof slice.sectionAnchor !== 'string' || slice.sectionAnchor.length === 0 || Buffer.byteLength(slice.sectionAnchor, 'utf8') > MAX_STRING || !uniqueRefs(slice.sources, sourceIds) || !uniqueRefs(slice.redCommands, commandIds) || slice.redCommands.length === 0 || !uniqueRefs(slice.greenCommands, commandIds) || slice.greenCommands.length === 0 || !allStrings(slice.fallback) || slice.fallback.length === 0 || slice.fallback.some((item) => item.trim().length === 0) || slice.risk !== 'bounded' && slice.risk !== 'full-context')
442
+ if (!exact(slice, ['id', 'title', 'requirements', 'dependsOn', 'sectionAnchor', 'sources', 'redCommands', 'greenCommands', 'reviewEvidence', 'risk', 'fallback']) || !validId(slice.id) || sliceIds.has(slice.id) || typeof slice.title !== 'string' || slice.title.length === 0 || Buffer.byteLength(slice.title, 'utf8') > MAX_STRING || !allStrings(slice.requirements) || slice.requirements.length === 0 || slice.requirements.some((id) => !validRequirementId(id) || !raw.requirements.includes(id)) || !allStrings(slice.dependsOn) || typeof slice.sectionAnchor !== 'string' || slice.sectionAnchor.length === 0 || Buffer.byteLength(slice.sectionAnchor, 'utf8') > MAX_STRING || !uniqueRefs(slice.sources, sourceIds) || !uniqueRefs(slice.redCommands, commandIds) || slice.redCommands.length === 0 || !uniqueRefs(slice.greenCommands, commandIds) || slice.greenCommands.length === 0 || !allStrings(slice.fallback) || slice.fallback.length === 0 || slice.fallback.some((item) => item.trim().length === 0) || slice.risk !== 'bounded' && slice.risk !== 'full-context')
348
443
  return diagnostic('PLAN_SLICE_SHAPE', 'slice fields are invalid');
349
444
  sliceIds.add(slice.id);
350
445
  if (!Array.isArray(slice.reviewEvidence) || slice.reviewEvidence.length !== 2 || new Set(slice.reviewEvidence).size !== 2 || !slice.reviewEvidence.includes('specification') || !slice.reviewEvidence.includes('code-quality'))
@@ -389,5 +484,24 @@ function validatePlanFile(planPath, cwd = process.cwd()) {
389
484
  const markdown = checkMarkdown(text, slices);
390
485
  if (markdown)
391
486
  return markdown;
392
- return { state: 'valid', schema: 'compact-slices/v1', manifest: raw };
487
+ const report = {
488
+ state: 'valid', schema: 'compact-slices/v1',
489
+ planDigest: crypto_1.default.createHash('sha256').update(text, 'utf8').digest('hex'),
490
+ executionDigest: (0, identity_1.executionPlanDigest)(text),
491
+ manifest: raw, executionMode: executionModeFromValidatedText(text),
492
+ };
493
+ freezeJson(report);
494
+ verifiedValidReports.add(report);
495
+ verifiedSnapshots.set(report, text);
496
+ return report;
497
+ }
498
+ /** Read-only validator for a bounded compact-slices/v1 Markdown plan. */
499
+ function validatePlanFile(planPath, cwd = process.cwd()) {
500
+ return validatePlan(planPath, cwd);
501
+ }
502
+ /** Validate an already-authenticated plan snapshot without reopening planPath. */
503
+ function validatePlanSnapshot(planPath, cwd, text) {
504
+ if (typeof text !== 'string')
505
+ throw new Error('plan snapshot must be text');
506
+ return validatePlan(planPath, cwd, Buffer.from(text, 'utf8'));
393
507
  }
@@ -17,6 +17,22 @@ describe('exec-wrapper', () => {
17
17
  let dir;
18
18
  beforeEach(() => { dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-wrap-')); });
19
19
  afterEach(() => { fs_1.default.rmSync(dir, { recursive: true, force: true }); });
20
+ test.each(['../escape', '..\\escape', '/escape', 'C:\\escape', '', '.', '..', 'sentinel\n', 'x'.repeat(129)])('rejects unsafe sidecar components before writes (%#)', async (value) => {
21
+ for (const helper of [exec_wrapper_1.claimPath, exec_wrapper_1.identityPath, exec_wrapper_1.resultPath, exec_wrapper_1.logPath, exec_wrapper_1.replayVerdict]) {
22
+ expect(() => helper(dir, value, 'nonce')).toThrow(/sidecar/);
23
+ expect(() => helper(dir, 'job', value)).toThrow(/sidecar/);
24
+ }
25
+ await expect((0, exec_wrapper_1.runExecWrapper)({ logsRoot: path_1.default.join(dir, 'unused'), jobId: value, nonce: 'nonce', argv: ['node', '-e', 'process.exit(0)'], cwd: '.' })).rejects.toThrow(/sidecar/);
26
+ expect(fs_1.default.readdirSync(dir)).toEqual([]);
27
+ });
28
+ test.each([
29
+ { argv: ['node'], cwd: '../escape' },
30
+ { argv: [undefined], cwd: '.' },
31
+ { argv: ['node', 'sentinel\u0000'], cwd: '.' },
32
+ ])('rejects invalid execution inputs before sidecar writes (%#)', async (inputs) => {
33
+ await expect((0, exec_wrapper_1.runExecWrapper)({ logsRoot: path_1.default.join(dir, 'unused'), jobId: 'job', nonce: 'nonce', repoRoot: dir, ...inputs })).rejects.toThrow();
34
+ expect(fs_1.default.readdirSync(dir)).toEqual([]);
35
+ });
20
36
  test('claim + identity sidecar + resultado terminal atomico (R1.8)', async () => {
21
37
  const out = await (0, exec_wrapper_1.runExecWrapper)({ logsRoot: dir, jobId: 'job1', nonce: 'nonceA', argv: ['node', '-e', 'setTimeout(()=>process.exit(0), 300)'], cwd: '.' });
22
38
  expect(out.exitCode).toBe(0);
@@ -13,6 +13,8 @@ const reconcile_1 = require("../../../src/commands/job/reconcile");
13
13
  const reap_1 = require("../../../src/commands/job/reap");
14
14
  const job_1 = require("../../../src/commands/job");
15
15
  const watch_1 = require("../../../src/commands/watch");
16
+ const init_1 = require("../../../src/commands/watch/init");
17
+ const validate_1 = require("../../../src/core/plan/validate");
16
18
  const types_1 = require("../../../src/core/journal/types");
17
19
  const store_1 = require("../../../src/core/journal/store");
18
20
  const descriptor_1 = require("../../../src/core/tracks/descriptor");
@@ -501,6 +503,16 @@ function trackRefFixture(overrides, worktreePath) {
501
503
  function descriptorFixture(overrides, planRoot) {
502
504
  return { schema: 1, planRoot, planBranch: 'main', trackId: 'cli', planJournalId: 'j-1', fencingToken: 'f'.repeat(32), ...overrides };
503
505
  }
506
+ function initCompactJournal(repo, branch) {
507
+ const planPath = path_1.default.join(repo, 'plans', 'fixture.md');
508
+ fs_1.default.mkdirSync(path_1.default.dirname(planPath), { recursive: true });
509
+ fs_1.default.writeFileSync(planPath, fs_1.default.readFileSync(path_1.default.join(__dirname, '../../core/plan/fixtures/compact-slices-v1/valid.md'), 'utf8'));
510
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'source.md'), '## Canonical source\nfixture source\n');
511
+ const report = (0, validate_1.validatePlanFile)('plans/fixture.md', repo);
512
+ if (report.state !== 'valid')
513
+ throw new Error(`compact fixture must validate: ${JSON.stringify(report)}`);
514
+ (0, init_1.initWatch)(repo, branch, { path: 'plans/fixture.md', report });
515
+ }
504
516
  async function runCommand(register, argv, cwd) {
505
517
  const prog = new commander_1.Command();
506
518
  prog.exitOverride();
@@ -543,12 +555,12 @@ describe('guard de contexto en awm job / awm watch (R9.4)', () => {
543
555
  gitInitAt(planRoot, 'main');
544
556
  gitInitAt(trackRoot, 'track/cli');
545
557
  gitInitAt(otherRoot, 'track/cli');
546
- (0, store_1.initJournal)(planRoot, 'main');
558
+ initCompactJournal(planRoot, 'main');
547
559
  const planState = (0, store_1.readJournal)(planRoot, 'main').state;
548
560
  planState.journalId = 'j-1';
549
561
  planState.tracks = [trackRefFixture({ fencingToken: 'f'.repeat(32) }, trackRoot)];
550
562
  (0, store_1.writeJournal)(planRoot, 'main', planState);
551
- (0, store_1.initJournal)(trackRoot, 'track/cli');
563
+ initCompactJournal(trackRoot, 'track/cli');
552
564
  const trackState = (0, store_1.readJournal)(trackRoot, 'track/cli').state;
553
565
  trackState.trackContext = { trackId: 'cli', taskIds: [], planDigest: 'x', baseSha: 'y', planJournalId: 'j-1' };
554
566
  (0, store_1.writeJournal)(trackRoot, 'track/cli', trackState);
@@ -556,7 +568,7 @@ describe('guard de contexto en awm job / awm watch (R9.4)', () => {
556
568
  // otherRoot: mismo descriptor (mismo plan/trackId/fencing) pero NO es
557
569
  // el worktree que el TrackRef del plan declara — su realpath jamas
558
570
  // puede autenticar (R9.4), aunque el resto del descriptor "calce".
559
- (0, store_1.initJournal)(otherRoot, 'track/cli');
571
+ initCompactJournal(otherRoot, 'track/cli');
560
572
  const otherState = (0, store_1.readJournal)(otherRoot, 'track/cli').state;
561
573
  otherState.trackContext = { trackId: 'cli', taskIds: [], planDigest: 'x', baseSha: 'y', planJournalId: 'j-1' };
562
574
  (0, store_1.writeJournal)(otherRoot, 'track/cli', otherState);
@@ -580,24 +592,26 @@ describe('guard de contexto en awm job / awm watch (R9.4)', () => {
580
592
  expect(exitCode).toBe(1);
581
593
  expect(JSON.parse(out).pass).toBe(false);
582
594
  });
583
- test('`watch --init` desde un cwd SIN descriptor (repo comun, sin tracks) procede exactamente como antes de esta task', async () => {
595
+ test('`watch --init --plan` desde un cwd SIN descriptor crea un binding compacto', async () => {
584
596
  const plainRepo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-guard-plain-'));
585
597
  try {
586
598
  gitInitAt(plainRepo, 'main');
587
- const { out, err, exitCode } = await runWatch(['--init'], plainRepo);
599
+ const planPath = path_1.default.join(plainRepo, 'plans', 'fixture.md');
600
+ fs_1.default.mkdirSync(path_1.default.dirname(planPath), { recursive: true });
601
+ fs_1.default.writeFileSync(planPath, fs_1.default.readFileSync(path_1.default.join(__dirname, '../../core/plan/fixtures/compact-slices-v1/valid.md'), 'utf8'));
602
+ fs_1.default.writeFileSync(path_1.default.join(plainRepo, 'source.md'), '## Canonical source\nfixture source\n');
603
+ const { out, err, exitCode } = await runWatch(['--init', '--plan', 'plans/fixture.md'], plainRepo);
588
604
  expect(exitCode).toBeNull();
589
605
  expect(err).toBe('');
590
606
  expect(out).toContain('journal inicializado');
607
+ expect((0, store_1.readJournal)(plainRepo, 'main').state).toMatchObject({ schema: 2, planBinding: { path: 'plans/fixture.md', executionMode: 'desatendido' } });
591
608
  }
592
609
  finally {
593
610
  fs_1.default.rmSync(plainRepo, { recursive: true, force: true });
594
611
  }
595
612
  });
596
- test('`watch --init` desde el cwd autenticado (trackRoot) procede con normalidad', async () => {
597
- const { out, err, exitCode } = await runWatch(['--init'], trackRoot);
598
- expect(err).not.toContain('cwd no autenticado');
599
- expect(exitCode).toBeNull();
600
- expect(out).toContain('journal inicializado');
613
+ test('`watch --init --plan` desde el cwd autenticado alcanza la guarda de lifecycle y rechaza sobrescribir el binding', async () => {
614
+ await expect(runWatch(['--init', '--plan', 'plans/fixture.md'], trackRoot)).rejects.toThrow(/overwrite|sobrescribir/i);
601
615
  });
602
616
  test('`watch --init` desde un cwd cuyo descriptor no autentica rechaza con "cwd no autenticado"', async () => {
603
617
  const { err, exitCode } = await runWatch(['--init'], otherRoot);
@@ -7,30 +7,30 @@ Object.defineProperty(exports, "__esModule", { value: true });
7
7
  const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const os_1 = __importDefault(require("os"));
10
+ let tmpHome;
11
+ let originalHome;
12
+ let originalAwmHome;
13
+ beforeEach(() => {
14
+ tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-pin-home-'));
15
+ originalHome = process.env.HOME;
16
+ originalAwmHome = process.env.AWM_HOME;
17
+ process.env.HOME = tmpHome;
18
+ process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
19
+ jest.resetModules();
20
+ });
21
+ afterEach(() => {
22
+ fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
23
+ if (originalHome === undefined)
24
+ delete process.env.HOME;
25
+ else
26
+ process.env.HOME = originalHome;
27
+ if (originalAwmHome === undefined)
28
+ delete process.env.AWM_HOME;
29
+ else
30
+ process.env.AWM_HOME = originalAwmHome;
31
+ });
32
+ const readPrefs = () => JSON.parse(fs_1.default.readFileSync(path_1.default.join(tmpHome, '.awm/preferences.json'), 'utf-8'));
10
33
  describe('pin/unpin (editores de preferences)', () => {
11
- let tmpHome;
12
- let originalHome;
13
- let originalAwmHome;
14
- beforeEach(() => {
15
- tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-pin-home-'));
16
- originalHome = process.env.HOME;
17
- originalAwmHome = process.env.AWM_HOME;
18
- process.env.HOME = tmpHome;
19
- process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
20
- jest.resetModules();
21
- });
22
- afterEach(() => {
23
- fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
24
- if (originalHome === undefined)
25
- delete process.env.HOME;
26
- else
27
- process.env.HOME = originalHome;
28
- if (originalAwmHome === undefined)
29
- delete process.env.AWM_HOME;
30
- else
31
- process.env.AWM_HOME = originalAwmHome;
32
- });
33
- const readPrefs = () => JSON.parse(fs_1.default.readFileSync(path_1.default.join(tmpHome, '.awm/preferences.json'), 'utf-8'));
34
34
  it('setPin escribe pins.base normalizado (acepta prefijo v)', () => {
35
35
  const { setPin } = require('../../src/commands/pin');
36
36
  setPin('base', 'v1.2.0');