@cleocode/playbooks 2026.4.128 → 2026.4.130

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,16 +26,24 @@
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
 
38
+ import { appendFileSync, mkdirSync } from 'node:fs';
39
+ import { dirname, join } from 'node:path';
33
40
  import type { DatabaseSync } from 'node:sqlite';
34
41
  import type {
35
42
  PlaybookAgenticNode,
36
43
  PlaybookApprovalNode,
37
44
  PlaybookDefinition,
38
45
  PlaybookDeterministicNode,
46
+ PlaybookEdge,
39
47
  PlaybookNode,
40
48
  PlaybookRun,
41
49
  PlaybookRunStatus,
@@ -163,13 +171,14 @@ export interface ExecutePlaybookOptions {
163
171
  sessionId?: string;
164
172
  /** Injectable clock for deterministic tests (defaults to `() => new Date()`). */
165
173
  now?: () => Date;
174
+ /**
175
+ * Project root for contract-violation audit writes (T1261 PSYCHE E4).
176
+ * When absent, contract violations are still enforced but not appended to
177
+ * `.cleo/audit/contract-violations.jsonl`.
178
+ */
179
+ projectRoot?: string;
166
180
  }
167
181
 
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
182
  export interface ResumePlaybookOptions {
174
183
  db: DatabaseSync;
175
184
  playbook: PlaybookDefinition;
@@ -180,8 +189,13 @@ export interface ResumePlaybookOptions {
180
189
  approvalSecret?: string;
181
190
  maxIterationsDefault?: number;
182
191
  now?: () => Date;
192
+ /**
193
+ * Project root for contract-violation audit writes (T1261 PSYCHE E4).
194
+ */
195
+ projectRoot?: string;
183
196
  }
184
197
 
198
+ /**
185
199
  /**
186
200
  * Terminal status values reported by the runtime.
187
201
  */
@@ -494,6 +508,99 @@ function executeApprovalNode(
494
508
  // Main execution loop
495
509
  // ---------------------------------------------------------------------------
496
510
 
511
+ // ---------------------------------------------------------------------------
512
+ // Contract enforcement helpers (T1261 PSYCHE E4)
513
+ // ---------------------------------------------------------------------------
514
+
515
+ /**
516
+ * Check that all required fields from a predecessor node are present in the
517
+ * current execution context.
518
+ *
519
+ * @param fields - List of keys that must be in `context`.
520
+ * @param from - Optional predecessor node id for error message context.
521
+ * @param context - Current accumulated run context.
522
+ * @returns `null` if all fields are present; a human-readable violation
523
+ * description otherwise.
524
+ */
525
+ function checkRequires(
526
+ fields: readonly string[],
527
+ from: string | undefined,
528
+ context: Record<string, unknown>,
529
+ ): string | null {
530
+ for (const key of fields) {
531
+ if (!(key in context)) {
532
+ const source = from !== undefined ? ` (from node '${from}')` : '';
533
+ return `requires.fields['${key}']${source} not present in context`;
534
+ }
535
+ }
536
+ return null;
537
+ }
538
+
539
+ /**
540
+ * Resolve the first outgoing edge from `fromId` to `toId` in the playbook edge
541
+ * list. Returns `undefined` when no explicit edge exists.
542
+ */
543
+ function resolveEdge(
544
+ fromId: string,
545
+ toId: string,
546
+ edges: readonly PlaybookEdge[],
547
+ ): PlaybookEdge | undefined {
548
+ return edges.find((e) => e.from === fromId && e.to === toId);
549
+ }
550
+
551
+ /**
552
+ * Best-effort contract violation audit write. Non-fatal: errors are swallowed
553
+ * so a broken filesystem never blocks playbook execution.
554
+ *
555
+ * Writes to `<projectRoot>/.cleo/audit/contract-violations.jsonl` following
556
+ * the ADR-039 pattern.
557
+ */
558
+ function auditContractViolation(
559
+ projectRoot: string | undefined,
560
+ runId: string,
561
+ nodeId: string,
562
+ field: 'requires' | 'ensures',
563
+ key: string,
564
+ playbookName: string,
565
+ ): void {
566
+ if (!projectRoot) return;
567
+ try {
568
+ // Write directly via node:fs to avoid importing @cleocode/core from
569
+ // @cleocode/playbooks (avoids circular TS project reference issues).
570
+ // Follows the same ADR-039 append-only NDJSON pattern as audit.ts.
571
+ const filePath = join(projectRoot, '.cleo', 'audit', 'contract-violations.jsonl');
572
+ mkdirSync(dirname(filePath), { recursive: true });
573
+ const entry = JSON.stringify({
574
+ timestamp: new Date().toISOString(),
575
+ runId,
576
+ nodeId,
577
+ field,
578
+ key,
579
+ message: `contract_violation: ${field}['${key}'] check failed on node '${nodeId}'`,
580
+ playbookName,
581
+ });
582
+ appendFileSync(filePath, `${entry}\n`, { encoding: 'utf-8' });
583
+ } catch {
584
+ // non-fatal
585
+ }
586
+ }
587
+
588
+ /**
589
+ * Map a `contract_violation` trigger to the registered error handler action.
590
+ *
591
+ * @returns `'inject_hint'` | `'hitl_escalate'` | `'abort'` based on the
592
+ * first matching handler, or `null` when no handler is registered.
593
+ */
594
+ function handleContractErrorHandler(
595
+ playbook: PlaybookDefinition,
596
+ trigger: 'contract_violation',
597
+ _message: string,
598
+ ): 'inject_hint' | 'hitl_escalate' | 'abort' | null {
599
+ if (!playbook.error_handlers) return null;
600
+ const handler = playbook.error_handlers.find((h) => h.on === trigger);
601
+ return handler?.action ?? null;
602
+ }
603
+
497
604
  /**
498
605
  * Determine the effective iteration cap for a node. Falls back to the
499
606
  * runtime default (3) when `on_failure.max_iterations` is unset. The parser
@@ -532,9 +639,12 @@ async function runFromNode(args: {
532
639
  approvalSecret: string;
533
640
  maxIterationsDefault: number;
534
641
  now: () => Date;
642
+ /** Project root for contract-violations.jsonl audit writes (T1261 E4). */
643
+ projectRoot?: string;
535
644
  }): Promise<ExecutePlaybookResult> {
536
645
  const {
537
646
  db,
647
+ playbook,
538
648
  run,
539
649
  startNodeId,
540
650
  nodeIndex,
@@ -567,6 +677,39 @@ async function runFromNode(args: {
567
677
  iterationCounts: { ...iterationCounts },
568
678
  });
569
679
 
680
+ // Contract enforcement (T1261 E4): validate node.requires BEFORE dispatch.
681
+ // On violation, trigger contract_violation error handler or fail the node.
682
+ if (node.requires?.fields) {
683
+ const violation = checkRequires(node.requires.fields, node.requires.from, context);
684
+ if (violation !== null) {
685
+ const contractFailure = `contract_violation: ${violation}`;
686
+ auditContractViolation(
687
+ args.projectRoot,
688
+ run.runId,
689
+ node.id,
690
+ 'requires',
691
+ violation,
692
+ playbook.name,
693
+ );
694
+ const handled = handleContractErrorHandler(playbook, 'contract_violation', contractFailure);
695
+ if (handled === 'abort') {
696
+ failedNodeId = node.id;
697
+ lastError = contractFailure;
698
+ break;
699
+ }
700
+ if (handled !== null) {
701
+ context['__lastError'] = contractFailure;
702
+ context['__lastFailedNode'] = node.id;
703
+ context['__contractViolation'] = violation;
704
+ if (handled === 'hitl_escalate') {
705
+ exceededNodeId = node.id;
706
+ break;
707
+ }
708
+ // inject_hint: continue to dispatch with the hint in context
709
+ }
710
+ }
711
+ }
712
+
570
713
  let outcome: NodeOutcome;
571
714
  if (node.type === 'agentic') {
572
715
  outcome = await executeAgenticNode(node, run.runId, context, attempt, dispatcher);
@@ -594,7 +737,56 @@ async function runFromNode(args: {
594
737
  // Merge outputs into context and persist.
595
738
  Object.assign(context, outcome.output);
596
739
  updatePlaybookRun(db, run.runId, { bindings: { ...context } });
597
- currentId = resolveNextNodeId(node.id, edgeIndex);
740
+
741
+ // Contract enforcement (T1261 E4): validate node.ensures AFTER merge.
742
+ if (node.ensures?.outputFiles) {
743
+ for (const key of node.ensures.outputFiles) {
744
+ if (!(key in context)) {
745
+ const violation = `ensures.outputFiles[${key}] not present in context after ${node.id}`;
746
+ auditContractViolation(
747
+ args.projectRoot,
748
+ run.runId,
749
+ node.id,
750
+ 'ensures',
751
+ key,
752
+ playbook.name,
753
+ );
754
+ handleContractErrorHandler(playbook, 'contract_violation', violation);
755
+ context['__ensuresViolation'] = violation;
756
+ }
757
+ }
758
+ }
759
+
760
+ // Validate outgoing edge contracts (edge.contract.requires on the FROM side).
761
+ const nextId = resolveNextNodeId(node.id, edgeIndex);
762
+ if (nextId !== null) {
763
+ const edge = resolveEdge(node.id, nextId, playbook.edges);
764
+ if (edge?.contract?.requires) {
765
+ for (const key of edge.contract.requires) {
766
+ if (!(key in context)) {
767
+ const violation = `edge.contract.requires[${key}] missing when crossing ${node.id} → ${nextId}`;
768
+ auditContractViolation(
769
+ args.projectRoot,
770
+ run.runId,
771
+ node.id,
772
+ 'requires',
773
+ key,
774
+ playbook.name,
775
+ );
776
+ const handled = handleContractErrorHandler(playbook, 'contract_violation', violation);
777
+ if (handled === 'abort') {
778
+ failedNodeId = node.id;
779
+ lastError = violation;
780
+ break;
781
+ }
782
+ context['__contractViolation'] = violation;
783
+ }
784
+ }
785
+ if (failedNodeId !== undefined) break;
786
+ }
787
+ }
788
+
789
+ currentId = nextId;
598
790
  continue;
599
791
  }
600
792
 
@@ -777,6 +969,7 @@ export async function executePlaybook(
777
969
  approvalSecret,
778
970
  maxIterationsDefault,
779
971
  now,
972
+ projectRoot: options.projectRoot,
780
973
  };
781
974
  return runFromNode(runArgs);
782
975
  }
@@ -907,6 +1100,7 @@ export async function resumePlaybook(
907
1100
  approvalSecret,
908
1101
  maxIterationsDefault,
909
1102
  now,
1103
+ projectRoot: options.projectRoot,
910
1104
  };
911
1105
  return runFromNode(runArgs);
912
1106
  }