@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/dist/index.d.ts +1 -1
- package/dist/index.js +9 -2
- package/dist/parser.js +114 -1
- package/dist/runtime.d.ts +92 -34
- package/dist/runtime.js +257 -149
- package/package.json +3 -3
- package/src/__tests__/fixtures/conditional-branch.cantbook +50 -0
- package/src/__tests__/fixtures/pi-skill-only.cantbook +26 -0
- package/src/__tests__/guarded-deterministic-dispatch.test.ts +225 -0
- package/src/__tests__/inputs-bindings.test.ts +250 -0
- package/src/__tests__/node-allowlist.test.ts +186 -0
- package/src/__tests__/node-profile.test.ts +142 -0
- package/src/__tests__/parser.test.ts +191 -0
- package/src/__tests__/pi-cantbook-e2e.test.ts +294 -0
- package/src/__tests__/rcasd-one-envelope.test.ts +239 -0
- package/src/__tests__/runtime.test.ts +502 -4
- package/src/index.ts +8 -3
- package/src/parser.ts +144 -1
- package/src/runtime.ts +363 -198
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
|
-
|
|
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.
|