@cleocode/playbooks 2026.6.12 → 2026.6.14

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.
package/src/parser.ts CHANGED
@@ -34,6 +34,7 @@ import type {
34
34
  PlaybookDefinition,
35
35
  PlaybookDeterministicNode,
36
36
  PlaybookEdge,
37
+ PlaybookEdgeCondition,
37
38
  PlaybookEnsures,
38
39
  PlaybookErrorHandler,
39
40
  PlaybookInput,
@@ -336,6 +337,19 @@ function parseAgenticNode(
336
337
 
337
338
  const context_files = parseStringArray(raw.context_files, `nodes[${index}].context_files`);
338
339
 
340
+ // T11759 (M4): per-stage LLM pin. Each is an optional non-empty string — the
341
+ // dispatcher resolves them through the E9 chokepoint at dispatch time. Absent
342
+ // fields leave resolution unchanged (additive).
343
+ const profile = parseOptionalNonEmptyString(raw.profile, `nodes[${index}].profile`);
344
+ const model = parseOptionalNonEmptyString(raw.model, `nodes[${index}].model`);
345
+ const provider = parseOptionalNonEmptyString(raw.provider, `nodes[${index}].provider`);
346
+
347
+ // T1947 (M4): per-step skill/tool allowlists. Each is an optional string[] —
348
+ // enforced at spawn time by composeSpawnPayload as an INTERSECTION against the
349
+ // Tier-0/1/2 baseline (restrict-only, never expand). Absent → no restriction.
350
+ const allowed_skills = parseStringArray(raw.allowed_skills, `nodes[${index}].allowed_skills`);
351
+ const allowed_tools = parseStringArray(raw.allowed_tools, `nodes[${index}].allowed_tools`);
352
+
339
353
  return {
340
354
  ...base,
341
355
  type: 'agentic',
@@ -344,6 +358,11 @@ function parseAgenticNode(
344
358
  role,
345
359
  inputs,
346
360
  ...(context_files !== undefined ? { context_files } : {}),
361
+ ...(profile !== undefined ? { profile } : {}),
362
+ ...(model !== undefined ? { model } : {}),
363
+ ...(provider !== undefined ? { provider } : {}),
364
+ ...(allowed_skills !== undefined ? { allowed_skills } : {}),
365
+ ...(allowed_tools !== undefined ? { allowed_tools } : {}),
347
366
  };
348
367
  }
349
368
 
@@ -499,7 +518,114 @@ function parseEdge(raw: unknown, index: number, ids: ReadonlySet<string>): Playb
499
518
  contract = { requires, ensures };
500
519
  }
501
520
 
502
- return { from: raw.from, to: raw.to, contract };
521
+ const when = parseEdgeCondition(raw.when, `edges[${index}].when`);
522
+
523
+ const edge: PlaybookEdge = { from: raw.from, to: raw.to, contract };
524
+ if (when !== undefined) edge.when = when;
525
+ return edge;
526
+ }
527
+
528
+ /**
529
+ * Recognised comparison operators on a {@link PlaybookEdgeCondition}. Exactly
530
+ * one MUST be present so a guarded edge is unambiguous (T11806).
531
+ */
532
+ const EDGE_CONDITION_OPERATORS = ['equals', 'notEquals', 'exists', 'in', 'truthy'] as const;
533
+
534
+ /**
535
+ * Parse the optional `when` branch guard on an edge into a validated
536
+ * {@link PlaybookEdgeCondition}. Returns `undefined` for an absent guard
537
+ * (the edge is unconditional).
538
+ *
539
+ * Validation:
540
+ * - `field` MUST be a non-empty string.
541
+ * - Exactly ONE comparison operator (`equals` | `notEquals` | `exists` | `in`
542
+ * | `truthy`) MUST be supplied — zero is ambiguous, more than one is
543
+ * contradictory, both rejected at parse time so the runtime never has to
544
+ * disambiguate.
545
+ * - `exists` / `truthy` MUST be boolean; `in` MUST be a non-empty array.
546
+ *
547
+ * @param raw - Raw YAML value for `edge.when`.
548
+ * @param field - Field path for diagnostics (e.g. `"edges[2].when"`).
549
+ * @returns The validated condition, or `undefined` when absent.
550
+ * @throws {PlaybookParseError} On any structural violation.
551
+ *
552
+ * @task T11806 — cantbook runtime branching
553
+ */
554
+ function parseEdgeCondition(raw: unknown, field: string): PlaybookEdgeCondition | undefined {
555
+ if (raw === undefined) return undefined;
556
+ if (!isRecord(raw)) {
557
+ throw new PlaybookParseError(`${field} must be an object`, field, raw);
558
+ }
559
+ if (typeof raw.field !== 'string' || raw.field.length === 0) {
560
+ throw new PlaybookParseError(
561
+ `${field}.field must be a non-empty string`,
562
+ `${field}.field`,
563
+ raw.field,
564
+ );
565
+ }
566
+
567
+ const present = EDGE_CONDITION_OPERATORS.filter((op) => Object.hasOwn(raw, op));
568
+ if (present.length === 0) {
569
+ throw new PlaybookParseError(
570
+ `${field} must declare exactly one of ${EDGE_CONDITION_OPERATORS.join(' | ')}`,
571
+ field,
572
+ raw,
573
+ );
574
+ }
575
+ if (present.length > 1) {
576
+ throw new PlaybookParseError(
577
+ `${field} declares multiple operators (${present.join(', ')}); exactly one is allowed`,
578
+ field,
579
+ raw,
580
+ );
581
+ }
582
+
583
+ const condition: PlaybookEdgeCondition = { field: raw.field };
584
+ const [op] = present;
585
+ switch (op) {
586
+ case 'equals':
587
+ condition.equals = raw.equals;
588
+ break;
589
+ case 'notEquals':
590
+ condition.notEquals = raw.notEquals;
591
+ break;
592
+ case 'in':
593
+ if (!Array.isArray(raw.in) || raw.in.length === 0) {
594
+ throw new PlaybookParseError(
595
+ `${field}.in must be a non-empty array`,
596
+ `${field}.in`,
597
+ raw.in,
598
+ );
599
+ }
600
+ condition.in = [...raw.in];
601
+ break;
602
+ case 'exists':
603
+ if (typeof raw.exists !== 'boolean') {
604
+ throw new PlaybookParseError(
605
+ `${field}.exists must be a boolean`,
606
+ `${field}.exists`,
607
+ raw.exists,
608
+ );
609
+ }
610
+ condition.exists = raw.exists;
611
+ break;
612
+ case 'truthy':
613
+ if (typeof raw.truthy !== 'boolean') {
614
+ throw new PlaybookParseError(
615
+ `${field}.truthy must be a boolean`,
616
+ `${field}.truthy`,
617
+ raw.truthy,
618
+ );
619
+ }
620
+ condition.truthy = raw.truthy;
621
+ break;
622
+ default: {
623
+ // Exhaustiveness guard — EDGE_CONDITION_OPERATORS is the SSoT.
624
+ const exhaustive: never = op;
625
+ throw new PlaybookParseError(`${field} unknown operator ${String(exhaustive)}`, field, raw);
626
+ }
627
+ }
628
+ return condition;
503
629
  }
504
630
 
505
631
  function parseInputs(raw: unknown): PlaybookInput[] | undefined {
@@ -660,6 +786,23 @@ function parseStringArray(raw: unknown, field: string): string[] | undefined {
660
786
  });
661
787
  }
662
788
 
789
+ /**
790
+ * Parse an optional scalar string field that, when present, MUST be a non-empty
791
+ * string (T11759 — the per-stage LLM pins `profile` / `model` / `provider`).
792
+ *
793
+ * @param raw - Raw YAML value.
794
+ * @param field - Field path for diagnostics.
795
+ * @returns The trimmed-as-authored string, or `undefined` when absent.
796
+ * @throws {PlaybookParseError} When present but not a non-empty string.
797
+ */
798
+ function parseOptionalNonEmptyString(raw: unknown, field: string): string | undefined {
799
+ if (raw === undefined) return undefined;
800
+ if (typeof raw !== 'string' || raw.length === 0) {
801
+ throw new PlaybookParseError(`${field} must be a non-empty string`, field, raw);
802
+ }
803
+ return raw;
804
+ }
805
+
663
806
  /**
664
807
  * Detect cycles across the combined edge-set (explicit `edges[]` plus
665
808
  * `depends[]` back-references). Uses 3-color DFS.