agentic-workflow-manager 9.3.0 → 9.4.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.
@@ -0,0 +1,393 @@
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.validatePlanFile = validatePlanFile;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const json_1 = require("./json");
10
+ const START = '<!-- AWM:COMPACT-SLICES:START v1 -->';
11
+ const END = '<!-- AWM:COMPACT-SLICES:END v1 -->';
12
+ const MAX_PLAN = 1024 * 1024;
13
+ const MAX_MANIFEST = 256 * 1024;
14
+ const MAX_SOURCE = 1024 * 1024;
15
+ const MAX_STRING = 4096;
16
+ const ID = /^[A-Z][A-Z0-9-]{0,63}$/;
17
+ const PLAN_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
18
+ const SHELL = new Set([
19
+ 'sh', 'bash', 'zsh', 'dash', 'ksh', 'fish', 'cmd', 'powershell', 'pwsh',
20
+ 'env', 'busybox', 'node', 'nodejs', 'deno', 'bun', 'python', 'python3', 'ruby', 'perl', 'php', 'lua',
21
+ ]);
22
+ function isLauncher(program) {
23
+ const base = path_1.default.posix.basename(program).toLowerCase();
24
+ 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);
25
+ }
26
+ function diagnostic(code, message, field) { return { state: 'invalid', diagnostics: [{ code, message, ...(field ? { field } : {}) }] }; }
27
+ function unsupported(schema) { return { state: 'unsupported', schema, diagnostics: [{ code: 'PLAN_UNSUPPORTED_SCHEMA', message: 'unsupported compact plan schema; update the CLI' }] }; }
28
+ function schemaClassification(candidate) {
29
+ if (!object(candidate) || !Object.prototype.hasOwnProperty.call(candidate, 'schema'))
30
+ return undefined;
31
+ if (typeof candidate.schema !== 'string')
32
+ return diagnostic('PLAN_MARKERS', 'compact markers must occur once in order');
33
+ if (Buffer.byteLength(candidate.schema, 'utf8') > MAX_STRING)
34
+ return diagnostic('PLAN_LIMIT', 'schema exceeds maximum string size');
35
+ if (candidate.schema.startsWith('compact-slices/') && candidate.schema !== 'compact-slices/v1')
36
+ return unsupported(candidate.schema);
37
+ return diagnostic('PLAN_MARKERS', 'compact markers must occur once in order');
38
+ }
39
+ 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
+ }
63
+ if (character === '{')
64
+ depth += 1;
65
+ if (character === '}' && --depth === 0) {
66
+ objects.push(text.slice(start, end + 1));
67
+ start = end;
68
+ break;
69
+ }
70
+ }
71
+ }
72
+ return objects;
73
+ }
74
+ function markerlessSchemaClassification(text) {
75
+ const candidates = [text.trim(), ...embeddedJsonObjects(text)];
76
+ for (const source of candidates) {
77
+ try {
78
+ const classification = schemaClassification((0, json_1.parseJsonNoDuplicate)(source));
79
+ if (classification)
80
+ return classification;
81
+ }
82
+ catch { /* try the next bounded JSON object */ }
83
+ }
84
+ return undefined;
85
+ }
86
+ function count(text, token) { return text.split(token).length - 1; }
87
+ function object(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
88
+ function jsonStringAt(text, start) {
89
+ if (text[start] !== '"')
90
+ return undefined;
91
+ for (let end = start + 1; end < text.length && end - start <= MAX_STRING; end += 1) {
92
+ if (text[end] === '\\') {
93
+ end += 1;
94
+ continue;
95
+ }
96
+ if (text[end] !== '"')
97
+ continue;
98
+ try {
99
+ const value = JSON.parse(text.slice(start, end + 1));
100
+ return typeof value === 'string' ? { value, end: end + 1 } : undefined;
101
+ }
102
+ catch {
103
+ return undefined;
104
+ }
105
+ }
106
+ return undefined;
107
+ }
108
+ function compactSchemaSignal(text) {
109
+ for (let start = text.indexOf('"'); start >= 0; start = text.indexOf('"', start + 1)) {
110
+ const key = jsonStringAt(text, start);
111
+ if (!key)
112
+ continue;
113
+ if (key.value !== 'schema') {
114
+ start = key.end - 1;
115
+ continue;
116
+ }
117
+ const colon = text.slice(key.end).match(/^\s*:/);
118
+ if (!colon)
119
+ continue;
120
+ const value = jsonStringAt(text, key.end + colon[0].length);
121
+ if (value?.value.startsWith('compact-slices/'))
122
+ return true;
123
+ }
124
+ return false;
125
+ }
126
+ function exact(value, fields) {
127
+ return object(value) && Object.keys(value).length === fields.length && fields.every((field) => Object.prototype.hasOwnProperty.call(value, field));
128
+ }
129
+ 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); }
131
+ 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
+ function validRelative(value) {
133
+ 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;
134
+ }
135
+ function regularInside(root, relative) {
136
+ if (!validRelative(relative))
137
+ return undefined;
138
+ const candidate = path_1.default.join(root, relative);
139
+ if (!inside(root, candidate))
140
+ return undefined;
141
+ const parts = relative.split('/');
142
+ let current = root;
143
+ try {
144
+ for (const part of parts) {
145
+ current = path_1.default.join(current, part);
146
+ if (fs_1.default.lstatSync(current).isSymbolicLink())
147
+ return undefined;
148
+ }
149
+ if (!fs_1.default.lstatSync(candidate).isFile())
150
+ return undefined;
151
+ const real = fs_1.default.realpathSync(candidate);
152
+ return inside(root, real) ? real : undefined;
153
+ }
154
+ catch {
155
+ return undefined;
156
+ }
157
+ }
158
+ function executableInside(root, relative) {
159
+ const file = regularInside(root, relative);
160
+ try {
161
+ return file && (process.platform === 'win32' ? /\.(?:exe|cmd|bat)$/i.test(file) : (fs_1.default.statSync(file).mode & 0o111) !== 0) ? file : undefined;
162
+ }
163
+ catch {
164
+ return undefined;
165
+ }
166
+ }
167
+ function refs(value, known) { return allStrings(value) && value.every((id) => known.has(id)); }
168
+ function uniqueRefs(value, known) { return refs(value, known) && new Set(value).size === value.length; }
169
+ function unsafeCommand(value) { return /[\0\r\n]|[`$]|\$\(|\$\{|[;&|<>*?\[\]{}()]/.test(value); }
170
+ function normalizeLineEndings(text) { return text.replace(/\r\n?/g, '\n'); }
171
+ function withoutFencedCode(text) {
172
+ let fence;
173
+ return text.split('\n').map((line) => {
174
+ const marker = /^(?: {0,3})(`{3,}|~{3,})/.exec(line)?.[1];
175
+ if (marker && (!fence || (marker[0] === fence.character && marker.length >= fence.length))) {
176
+ fence = fence ? undefined : { character: marker[0], length: marker.length };
177
+ return '';
178
+ }
179
+ return fence ? '' : line;
180
+ }).join('\n');
181
+ }
182
+ function checkMarkdown(text, slices) {
183
+ const markdown = withoutFencedCode(text);
184
+ const headingOffsets = new Set();
185
+ for (const slice of slices) {
186
+ const anchor = `<a id="${slice.sectionAnchor}"></a>`;
187
+ if (count(markdown, anchor) !== 1)
188
+ return diagnostic('PLAN_MARKDOWN_ANCHOR', 'slice anchor must occur exactly once');
189
+ const start = markdown.indexOf(anchor);
190
+ const heading = `### Slice ${slice.id}: ${slice.title}`;
191
+ const expectedHeading = start + anchor.length + 1;
192
+ if (markdown[start + anchor.length] !== '\n')
193
+ return diagnostic('PLAN_MARKDOWN_HEADING', 'slice heading must immediately follow anchor');
194
+ const headingEnd = markdown.indexOf('\n', expectedHeading);
195
+ if (markdown.slice(expectedHeading, headingEnd < 0 ? markdown.length : headingEnd) !== heading)
196
+ return diagnostic('PLAN_MARKDOWN_HEADING', 'slice heading must exactly match manifest after anchor');
197
+ const headingAt = expectedHeading;
198
+ headingOffsets.add(headingAt);
199
+ }
200
+ for (const match of markdown.matchAll(/^### Slice .*$/gm)) {
201
+ if (!headingOffsets.has(match.index ?? -1))
202
+ return diagnostic('PLAN_MARKDOWN_HEADING', 'slice headings must immediately follow declared anchors');
203
+ }
204
+ for (const slice of slices) {
205
+ const anchor = `<a id="${slice.sectionAnchor}"></a>`;
206
+ const start = markdown.indexOf(anchor);
207
+ const heading = `### Slice ${slice.id}: ${slice.title}`;
208
+ const headingAt = start + anchor.length + 1;
209
+ const next = markdown.indexOf('\n### Slice ', headingAt + heading.length);
210
+ const end = next < 0 ? markdown.length : next;
211
+ const section = markdown.slice(start, end);
212
+ for (const name of ['Surfaces', 'Implementation', 'Edge cases', 'Evidence', 'Fallback']) {
213
+ if ((section.match(new RegExp(`^#### ${name}\\s*$`, 'gm')) ?? []).length !== 1)
214
+ return diagnostic('PLAN_MARKDOWN_SECTION', 'each required slice subsection must occur exactly once');
215
+ const match = new RegExp(`^#### ${name}\\s*\\n\\s*([^#\\s][\\s\\S]*?)(?=\\n#### |$)`, 'm').exec(section);
216
+ if (!match || /\b(?:TODO|TBD|placeholder|draft)\b/i.test(match[1]))
217
+ return diagnostic('PLAN_MARKDOWN_SECTION', 'slice section must contain complete prose');
218
+ }
219
+ }
220
+ return undefined;
221
+ }
222
+ /** Read-only validator for a bounded compact-slices/v1 Markdown plan. */
223
+ function validatePlanFile(planPath, cwd = process.cwd()) {
224
+ if (typeof planPath !== 'string' || planPath.length === 0)
225
+ throw new Error('planPath must be a non-empty path');
226
+ if (typeof cwd !== 'string' || cwd.length === 0)
227
+ throw new Error('cwd must be a non-empty directory path');
228
+ let root;
229
+ try {
230
+ root = fs_1.default.realpathSync(cwd);
231
+ if (!fs_1.default.statSync(root).isDirectory())
232
+ throw new Error();
233
+ }
234
+ catch {
235
+ throw new Error('cwd must resolve to an existing directory');
236
+ }
237
+ const candidate = path_1.default.resolve(root, planPath);
238
+ if (!inside(root, candidate))
239
+ return diagnostic('PLAN_PATH_UNSAFE', 'plan path must be inside cwd');
240
+ 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
+ let bytes;
245
+ try {
246
+ if (fs_1.default.statSync(planFile).size > MAX_PLAN)
247
+ return diagnostic('PLAN_LIMIT', 'plan exceeds maximum size');
248
+ bytes = fs_1.default.readFileSync(planFile);
249
+ }
250
+ catch {
251
+ return diagnostic('PLAN_READ', 'plan cannot be read');
252
+ }
253
+ let text;
254
+ try {
255
+ text = normalizeLineEndings(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
256
+ }
257
+ catch {
258
+ return diagnostic('PLAN_ENCODING', 'plan must be valid UTF-8');
259
+ }
260
+ const starts = count(text, START);
261
+ const ends = count(text, END);
262
+ const signaled = starts > 0 || ends > 0 || compactSchemaSignal(text);
263
+ if (!signaled)
264
+ return markerlessSchemaClassification(text) ?? { state: 'legacy' };
265
+ if (starts === 0 && ends === 0)
266
+ return markerlessSchemaClassification(text) ?? diagnostic('PLAN_MARKERS', 'compact markers must occur once in order');
267
+ if (starts !== 1 || ends !== 1 || text.indexOf(START) > text.indexOf(END)) {
268
+ const partial = starts === 1 ? text.slice(text.indexOf(START) + START.length, ends ? text.indexOf(END) : undefined).trim() : '';
269
+ try {
270
+ const candidate = (0, json_1.parseJsonNoDuplicate)(partial);
271
+ if (object(candidate) && typeof candidate.schema === 'string' && Buffer.byteLength(candidate.schema, 'utf8') <= MAX_STRING && candidate.schema.startsWith('compact-slices/') && candidate.schema !== 'compact-slices/v1')
272
+ return unsupported(candidate.schema);
273
+ }
274
+ catch { /* invalid below */ }
275
+ return diagnostic('PLAN_MARKERS', 'compact markers must occur once in order');
276
+ }
277
+ const body = text.slice(text.indexOf(START) + START.length, text.indexOf(END));
278
+ if (Buffer.byteLength(body, 'utf8') > MAX_MANIFEST)
279
+ return diagnostic('PLAN_LIMIT', 'manifest exceeds maximum size');
280
+ let raw;
281
+ try {
282
+ raw = (0, json_1.parseJsonNoDuplicate)(body.trim());
283
+ }
284
+ catch {
285
+ return diagnostic('PLAN_JSON', 'manifest must be valid JSON without duplicate keys');
286
+ }
287
+ if (!object(raw))
288
+ return diagnostic('PLAN_SHAPE', 'manifest must be an object');
289
+ if (typeof raw.schema === 'string' && Buffer.byteLength(raw.schema, 'utf8') > MAX_STRING)
290
+ return diagnostic('PLAN_LIMIT', 'schema exceeds maximum string size');
291
+ if (typeof raw.schema === 'string' && raw.schema.startsWith('compact-slices/') && raw.schema !== 'compact-slices/v1')
292
+ return unsupported(raw.schema);
293
+ if (!exact(raw, ['schema', 'planId', 'requirements', 'sources', 'commands', 'slices', 'closureCommands']))
294
+ return diagnostic(Object.keys(raw).some((key) => !['schema', 'planId', 'requirements', 'sources', 'commands', 'slices', 'closureCommands'].includes(key)) ? 'PLAN_UNKNOWN_FIELD' : 'PLAN_MISSING_FIELD', 'manifest fields must exactly match the contract');
295
+ if (!Array.isArray(raw.requirements) || !Array.isArray(raw.sources) || !Array.isArray(raw.commands) || !Array.isArray(raw.slices) || !Array.isArray(raw.closureCommands))
296
+ return diagnostic('PLAN_SHAPE', 'manifest collections are invalid');
297
+ if (raw.requirements.length > 256 || raw.sources.length > 256 || raw.commands.length > 512 || raw.slices.length > 64 || raw.closureCommands.length > 512)
298
+ return diagnostic('PLAN_LIMIT', 'manifest exceeds collection limits');
299
+ if (typeof raw.planId === 'string' && Buffer.byteLength(raw.planId, 'utf8') > MAX_STRING)
300
+ return diagnostic('PLAN_LIMIT', 'plan id exceeds maximum string size');
301
+ 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
+ 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)
304
+ return diagnostic('PLAN_SHAPE', 'manifest arrays or identifiers are invalid');
305
+ const sourceIds = new Set();
306
+ for (const source of raw.sources) {
307
+ if (!exact(source, ['id', 'path', 'locator', 'fact']) || !validId(source.id) || sourceIds.has(source.id) || !validRelative(source.path) || typeof source.locator !== 'string' || source.locator.trim().length === 0 || /[\0\r\n]/.test(source.locator) || Buffer.byteLength(source.locator, 'utf8') > MAX_STRING || typeof source.fact !== 'string' || source.fact.trim().length === 0 || Buffer.byteLength(source.fact, 'utf8') > MAX_STRING)
308
+ return diagnostic('PLAN_SOURCE_SHAPE', 'source fields are invalid');
309
+ sourceIds.add(source.id);
310
+ const file = regularInside(root, source.path);
311
+ if (!file)
312
+ 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 {
320
+ return diagnostic('PLAN_SOURCE_UNSAFE', 'source cannot be read');
321
+ }
322
+ let contents;
323
+ try {
324
+ contents = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
325
+ }
326
+ catch {
327
+ return diagnostic('PLAN_SOURCE_ENCODING', 'source must be valid UTF-8');
328
+ }
329
+ if (!contents.includes(source.locator))
330
+ return diagnostic('PLAN_SOURCE_LOCATOR', 'source locator must occur in source file');
331
+ }
332
+ const commandIds = new Set();
333
+ const closureIds = new Set(raw.closureCommands);
334
+ 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)))
336
+ return diagnostic('PLAN_COMMAND_SHAPE', 'command fields are invalid');
337
+ commandIds.add(command.id);
338
+ const program = command.program;
339
+ if (isLauncher(program) || unsafeCommand(program) || program.includes('\\') || /^[a-zA-Z]:/.test(program) || /\s/.test(program) || (program.includes('/') && !executableInside(root, program)))
340
+ return diagnostic('PLAN_COMMAND_UNSAFE', 'command program is not inert and safe');
341
+ if (command.args.some(unsafeCommand))
342
+ return diagnostic('PLAN_COMMAND_UNSAFE', 'command arguments contain shell syntax');
343
+ }
344
+ const sliceIds = new Set();
345
+ const owners = new Map();
346
+ 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')
348
+ return diagnostic('PLAN_SLICE_SHAPE', 'slice fields are invalid');
349
+ sliceIds.add(slice.id);
350
+ if (!Array.isArray(slice.reviewEvidence) || slice.reviewEvidence.length !== 2 || new Set(slice.reviewEvidence).size !== 2 || !slice.reviewEvidence.includes('specification') || !slice.reviewEvidence.includes('code-quality'))
351
+ return diagnostic('PLAN_REVIEW_EVIDENCE', 'review evidence must be specification and code-quality');
352
+ for (const requirement of slice.requirements)
353
+ owners.set(requirement, (owners.get(requirement) ?? 0) + 1);
354
+ }
355
+ if (raw.requirements.some((requirement) => owners.get(requirement) !== 1))
356
+ return diagnostic('PLAN_REQUIREMENT_OWNER', 'each requirement must have exactly one owner');
357
+ const slices = raw.slices;
358
+ for (const slice of slices) {
359
+ if (new Set(slice.dependsOn).size !== slice.dependsOn.length || slice.dependsOn.includes(slice.id) || !slice.dependsOn.every((id) => sliceIds.has(id)))
360
+ return diagnostic('PLAN_DEPENDENCY', 'slice dependencies must resolve and be non-self unique');
361
+ }
362
+ const visited = new Set();
363
+ const visiting = new Set();
364
+ const byId = new Map(slices.map((slice) => [slice.id, slice]));
365
+ const cycle = (id) => { if (visiting.has(id))
366
+ return true; if (visited.has(id))
367
+ return false; visiting.add(id); const found = byId.get(id).dependsOn.some(cycle); visiting.delete(id); visited.add(id); return found; };
368
+ if (slices.some((slice) => cycle(slice.id)))
369
+ return diagnostic('PLAN_DEPENDENCY', 'slice dependencies must be acyclic');
370
+ for (const command of raw.commands) {
371
+ const covers = command.covers;
372
+ const references = slices.filter((slice) => slice.redCommands.includes(command.id) || slice.greenCommands.includes(command.id));
373
+ if (covers.length > 0 && !references.some((slice) => covers.some((requirement) => slice.requirements.includes(requirement))))
374
+ return diagnostic('PLAN_COMMAND_COVER', 'command coverage must relate to a referencing slice');
375
+ }
376
+ for (const slice of slices) {
377
+ for (const requirement of slice.requirements) {
378
+ const covered = raw.commands.some((command) => command.covers.includes(requirement) && (slice.redCommands.includes(command.id) || slice.greenCommands.includes(command.id)));
379
+ if (!covered)
380
+ return diagnostic('PLAN_REQUIREMENT_COVER', 'each requirement must be covered by a command executed by its owning slice');
381
+ }
382
+ }
383
+ const usedSources = new Set(slices.flatMap((slice) => slice.sources));
384
+ const usedCommands = new Set([...slices.flatMap((slice) => [...slice.redCommands, ...slice.greenCommands]), ...raw.closureCommands]);
385
+ if (Array.from(sourceIds).some((id) => !usedSources.has(id)) || Array.from(commandIds).some((id) => !usedCommands.has(id)) || !refs(raw.closureCommands, commandIds))
386
+ return diagnostic('PLAN_ORPHAN', 'sources and commands must be referenced');
387
+ if (raw.requirements.length === 0 || raw.sources.length === 0 || raw.commands.length === 0 || raw.slices.length === 0 || raw.closureCommands.length === 0)
388
+ return diagnostic('PLAN_SHAPE', 'manifest collections must be nonempty');
389
+ const markdown = checkMarkdown(text, slices);
390
+ if (markdown)
391
+ return markdown;
392
+ return { state: 'valid', schema: 'compact-slices/v1', manifest: raw };
393
+ }
package/dist/src/index.js CHANGED
@@ -31,6 +31,7 @@ const sensors_1 = require("./commands/sensors");
31
31
  const ledger_1 = require("./commands/ledger");
