orchestrator-workflow 0.35.0 → 0.37.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.
@@ -0,0 +1,554 @@
1
+ import { parse as parseYaml } from "yaml";
2
+ /**
3
+ * Structural schema for the reviewer output contract (the final ```yaml
4
+ * block in `assets/agents/reviewer.md`, mirrored byte-for-byte in
5
+ * `assets/skill/references/contracts.md`). This module is the single
6
+ * hand-maintained copy of that shape in code; `test/docs-consistency.test.ts`
7
+ * parses the contract block itself and asserts it matches the constants
8
+ * below exactly, so a contract edit without a matching edit here fails the
9
+ * suite instead of drifting silently.
10
+ *
11
+ * This validator checks structure only. It never judges semantic adequacy,
12
+ * never waives a finding, and its passing is never orchestrator acceptance.
13
+ */
14
+ /** Top-level field names, in the contract's own order. */
15
+ export const TOP_LEVEL_FIELDS = [
16
+ "status",
17
+ "role",
18
+ "task_id",
19
+ "summary",
20
+ "findings",
21
+ "acceptance_recommendation",
22
+ "missing_tests",
23
+ "residual_risks",
24
+ "reproduction",
25
+ "method_applied",
26
+ "withdrawn",
27
+ ];
28
+ /** Field names of one `findings[]` entry, in the contract's own order. */
29
+ export const FINDING_FIELDS = [
30
+ "severity",
31
+ "category",
32
+ "description",
33
+ "suggested_fix",
34
+ "recurrence",
35
+ "introduced_by_delta",
36
+ ];
37
+ /** Field names of the `reproduction` object, in the contract's own order. */
38
+ export const REPRODUCTION_FIELDS = [
39
+ "method",
40
+ "sample_size",
41
+ "result",
42
+ "matches_implementer_claim",
43
+ ];
44
+ /** Field names of one `withdrawn[]` entry, in the contract's own order. */
45
+ export const WITHDRAWN_FIELDS = ["description", "reason"];
46
+ /**
47
+ * Enum spellings keyed by their bare field name. Every enum-bearing field
48
+ * in the contract has a name unique across the whole block, so a flat map
49
+ * (rather than one keyed by full path) is enough and matches how the
50
+ * contract text itself reads (`key: a | b | c`).
51
+ */
52
+ export const ENUM_VALUES = {
53
+ status: ["reviewed"],
54
+ role: ["reviewer"],
55
+ severity: ["low", "medium", "high", "critical"],
56
+ category: [
57
+ "correctness",
58
+ "architecture",
59
+ "security",
60
+ "tests",
61
+ "maintainability",
62
+ "performance",
63
+ "docs",
64
+ ],
65
+ recurrence: ["new", "repeated"],
66
+ introduced_by_delta: ["yes", "no", "unknown"],
67
+ acceptance_recommendation: [
68
+ "accept",
69
+ "accept_with_notes",
70
+ "fix_required",
71
+ "reject",
72
+ ],
73
+ matches_implementer_claim: ["matched", "mismatched", "not_applicable"],
74
+ method_applied: ["normal", "rigorous", "adversarial"],
75
+ };
76
+ /**
77
+ * This validator reports structural validity only. It does not judge
78
+ * semantic adequacy, cannot waive findings, and passing it does not
79
+ * constitute orchestrator acceptance.
80
+ */
81
+ export const STRUCTURAL_ONLY_NOTE = "Structural check only: this does not judge semantic adequacy, cannot waive findings, and does not constitute orchestrator acceptance.";
82
+ function describeValue(value) {
83
+ if (value === undefined)
84
+ return "missing";
85
+ if (value === null)
86
+ return "null";
87
+ if (Array.isArray(value))
88
+ return `array(length=${value.length})`;
89
+ if (typeof value === "object")
90
+ return "mapping";
91
+ if (typeof value === "string")
92
+ return JSON.stringify(value);
93
+ return String(value);
94
+ }
95
+ function isPlainRecord(value) {
96
+ return typeof value === "object" && value !== null && !Array.isArray(value);
97
+ }
98
+ function checkEnumField(record, key, allowed, path, diagnostics) {
99
+ const value = record[key];
100
+ const expected = allowed.join(" | ");
101
+ if (value === undefined) {
102
+ diagnostics.push({ path, expected, got: "missing" });
103
+ return;
104
+ }
105
+ if (typeof value !== "string" || !allowed.includes(value)) {
106
+ diagnostics.push({ path, expected, got: describeValue(value) });
107
+ }
108
+ }
109
+ function checkStringField(record, key, path, diagnostics) {
110
+ const value = record[key];
111
+ if (value === undefined) {
112
+ diagnostics.push({ path, expected: "string", got: "missing" });
113
+ return;
114
+ }
115
+ if (typeof value !== "string") {
116
+ diagnostics.push({ path, expected: "string", got: describeValue(value) });
117
+ }
118
+ }
119
+ function checkNonEmptyStringField(record, key, path, diagnostics) {
120
+ const value = record[key];
121
+ if (value === undefined) {
122
+ diagnostics.push({ path, expected: "non-empty string", got: "missing" });
123
+ return;
124
+ }
125
+ if (typeof value !== "string" || value.trim().length === 0) {
126
+ diagnostics.push({
127
+ path,
128
+ expected: "non-empty string",
129
+ got: describeValue(value),
130
+ });
131
+ }
132
+ }
133
+ /** Accepts a string or a number (a reviewer may write `sample_size: 5`). */
134
+ function checkScalarField(record, key, path, diagnostics) {
135
+ const value = record[key];
136
+ if (value === undefined) {
137
+ diagnostics.push({ path, expected: "string or number", got: "missing" });
138
+ return;
139
+ }
140
+ if (typeof value !== "string" && typeof value !== "number") {
141
+ diagnostics.push({
142
+ path,
143
+ expected: "string or number",
144
+ got: describeValue(value),
145
+ });
146
+ }
147
+ }
148
+ /**
149
+ * Checks a top-level `array`-kind field (`summary`, `missing_tests`,
150
+ * `residual_risks`): the contract writes each of these as a plain list of
151
+ * strings (`- ""`), so every element that is not a string is its own
152
+ * diagnostic at `<key>[<index>]`, `expected: "string"`, alongside the
153
+ * container-level checks. All offending elements are reported, not only
154
+ * the first, the same way {@link checkFindings} reports every non-mapping
155
+ * `findings[]` entry rather than stopping at one.
156
+ *
157
+ * Emptiness is deliberately not judged here: an element that is an
158
+ * explicitly quoted empty or blank string (`- ""`, `- " "`) is still a
159
+ * string and passes, the same tolerance {@link checkStringField}'s plain
160
+ * `"string"` kind gives a top-level field (unlike
161
+ * {@link checkNonEmptyStringField}'s `"non-empty-string"` kind, which
162
+ * `task_id` uses). A bare `- ` or `- ~` bullet is not a string at all --
163
+ * YAML parses either as `null`, which this checker rejects the same as
164
+ * any other non-string element; only a quoted placeholder passes. These
165
+ * three fields are declared `"array"` in {@link FIELD_KINDS}, not
166
+ * `"non-empty-string"`, so a quoted element gets the same tolerance its
167
+ * own kind implies; a reviewer emitting a quoted placeholder blank bullet
168
+ * is a content question the orchestrator judges, not a structural one
169
+ * this validator judges.
170
+ */
171
+ function checkArrayField(doc, key, diagnostics) {
172
+ const value = doc[key];
173
+ if (value === undefined) {
174
+ diagnostics.push({ path: key, expected: "array", got: "missing" });
175
+ return;
176
+ }
177
+ if (!Array.isArray(value)) {
178
+ diagnostics.push({
179
+ path: key,
180
+ expected: "array",
181
+ got: describeValue(value),
182
+ });
183
+ return;
184
+ }
185
+ value.forEach((element, index) => {
186
+ if (typeof element !== "string") {
187
+ diagnostics.push({
188
+ path: `${key}[${index}]`,
189
+ expected: "string",
190
+ got: describeValue(element),
191
+ });
192
+ }
193
+ });
194
+ }
195
+ /**
196
+ * Dispatch table keyed by every name in {@link FINDING_FIELDS}. The
197
+ * `Record<(typeof FINDING_FIELDS)[number], FieldChecker>` type means a
198
+ * name added to `FINDING_FIELDS` without a matching entry here is a
199
+ * TypeScript compile error, not a silently-unchecked field (fix-round,
200
+ * review finding M2).
201
+ */
202
+ const FINDING_CHECKS = {
203
+ severity: (entry, path, diagnostics) => checkEnumField(entry, "severity", ENUM_VALUES.severity, `${path}.severity`, diagnostics),
204
+ category: (entry, path, diagnostics) => checkEnumField(entry, "category", ENUM_VALUES.category, `${path}.category`, diagnostics),
205
+ description: (entry, path, diagnostics) => checkStringField(entry, "description", `${path}.description`, diagnostics),
206
+ suggested_fix: (entry, path, diagnostics) => checkStringField(entry, "suggested_fix", `${path}.suggested_fix`, diagnostics),
207
+ recurrence: (entry, path, diagnostics) => checkEnumField(entry, "recurrence", ENUM_VALUES.recurrence, `${path}.recurrence`, diagnostics),
208
+ introduced_by_delta: (entry, path, diagnostics) => checkEnumField(entry, "introduced_by_delta", ENUM_VALUES.introduced_by_delta, `${path}.introduced_by_delta`, diagnostics),
209
+ };
210
+ /** Dispatch table keyed by every name in {@link REPRODUCTION_FIELDS}. */
211
+ const REPRODUCTION_CHECKS = {
212
+ method: (value, path, diagnostics) => checkScalarField(value, "method", `${path}.method`, diagnostics),
213
+ sample_size: (value, path, diagnostics) => checkScalarField(value, "sample_size", `${path}.sample_size`, diagnostics),
214
+ result: (value, path, diagnostics) => checkScalarField(value, "result", `${path}.result`, diagnostics),
215
+ matches_implementer_claim: (value, path, diagnostics) => checkEnumField(value, "matches_implementer_claim", ENUM_VALUES.matches_implementer_claim, `${path}.matches_implementer_claim`, diagnostics),
216
+ };
217
+ /** Dispatch table keyed by every name in {@link WITHDRAWN_FIELDS}. */
218
+ const WITHDRAWN_CHECKS = {
219
+ description: (entry, path, diagnostics) => checkStringField(entry, "description", `${path}.description`, diagnostics),
220
+ reason: (entry, path, diagnostics) => checkStringField(entry, "reason", `${path}.reason`, diagnostics),
221
+ };
222
+ function checkFindings(doc, diagnostics) {
223
+ const value = doc.findings;
224
+ if (value === undefined) {
225
+ diagnostics.push({ path: "findings", expected: "array", got: "missing" });
226
+ return;
227
+ }
228
+ if (!Array.isArray(value)) {
229
+ diagnostics.push({
230
+ path: "findings",
231
+ expected: "array",
232
+ got: describeValue(value),
233
+ });
234
+ return;
235
+ }
236
+ value.forEach((entry, index) => {
237
+ const path = `findings[${index}]`;
238
+ if (!isPlainRecord(entry)) {
239
+ diagnostics.push({
240
+ path,
241
+ expected: "mapping",
242
+ got: describeValue(entry),
243
+ });
244
+ return;
245
+ }
246
+ for (const field of FINDING_FIELDS) {
247
+ FINDING_CHECKS[field](entry, path, diagnostics);
248
+ }
249
+ });
250
+ }
251
+ function checkReproduction(doc, diagnostics) {
252
+ const value = doc.reproduction;
253
+ if (value === undefined) {
254
+ diagnostics.push({
255
+ path: "reproduction",
256
+ expected: "mapping",
257
+ got: "missing",
258
+ });
259
+ return;
260
+ }
261
+ if (!isPlainRecord(value)) {
262
+ diagnostics.push({
263
+ path: "reproduction",
264
+ expected: "mapping",
265
+ got: describeValue(value),
266
+ });
267
+ return;
268
+ }
269
+ for (const field of REPRODUCTION_FIELDS) {
270
+ REPRODUCTION_CHECKS[field](value, "reproduction", diagnostics);
271
+ }
272
+ }
273
+ function checkWithdrawn(doc, diagnostics) {
274
+ const value = doc.withdrawn;
275
+ if (value === undefined) {
276
+ diagnostics.push({
277
+ path: "withdrawn",
278
+ expected: "array (may be empty)",
279
+ got: "missing",
280
+ });
281
+ return;
282
+ }
283
+ if (!Array.isArray(value)) {
284
+ diagnostics.push({
285
+ path: "withdrawn",
286
+ expected: "array (may be empty)",
287
+ got: describeValue(value),
288
+ });
289
+ return;
290
+ }
291
+ value.forEach((entry, index) => {
292
+ const path = `withdrawn[${index}]`;
293
+ if (!isPlainRecord(entry)) {
294
+ diagnostics.push({
295
+ path,
296
+ expected: "mapping",
297
+ got: describeValue(entry),
298
+ });
299
+ return;
300
+ }
301
+ for (const field of WITHDRAWN_FIELDS) {
302
+ WITHDRAWN_CHECKS[field](entry, path, diagnostics);
303
+ }
304
+ });
305
+ }
306
+ /**
307
+ * Dispatch table keyed by every name in {@link TOP_LEVEL_FIELDS}. As with
308
+ * {@link FINDING_CHECKS}, the `Record<(typeof TOP_LEVEL_FIELDS)[number],
309
+ * DocChecker>` type turns a `TOP_LEVEL_FIELDS` entry without a matching
310
+ * checker into a TypeScript compile error (fix-round, review finding M2):
311
+ * a field added to both the contract fences and this array can no longer
312
+ * go unchecked while every test stays green, since it fails to typecheck
313
+ * before any test runs.
314
+ */
315
+ const TOP_LEVEL_CHECKS = {
316
+ status: (doc, diagnostics) => checkEnumField(doc, "status", ENUM_VALUES.status, "status", diagnostics),
317
+ role: (doc, diagnostics) => checkEnumField(doc, "role", ENUM_VALUES.role, "role", diagnostics),
318
+ task_id: (doc, diagnostics) => checkNonEmptyStringField(doc, "task_id", "task_id", diagnostics),
319
+ summary: (doc, diagnostics) => checkArrayField(doc, "summary", diagnostics),
320
+ findings: checkFindings,
321
+ acceptance_recommendation: (doc, diagnostics) => checkEnumField(doc, "acceptance_recommendation", ENUM_VALUES.acceptance_recommendation, "acceptance_recommendation", diagnostics),
322
+ missing_tests: (doc, diagnostics) => checkArrayField(doc, "missing_tests", diagnostics),
323
+ residual_risks: (doc, diagnostics) => checkArrayField(doc, "residual_risks", diagnostics),
324
+ reproduction: checkReproduction,
325
+ method_applied: (doc, diagnostics) => checkEnumField(doc, "method_applied", ENUM_VALUES.method_applied, "method_applied", diagnostics),
326
+ withdrawn: checkWithdrawn,
327
+ };
328
+ /**
329
+ * The declared kind of every field of every contract constant, keyed by
330
+ * bare field name the way {@link ENUM_VALUES} is: a name is unique across
331
+ * the whole contract block except for `description`, which `findings[]`
332
+ * and `withdrawn[]` share with the same kind. The `satisfies
333
+ * Record<SchemaFieldName, FieldKind>` clause means a name added to any of
334
+ * the four constants without a kind here is a TypeScript compile error,
335
+ * the same way it is already an error to add one without a checker in the
336
+ * dispatch tables above. Were a future contract to reuse one name at two
337
+ * levels with two different kinds, this flat map could hold only one of
338
+ * them: the generator asserts each field's real diagnostics against its
339
+ * declared kind, so that shows up as a failing generated case rather than
340
+ * as an unchecked field.
341
+ */
342
+ export const FIELD_KINDS = {
343
+ status: "enum",
344
+ role: "enum",
345
+ task_id: "non-empty-string",
346
+ summary: "array",
347
+ findings: "mapping-list",
348
+ acceptance_recommendation: "enum",
349
+ missing_tests: "array",
350
+ residual_risks: "array",
351
+ reproduction: "mapping",
352
+ method_applied: "enum",
353
+ withdrawn: "mapping-list",
354
+ severity: "enum",
355
+ category: "enum",
356
+ description: "string",
357
+ suggested_fix: "string",
358
+ recurrence: "enum",
359
+ introduced_by_delta: "enum",
360
+ method: "scalar",
361
+ sample_size: "scalar",
362
+ result: "scalar",
363
+ matches_implementer_claim: "enum",
364
+ reason: "string",
365
+ };
366
+ /**
367
+ * The `expected` text a diagnostic carries, per kind. `enum` is absent on
368
+ * purpose: its text is the enum's own spellings, read from
369
+ * {@link ENUM_VALUES}.
370
+ */
371
+ const KIND_EXPECTED = {
372
+ string: "string",
373
+ "non-empty-string": "non-empty string",
374
+ scalar: "string or number",
375
+ array: "array",
376
+ "mapping-list": "array",
377
+ mapping: "mapping",
378
+ };
379
+ /**
380
+ * Fields whose diagnostic wording differs from their kind's default.
381
+ * `withdrawn`'s own text spells out that an empty list is fine, since a
382
+ * reviewer with nothing withdrawn must still emit the key.
383
+ */
384
+ const EXPECTED_OVERRIDES = {
385
+ withdrawn: "array (may be empty)",
386
+ };
387
+ /**
388
+ * The `expected` text this validator's diagnostics about `field` carry.
389
+ * The checkers above hold that text literally, at their own push sites;
390
+ * this is the schema's declaration of the same text, which the generated
391
+ * test cases assert the checkers actually produce, so a checker whose
392
+ * behaviour stops matching its declared kind fails a case instead of
393
+ * drifting quietly.
394
+ */
395
+ export function expectedTextFor(field) {
396
+ const override = EXPECTED_OVERRIDES[field];
397
+ if (override !== undefined)
398
+ return override;
399
+ const kind = FIELD_KINDS[field];
400
+ return kind === "enum" ? ENUM_VALUES[field].join(" | ") : KIND_EXPECTED[kind];
401
+ }
402
+ /** The `expected` text a diagnostic about one element of a `mapping-list` carries. */
403
+ export const MAPPING_LIST_ELEMENT_EXPECTED = KIND_EXPECTED.mapping;
404
+ /** The `expected` text a diagnostic about one non-string element of a plain `array`-kind field (`summary`, `missing_tests`, `residual_risks`) carries. */
405
+ export const ARRAY_ELEMENT_EXPECTED = KIND_EXPECTED.string;
406
+ /**
407
+ * A reviewer return is commonly wrapped in a single fenced code block.
408
+ * Strips one leading/trailing fence when present, whatever language tag
409
+ * it carries (```yaml, ```yml, a bare ``` , or any other tag -- a
410
+ * language-less fence previously fell through to the "literal YAML"
411
+ * branch below and produced a confusing YAML parse error instead of a
412
+ * clean structural diagnostic; fix-round, review finding L2); anything
413
+ * unfenced is treated as literal YAML. The fence is located anywhere in
414
+ * the input, not only at its very start: a return prefixed with prose
415
+ * ("Here is my report:\n```yaml ...") previously fell through to the
416
+ * "literal YAML" branch, since the fence pattern was anchored to the
417
+ * start of the string, and produced a raw parse error instead of a
418
+ * structural diagnostic (fix-round, review finding L1). Prose found
419
+ * before the opening fence or after the closing fence is tolerated, but
420
+ * each is named as its own warning rather than silently dropped.
421
+ *
422
+ * The opening fence's whole backtick run is captured, and the closing
423
+ * fence must be a run at least as long, starting at column 0, with
424
+ * nothing but whitespace after it (CommonMark's own rule): the pattern
425
+ * backreferences the captured run and anchors it with `^` under the `m`
426
+ * flag. So a triple-backtick sequence inside a value (a reviewer quoting
427
+ * a fenced snippet in a `description` block scalar, which YAML
428
+ * necessarily indents) can no longer close the block early and hand the
429
+ * parser a truncated document, which surfaced as diagnostics about
430
+ * fields the return actually carried (fix-round, review finding L3);
431
+ * and a return a reviewer wrapped in four backticks precisely because
432
+ * it contains a fence of its own is closed by its own four-backtick run
433
+ * rather than by that inner one. Matching a fixed three backticks
434
+ * instead of the run left a longer opener's remaining backticks in the
435
+ * info string, which read as the tag `` `yaml `` and matched no
436
+ * yaml/yml fence at all. The OPENING fence keeps its own position
437
+ * discipline unchanged: it is located anywhere in the input rather than
438
+ * anchored to a line start.
439
+ *
440
+ * Lookarounds on both sides of the run keep it whole, so the opening
441
+ * run is never re-entered at a shorter length. Without them, an input
442
+ * whose long backtick run has no valid closer is retried at every
443
+ * shorter run length from every offset inside the run, each retry
444
+ * rescanning the lazy body: work quadratic in the run's length, which a
445
+ * single pasted return of a few hundred backticks already turns into
446
+ * seconds (CHANGELOG [Unreleased] names the measurement). Keeping the
447
+ * run whole also makes the "closing run at least as long as the opening
448
+ * one" rule above literal: an opener longer than any closing run in the
449
+ * input is no fence at all, where splitting the run instead matched it
450
+ * and pushed the leftover backticks into the info string. One cost
451
+ * stays: every backtick run is still tried as an opener candidate, and a
452
+ * candidate with no qualifying closer scans to the end of the input, so
453
+ * an input of many runs with no valid closer costs work quadratic in the
454
+ * number of runs (well under a second at several thousand runs; a
455
+ * well-formed return is unaffected). Anchoring the opener to a line
456
+ * start would remove it, at the price of the position-free opener the
457
+ * paragraph above keeps.
458
+ *
459
+ * When the input carries more than one fenced block (a reviewer pasting
460
+ * a worked example ahead of the real return, say), the FIRST fence whose
461
+ * info string's first whitespace-delimited word is `yaml` or `yml`
462
+ * (case-insensitive) is preferred over every earlier fence, tagged or
463
+ * not; only when none of the fences carries that word does today's
464
+ * original first-fence behaviour apply. The whole info string is
465
+ * captured, not only a leading run of letters, so a tag followed by
466
+ * attributes (` ```yaml title=x `) is still recognised as `yaml` --
467
+ * previously the capture stopped at the first non-letter and required a
468
+ * newline right after it, so an attribute-bearing info string matched no
469
+ * fence at all, tagged or not; this also widens the untagged first-fence
470
+ * fallback, so an attribute-bearing fence with no yaml/yml word is at
471
+ * least recognised as a fence. This preference rule is not free of
472
+ * surprises of its own: a reviewer whose own return is left unfenced and
473
+ * who then quotes a ```yaml example afterward has that later example
474
+ * validated instead of their real return, which the emitted warning
475
+ * names. Preferring a later, differently-positioned fence over
476
+ * `fences[0]` is itself named as a warning, distinct from the existing
477
+ * before/after prose warnings; the before-warning is suppressed when the
478
+ * text preceding the chosen fence consists only of the skipped fence(s)
479
+ * and whitespace, since calling a legitimate (if unpreferred) fenced
480
+ * block "prose" alongside the skip warning that already names it is
481
+ * redundant; real prose ahead of a skipped fence still warns as before.
482
+ */
483
+ export function extractYamlSource(raw) {
484
+ const warnings = [];
485
+ // No BOM handling: the yaml parser accepts a leading U+FEFF and the
486
+ // fenced path trims it away with the surrounding prose.
487
+ const withoutBom = raw;
488
+ const fences = [
489
+ ...withoutBom.matchAll(/(?<!`)(`{3,})(?!`)([^\r\n]*)\r?\n([\s\S]*?)\r?\n?^\1`*[ \t]*$/gm),
490
+ ];
491
+ if (fences.length > 0) {
492
+ const fenceTag = (info) => info.trim().split(/\s+/, 1)[0] ?? "";
493
+ const yamlTaggedIndex = fences.findIndex((match) => /^(?:yaml|yml)$/i.test(fenceTag(match[2])));
494
+ const chosenIndex = yamlTaggedIndex >= 0 ? yamlTaggedIndex : 0;
495
+ const chosen = fences[chosenIndex];
496
+ if (chosenIndex > 0) {
497
+ const skippedCount = chosenIndex;
498
+ const noun = skippedCount === 1 ? "block" : "blocks";
499
+ const verb = skippedCount === 1 ? "was" : "were";
500
+ warnings.push(`${skippedCount} earlier fenced ${noun} without a yaml/yml tag ${verb} skipped in favor of the later \`${fenceTag(chosen[2])}\` fenced block; only that later block was validated`);
501
+ }
502
+ const start = chosen.index ?? 0;
503
+ const before = withoutBom.slice(0, start);
504
+ const beforeIsOnlySkippedFences = chosenIndex > 0 &&
505
+ fences
506
+ .slice(0, chosenIndex)
507
+ .reduce((text, skipped) => text.replace(skipped[0], ""), before)
508
+ .trim().length === 0;
509
+ if (before.trim().length > 0 && !beforeIsOnlySkippedFences) {
510
+ warnings.push("prose found before the opening ```yaml fence; only the fenced block was validated");
511
+ }
512
+ const inner = chosen[3];
513
+ const after = withoutBom.slice(start + chosen[0].length);
514
+ if (after.trim().length > 0) {
515
+ warnings.push("prose found after the closing ```yaml fence; only the fenced block was validated");
516
+ }
517
+ return { yamlText: inner, warnings };
518
+ }
519
+ return { yamlText: withoutBom, warnings };
520
+ }
521
+ /**
522
+ * Validates a reviewer return against the reviewer output contract's
523
+ * structure. Reports structural validity ONLY: it never judges semantic
524
+ * adequacy, never waives a finding, and passing it is never orchestrator
525
+ * acceptance.
526
+ */
527
+ export function validateReviewReport(raw) {
528
+ const diagnostics = [];
529
+ const { yamlText, warnings } = extractYamlSource(raw);
530
+ let parsed;
531
+ try {
532
+ parsed = parseYaml(yamlText);
533
+ }
534
+ catch (error) {
535
+ diagnostics.push({
536
+ path: "<root>",
537
+ expected: "valid YAML",
538
+ got: error instanceof Error ? error.message : String(error),
539
+ });
540
+ return { valid: false, diagnostics, warnings };
541
+ }
542
+ if (!isPlainRecord(parsed)) {
543
+ diagnostics.push({
544
+ path: "<root>",
545
+ expected: "a YAML mapping (object)",
546
+ got: describeValue(parsed),
547
+ });
548
+ return { valid: false, diagnostics, warnings };
549
+ }
550
+ for (const field of TOP_LEVEL_FIELDS) {
551
+ TOP_LEVEL_CHECKS[field](parsed, diagnostics);
552
+ }
553
+ return { valid: diagnostics.length === 0, diagnostics, warnings };
554
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orchestrator-workflow",
3
- "version": "0.35.0",
3
+ "version": "0.37.0",
4
4
  "description": "Installer for an orchestrator-led agent workflow: .ai/ run state, an AGENTS.md policy section, and per-harness subagent definitions for Claude Code, OpenAI Codex, and opencode",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -47,7 +47,8 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "commander": "^12.0.0",
50
- "inquirer": "^9.2.0"
50
+ "inquirer": "^9.2.0",
51
+ "yaml": "^2.9.1"
51
52
  },
52
53
  "devDependencies": {
53
54
  "@iarna/toml": "^2.2.5",