@cleocode/playbooks 2026.4.128 → 2026.4.129

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/runtime.ts CHANGED
@@ -26,8 +26,13 @@
26
26
  * written with the HMAC-signed resume token, and the returned
27
27
  * {@link ExecutePlaybookResult.approvalToken} is what the human reviewer
28
28
  * must present via `resumePlaybook` to continue.
29
+ * 5. Contract enforcement (T1261 PSYCHE E4) — requires/ensures DSL validated
30
+ * at every node boundary. Violations are appended to
31
+ * `.cleo/audit/contract-violations.jsonl` and trigger the `contract_violation`
32
+ * error_handler (inject_hint | hitl_escalate | abort).
29
33
  *
30
34
  * @task T930 — Playbook Runtime State Machine
35
+ * @task T1261 PSYCHE E4 — contract enforcement + context boundary
31
36
  */
32
37
 
33
38
  import type { DatabaseSync } from 'node:sqlite';
@@ -36,6 +41,7 @@ import type {
36
41
  PlaybookApprovalNode,
37
42
  PlaybookDefinition,
38
43
  PlaybookDeterministicNode,
44
+ PlaybookEdge,
39
45
  PlaybookNode,
40
46
  PlaybookRun,
41
47
  PlaybookRunStatus,
@@ -163,13 +169,14 @@ export interface ExecutePlaybookOptions {
163
169
  sessionId?: string;
164
170
  /** Injectable clock for deterministic tests (defaults to `() => new Date()`). */
165
171
  now?: () => Date;
172
+ /**
173
+ * Project root for contract-violation audit writes (T1261 PSYCHE E4).
174
+ * When absent, contract violations are still enforced but not appended to
175
+ * `.cleo/audit/contract-violations.jsonl`.
176
+ */
177
+ projectRoot?: string;
166
178
  }
167
179
 
168
- /**
169
- * Options accepted by {@link resumePlaybook}. The runtime validates that the
170
- * supplied approval token resolves to an `approved` {@link PlaybookApproval}
171
- * row before continuing execution.
172
- */
173
180
  export interface ResumePlaybookOptions {
174
181
  db: DatabaseSync;
175
182
  playbook: PlaybookDefinition;
@@ -180,8 +187,13 @@ export interface ResumePlaybookOptions {
180
187
  approvalSecret?: string;
181
188
  maxIterationsDefault?: number;
182
189
  now?: () => Date;
190
+ /**
191
+ * Project root for contract-violation audit writes (T1261 PSYCHE E4).
192
+ */
193
+ projectRoot?: string;
183
194
  }
184
195
 
196
+ /**
185
197
  /**
186
198
  * Terminal status values reported by the runtime.
187
199
  */
@@ -494,6 +506,96 @@ function executeApprovalNode(
494
506
  // Main execution loop
495
507
  // ---------------------------------------------------------------------------
496
508
 
509
+ // ---------------------------------------------------------------------------
510
+ // Contract enforcement helpers (T1261 PSYCHE E4)
511
+ // ---------------------------------------------------------------------------
512
+
513
+ /**
514
+ * Check that all required fields from a predecessor node are present in the
515
+ * current execution context.
516
+ *
517
+ * @param fields - List of keys that must be in `context`.
518
+ * @param from - Optional predecessor node id for error message context.
519
+ * @param context - Current accumulated run context.
520
+ * @returns `null` if all fields are present; a human-readable violation
521
+ * description otherwise.
522
+ */
523
+ function checkRequires(
524
+ fields: readonly string[],
525
+ from: string | undefined,
526
+ context: Record<string, unknown>,
527
+ ): string | null {
528
+ for (const key of fields) {
529
+ if (!(key in context)) {
530
+ const source = from !== undefined ? ` (from node '${from}')` : '';
531
+ return `requires.fields['${key}']${source} not present in context`;
532
+ }
533
+ }
534
+ return null;
535
+ }
536
+
537
+ /**
538
+ * Resolve the first outgoing edge from `fromId` to `toId` in the playbook edge
539
+ * list. Returns `undefined` when no explicit edge exists.
540
+ */
541
+ function resolveEdge(
542
+ fromId: string,
543
+ toId: string,
544
+ edges: readonly PlaybookEdge[],
545
+ ): PlaybookEdge | undefined {
546
+ return edges.find((e) => e.from === fromId && e.to === toId);
547
+ }
548
+
549
+ /**
550
+ * Best-effort contract violation audit write. Non-fatal: errors are swallowed
551
+ * so a broken filesystem never blocks playbook execution.
552
+ *
553
+ * Writes to `<projectRoot>/.cleo/audit/contract-violations.jsonl` following
554
+ * the ADR-039 pattern.
555
+ */
556
+ function auditContractViolation(
557
+ projectRoot: string | undefined,
558
+ runId: string,
559
+ nodeId: string,
560
+ field: 'requires' | 'ensures',
561
+ key: string,
562
+ playbookName: string,
563
+ ): void {
564
+ if (!projectRoot) return;
565
+ try {
566
+ // Lazy-import to avoid a hard dep on @cleocode/core from @cleocode/playbooks.
567
+ // The audit is best-effort; we swallow any dynamic import failure.
568
+ void import('@cleocode/core').then(({ appendContractViolation }) => {
569
+ appendContractViolation(projectRoot, {
570
+ runId,
571
+ nodeId,
572
+ field,
573
+ key,
574
+ message: `contract_violation: ${field}['${key}'] check failed on node '${nodeId}'`,
575
+ playbookName,
576
+ });
577
+ });
578
+ } catch {
579
+ // non-fatal
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Map a `contract_violation` trigger to the registered error handler action.
585
+ *
586
+ * @returns `'inject_hint'` | `'hitl_escalate'` | `'abort'` based on the
587
+ * first matching handler, or `null` when no handler is registered.
588
+ */
589
+ function handleContractErrorHandler(
590
+ playbook: PlaybookDefinition,
591
+ trigger: 'contract_violation',
592
+ _message: string,
593
+ ): 'inject_hint' | 'hitl_escalate' | 'abort' | null {
594
+ if (!playbook.error_handlers) return null;
595
+ const handler = playbook.error_handlers.find((h) => h.on === trigger);
596
+ return handler?.action ?? null;
597
+ }
598
+
497
599
  /**
498
600
  * Determine the effective iteration cap for a node. Falls back to the
499
601
  * runtime default (3) when `on_failure.max_iterations` is unset. The parser
@@ -532,9 +634,12 @@ async function runFromNode(args: {
532
634
  approvalSecret: string;
533
635
  maxIterationsDefault: number;
534
636
  now: () => Date;
637
+ /** Project root for contract-violations.jsonl audit writes (T1261 E4). */
638
+ projectRoot?: string;
535
639
  }): Promise<ExecutePlaybookResult> {
536
640
  const {
537
641
  db,
642
+ playbook,
538
643
  run,
539
644
  startNodeId,
540
645
  nodeIndex,
@@ -567,6 +672,39 @@ async function runFromNode(args: {
567
672
  iterationCounts: { ...iterationCounts },
568
673
  });
569
674
 
675
+ // Contract enforcement (T1261 E4): validate node.requires BEFORE dispatch.
676
+ // On violation, trigger contract_violation error handler or fail the node.
677
+ if (node.requires?.fields) {
678
+ const violation = checkRequires(node.requires.fields, node.requires.from, context);
679
+ if (violation !== null) {
680
+ const contractFailure = `contract_violation: ${violation}`;
681
+ auditContractViolation(
682
+ args.projectRoot,
683
+ run.runId,
684
+ node.id,
685
+ 'requires',
686
+ violation,
687
+ playbook.name,
688
+ );
689
+ const handled = handleContractErrorHandler(playbook, 'contract_violation', contractFailure);
690
+ if (handled === 'abort') {
691
+ failedNodeId = node.id;
692
+ lastError = contractFailure;
693
+ break;
694
+ }
695
+ if (handled !== null) {
696
+ context['__lastError'] = contractFailure;
697
+ context['__lastFailedNode'] = node.id;
698
+ context['__contractViolation'] = violation;
699
+ if (handled === 'hitl_escalate') {
700
+ exceededNodeId = node.id;
701
+ break;
702
+ }
703
+ // inject_hint: continue to dispatch with the hint in context
704
+ }
705
+ }
706
+ }
707
+
570
708
  let outcome: NodeOutcome;
571
709
  if (node.type === 'agentic') {
572
710
  outcome = await executeAgenticNode(node, run.runId, context, attempt, dispatcher);
@@ -594,7 +732,56 @@ async function runFromNode(args: {
594
732
  // Merge outputs into context and persist.
595
733
  Object.assign(context, outcome.output);
596
734
  updatePlaybookRun(db, run.runId, { bindings: { ...context } });
597
- currentId = resolveNextNodeId(node.id, edgeIndex);
735
+
736
+ // Contract enforcement (T1261 E4): validate node.ensures AFTER merge.
737
+ if (node.ensures?.outputFiles) {
738
+ for (const key of node.ensures.outputFiles) {
739
+ if (!(key in context)) {
740
+ const violation = `ensures.outputFiles[${key}] not present in context after ${node.id}`;
741
+ auditContractViolation(
742
+ args.projectRoot,
743
+ run.runId,
744
+ node.id,
745
+ 'ensures',
746
+ key,
747
+ playbook.name,
748
+ );
749
+ handleContractErrorHandler(playbook, 'contract_violation', violation);
750
+ context['__ensuresViolation'] = violation;
751
+ }
752
+ }
753
+ }
754
+
755
+ // Validate outgoing edge contracts (edge.contract.requires on the FROM side).
756
+ const nextId = resolveNextNodeId(node.id, edgeIndex);
757
+ if (nextId !== null) {
758
+ const edge = resolveEdge(node.id, nextId, playbook.edges);
759
+ if (edge?.contract?.requires) {
760
+ for (const key of edge.contract.requires) {
761
+ if (!(key in context)) {
762
+ const violation = `edge.contract.requires[${key}] missing when crossing ${node.id} → ${nextId}`;
763
+ auditContractViolation(
764
+ args.projectRoot,
765
+ run.runId,
766
+ node.id,
767
+ 'requires',
768
+ key,
769
+ playbook.name,
770
+ );
771
+ const handled = handleContractErrorHandler(playbook, 'contract_violation', violation);
772
+ if (handled === 'abort') {
773
+ failedNodeId = node.id;
774
+ lastError = violation;
775
+ break;
776
+ }
777
+ context['__contractViolation'] = violation;
778
+ }
779
+ }
780
+ if (failedNodeId !== undefined) break;
781
+ }
782
+ }
783
+
784
+ currentId = nextId;
598
785
  continue;
599
786
  }
600
787
 
@@ -777,6 +964,7 @@ export async function executePlaybook(
777
964
  approvalSecret,
778
965
  maxIterationsDefault,
779
966
  now,
967
+ projectRoot: options.projectRoot,
780
968
  };
781
969
  return runFromNode(runArgs);
782
970
  }
@@ -907,6 +1095,7 @@ export async function resumePlaybook(
907
1095
  approvalSecret,
908
1096
  maxIterationsDefault,
909
1097
  now,
1098
+ projectRoot: options.projectRoot,
910
1099
  };
911
1100
  return runFromNode(runArgs);
912
1101
  }