32
32
  const context_budget_1 = require("./commands/context-budget");
33
33
  const preflight_1 = require("./commands/preflight");
34
+ const plan_1 = require("./commands/plan");
34
35
  const doctor_1 = require("./commands/doctor");
35
36
  const backup_1 = require("./commands/backup");
36
37
  const init_1 = require("./commands/init");
@@ -52,7 +53,11 @@ const update_check_1 = require("./core/update-check");
52
53
  const cli_version_1 = require("./core/cli-version");
53
54
  const program = new commander_1.Command();
54
55
  program.name('awm').description('Agentic Workflow Manager').version((0, cli_version_1.cliVersion)());
55
- program.hook('postAction', () => {
56
+ program.hook('postAction', (_command, actionCommand) => {
57
+ if (actionCommand.name() === 'preflight')
58
+ return;
59
+ if (actionCommand.parent?.name() === 'plan' && actionCommand.name() === 'validate' && actionCommand.opts().json === true)
60
+ return;
56
61
  try {
57
62
  (0, update_check_1.maybeNotifyUpdate)();
58
63
  }
@@ -726,6 +731,7 @@ miroCmd.command('sync <storyMapPath>')
726
731
  (0, ledger_1.registerLedgerCommand)(program);
727
732
  (0, context_budget_1.registerContextBudgetCommand)(program);
728
733
  (0, preflight_1.registerPreflightCommand)(program);
734
+ (0, plan_1.registerPlanCommand)(program);
729
735
  (0, doctor_1.registerDoctorCommand)(program);
730
736
  (0, backup_1.registerBackupCommand)(program);
731
737
  (0, init_1.registerInitCommand)(program);
@@ -0,0 +1,154 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const commander_1 = require("commander");
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const os_1 = __importDefault(require("os"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const child_process_1 = require("child_process");
11
+ const plan_1 = require("../../../src/commands/plan");
12
+ const stdoutWrite = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
13
+ const valid = {
14
+ state: 'valid', schema: 'compact-slices/v1', manifest: {
15
+ schema: 'compact-slices/v1', planId: 'r4-plan', requirements: ['R4-VAL-1', 'R4-VAL-4'],
16
+ sources: [], commands: [], slices: [], closureCommands: [],
17
+ },
18
+ };
19
+ const invalid = {
20
+ state: 'invalid', diagnostics: [{ code: 'PLAN_MARKERS', message: 'compact markers must occur once in order' }],
21
+ };
22
+ const unsupported = {
23
+ state: 'unsupported', schema: 'compact-slices/v2', diagnostics: [{ code: 'PLAN_UNSUPPORTED_SCHEMA', message: 'unsupported compact plan schema; update the CLI' }],
24
+ };
25
+ function commandFor(report, calls = []) {
26
+ const program = new commander_1.Command();
27
+ program.exitOverride();
28
+ program.configureOutput({ writeErr: () => undefined });
29
+ (0, plan_1.registerPlanCommand)(program, {
30
+ validatePlanFile: (planPath, cwd) => {
31
+ calls.push([planPath, cwd]);
32
+ return report;
33
+ },
34
+ });
35
+ return program;
36
+ }
37
+ describe('plan validate Commander wiring', () => {
38
+ const previousExitCode = process.exitCode;
39
+ beforeEach(() => {
40
+ jest.clearAllMocks();
41
+ process.exitCode = undefined;
42
+ });
43
+ afterAll(() => {
44
+ stdoutWrite.mockRestore();
45
+ process.exitCode = previousExitCode;
46
+ });
47
+ it('emits deterministic human output for a valid compact plan', async () => {
48
+ await commandFor(valid).parseAsync(['node', 'awm', 'plan', 'validate', 'plans/r4.md']);
49
+ expect(String(stdoutWrite.mock.calls[0][0])).toBe('Plan validation: valid "plans/r4.md" (compact-slices/v1; 0 slices; 2 requirements; complete ownership)\n');
50
+ expect(process.exitCode).toBe(0);
51
+ });
52
+ it('emits one stable JSON object without an update banner', async () => {
53
+ await commandFor(valid).parseAsync(['node', 'awm', 'plan', 'validate', 'plans/r4.md', '--json']);
54
+ expect(JSON.parse(String(stdoutWrite.mock.calls[0][0]))).toEqual({
55
+ state: 'valid', path: 'plans/r4.md', schema: 'compact-slices/v1', planId: 'r4-plan',
56
+ requirements: 2, sources: 0, commands: 0, slices: 0, completeOwnership: true,
57
+ });
58
+ expect(String(stdoutWrite.mock.calls[0][0])).not.toContain('update');
59
+ });
60
+ it('keeps a legacy plan on the existing full-quality path with exit 0', async () => {
61
+ await commandFor({ state: 'legacy' }).parseAsync(['node', 'awm', 'plan', 'validate', 'legacy.md']);
62
+ expect(String(stdoutWrite.mock.calls[0][0])).toBe('Plan validation: legacy "legacy.md" — existing full-quality path applies; compact optimization was not requested.\n');
63
+ expect(process.exitCode).toBe(0);
64
+ });
65
+ it.each([
66
+ ['invalid', invalid],
67
+ ['unsupported', unsupported],
68
+ ])('writes complete %s diagnostics before assigning semantic exit 2', async (state, report) => {
69
+ const observedExitCodes = [];
70
+ stdoutWrite.mockImplementation(() => {
71
+ observedExitCodes.push(process.exitCode);
72
+ return true;
73
+ });
74
+ await commandFor(report).parseAsync(['node', 'awm', 'plan', 'validate', 'broken.md', '--json']);
75
+ expect(JSON.parse(String(stdoutWrite.mock.calls[0][0]))).toMatchObject({ state, path: 'broken.md', diagnostics: report.diagnostics });
76
+ expect(observedExitCodes).toEqual([undefined]);
77
+ expect(process.exitCode).toBe(2);
78
+ });
79
+ it('passes an explicit cwd only to validation while retaining the original path text in output', async () => {
80
+ const calls = [];
81
+ await commandFor(valid, calls).parseAsync(['node', 'awm', 'plan', 'validate', 'plans/r4.md', '--cwd', 'fixture-root']);
82
+ expect(calls).toEqual([['plans/r4.md', 'fixture-root']]);
83
+ expect(String(stdoutWrite.mock.calls[0][0])).toContain('"plans/r4.md"');
84
+ });
85
+ it('rejects missing paths, extra arguments, and missing --cwd values before validation', async () => {
86
+ const calls = [];
87
+ await expect(commandFor(valid, calls).parseAsync(['node', 'awm', 'plan', 'validate'])).rejects.toThrow();
88
+ await expect(commandFor(valid, calls).parseAsync(['node', 'awm', 'plan', 'validate', 'one.md', 'two.md'])).rejects.toThrow();
89
+ await expect(commandFor(valid, calls).parseAsync(['node', 'awm', 'plan', 'validate', 'one.md', '--cwd', '--json'])).rejects.toThrow();
90
+ expect(calls).toEqual([]);
91
+ });
92
+ it.each(['plans/escape\u001b.md', 'plans/tab\t.md'])('rejects control characters in the public plan path %j', async (planPath) => {
93
+ const calls = [];
94
+ await expect(commandFor(valid, calls).parseAsync(['node', 'awm', 'plan', 'validate', planPath])).rejects.toThrow('plan path must be a non-empty path without control characters');
95
+ expect(calls).toEqual([]);
96
+ });
97
+ it.each(['fixture\u001b-root', 'fixture\troot'])('rejects control characters in the public --cwd value %j', async (cwd) => {
98
+ const calls = [];
99
+ await expect(commandFor(valid, calls).parseAsync(['node', 'awm', 'plan', 'validate', 'plans/r4.md', '--cwd', cwd])).rejects.toThrow('--cwd must be a non-empty path without control characters');
100
+ expect(calls).toEqual([]);
101
+ });
102
+ it('does not emit terminal control characters in unsupported human output', () => {
103
+ const report = {
104
+ state: 'unsupported', schema: 'compact-slices/v2\u001b[2J',
105
+ diagnostics: [{ code: 'PLAN\tSCHEMA', message: 'unsafe\u001b[31m diagnostic', field: 'schema\r' }],
106
+ };
107
+ const output = (0, plan_1.formatReport)(report, 'plans/unsafe\u001b.md');
108
+ expect(output.replace(/\n/g, '')).not.toMatch(/[\u0000-\u001f\u007f-\u009f]/);
109
+ expect(output).toContain('compact-slices/v2\\u001b[2J');
110
+ });
111
+ it('suppresses the passive update notification only for root-program JSON validation', () => {
112
+ const awmHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-plan-update-'));
113
+ const repoRoot = path_1.default.resolve(__dirname, '../../../..');
114
+ const indexPath = path_1.default.join(repoRoot, 'cli', 'src', 'index.ts');
115
+ const registerPath = require.resolve('ts-node/register');
116
+ fs_1.default.writeFileSync(path_1.default.join(awmHome, 'update-check.json'), JSON.stringify({ lastCheck: Date.now(), latest: '999.0.0' }));
117
+ try {
118
+ const result = (0, child_process_1.spawnSync)(process.execPath, ['-r', registerPath, indexPath, 'plan', 'validate', 'docs/plans/2026-08-26-r4a-compact-plan-cli-plan.md', '--cwd', repoRoot, '--json'], {
119
+ cwd: repoRoot,
120
+ encoding: 'utf8',
121
+ env: { ...process.env, AWM_HOME: awmHome, TS_NODE_PROJECT: path_1.default.join(repoRoot, 'cli', 'tsconfig.json') },
122
+ stdio: ['ignore', 'pipe', 'pipe'],
123
+ });
124
+ expect(result.status).toBe(0);
125
+ expect(() => JSON.parse(result.stdout)).not.toThrow();
126
+ expect(JSON.parse(result.stdout)).toMatchObject({ state: 'valid', path: 'docs/plans/2026-08-26-r4a-compact-plan-cli-plan.md' });
127
+ expect(result.stderr).toBe('');
128
+ const humanResult = (0, child_process_1.spawnSync)(process.execPath, ['-r', registerPath, indexPath, 'plan', 'validate', 'docs/plans/2026-08-26-r4a-compact-plan-cli-plan.md', '--cwd', repoRoot], {
129
+ cwd: repoRoot,
130
+ encoding: 'utf8',
131
+ env: { ...process.env, AWM_HOME: awmHome, TS_NODE_PROJECT: path_1.default.join(repoRoot, 'cli', 'tsconfig.json') },
132
+ stdio: ['ignore', 'pipe', 'pipe'],
133
+ });
134
+ expect(humanResult.status).toBe(0);
135
+ expect(humanResult.stderr).toContain('awm v999.0.0 available');
136
+ }
137
+ finally {
138
+ fs_1.default.rmSync(awmHome, { recursive: true, force: true });
139
+ }
140
+ });
141
+ });
142
+ describe('plan validation public boundaries', () => {
143
+ it.each([
144
+ ['valid', valid, 0],
145
+ ['legacy', { state: 'legacy' }, 0],
146
+ ['invalid', { state: 'invalid', diagnostics: [] }, 2],
147
+ ['unsupported', { state: 'unsupported', schema: 'compact-slices/v2', diagnostics: [] }, 2],
148
+ ])('maps %s reports to exit %i', (_state, report, code) => {
149
+ expect((0, plan_1.exitCodeFor)(report)).toBe(code);
150
+ });
151
+ it('fails loudly for an invalid validator dependency', () => {
152
+ expect(() => (0, plan_1.registerPlanCommand)(new commander_1.Command(), {})).toThrow('validatePlanFile must be a function');
153
+ });
154
+ });