@sublang/playbook 9.0.0 → 11.0.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.
Files changed (71) hide show
  1. package/README.md +1 -1
  2. package/docs/cli.md +109 -21
  3. package/docs/configuration.md +89 -40
  4. package/docs/embedding.md +126 -12
  5. package/package.json +14 -3
  6. package/reference/sdlc/captain.md +14 -10
  7. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +33 -13
  8. package/reference/sdlc/captain.playbook/captain.fsm.js +80 -9
  9. package/reference/sdlc/captain.playbook/captain.fsm.ts +137 -18
  10. package/reference/sdlc/captain.playbook/captain.gears.md +10 -6
  11. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +5 -1
  12. package/reference/sdlc/captain.playbook/captain.playbook.js +140 -10
  13. package/reference/sdlc/captain.playbook/captain.playbook.ts +188 -16
  14. package/reference/sdlc/code.md +35 -16
  15. package/reference/sdlc/code.playbook/bin/interactive-session.js +228 -23
  16. package/reference/sdlc/code.playbook/bin/launch-config.js +611 -221
  17. package/reference/sdlc/code.playbook/bin/playbook.js +304 -178
  18. package/reference/sdlc/code.playbook/bin/replay-observer.js +221 -0
  19. package/reference/sdlc/code.playbook/bin/repository-effects.js +2930 -0
  20. package/reference/sdlc/code.playbook/bin/run.js +669 -215
  21. package/reference/sdlc/code.playbook/bin/session-store.js +4546 -502
  22. package/reference/sdlc/code.playbook/code.fsm.d.ts +7 -0
  23. package/reference/sdlc/code.playbook/code.fsm.js +74 -25
  24. package/reference/sdlc/code.playbook/code.fsm.ts +83 -29
  25. package/reference/sdlc/code.playbook/code.gears.md +0 -2
  26. package/reference/sdlc/code.playbook/code.playbook.d.ts +5 -2
  27. package/reference/sdlc/code.playbook/code.playbook.js +54 -2
  28. package/reference/sdlc/code.playbook/code.playbook.ts +75 -6
  29. package/reference/sdlc/code.playbook/code.registry.d.ts +10 -3
  30. package/reference/sdlc/code.playbook/code.registry.js +10 -3
  31. package/reference/sdlc/code.playbook/code.registry.ts +23 -5
  32. package/reference/sdlc/code.playbook/playbook-captain.d.ts +103 -8
  33. package/reference/sdlc/code.playbook/playbook-captain.js +1871 -75
  34. package/reference/sdlc/code.playbook/playbook-captain.ts +2801 -102
  35. package/reference/sdlc/code.playbook/playbook.config.template.yaml +14 -10
  36. package/reference/sdlc/code.playbook/session-store.d.ts +82 -0
  37. package/reference/sdlc/code.playbook/session-store.js +113 -0
  38. package/reference/sdlc/decide.md +24 -16
  39. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +7 -0
  40. package/reference/sdlc/decide.playbook/decide.fsm.js +80 -29
  41. package/reference/sdlc/decide.playbook/decide.fsm.ts +89 -31
  42. package/reference/sdlc/decide.playbook/decide.gears.md +0 -1
  43. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +13 -5
  44. package/reference/sdlc/decide.playbook/decide.playbook.js +1712 -91
  45. package/reference/sdlc/decide.playbook/decide.playbook.ts +2677 -136
  46. package/reference/sdlc/decide.playbook/decide.registry.d.ts +7 -3
  47. package/reference/sdlc/decide.playbook/decide.registry.js +10 -3
  48. package/reference/sdlc/decide.playbook/decide.registry.ts +20 -5
  49. package/reference/sdlc/review.md +36 -18
  50. package/reference/sdlc/review.playbook/review.fsm.d.ts +7 -0
  51. package/reference/sdlc/review.playbook/review.fsm.js +133 -12
  52. package/reference/sdlc/review.playbook/review.fsm.ts +140 -12
  53. package/reference/sdlc/review.playbook/review.playbook.d.ts +5 -2
  54. package/reference/sdlc/review.playbook/review.playbook.js +65 -2
  55. package/reference/sdlc/review.playbook/review.playbook.ts +83 -6
  56. package/reference/sdlc/review.playbook/review.registry.d.ts +10 -3
  57. package/reference/sdlc/review.playbook/review.registry.js +10 -3
  58. package/reference/sdlc/review.playbook/review.registry.ts +23 -5
  59. package/slc/gears2fsm.md +6 -5
  60. package/slc/link.md +544 -41
  61. package/src/accepted-outcome.d.ts +18 -0
  62. package/src/accepted-outcome.js +94 -0
  63. package/src/accepted-outcome.ts +140 -0
  64. package/src/runtime.d.ts +164 -3
  65. package/src/runtime.ts +213 -2
  66. package/src/xstate-playbook-runtime.d.ts +149 -10
  67. package/src/xstate-playbook-runtime.js +2569 -270
  68. package/src/xstate-playbook-runtime.ts +4133 -490
  69. package/src/xstate-runtime.d.ts +59 -1
  70. package/src/xstate-runtime.js +866 -7
  71. package/src/xstate-runtime.ts +1397 -7
@@ -31,9 +31,9 @@
31
31
  // deterministic entry mapping, the controller captain-call
32
32
  // strategy with its single corrective re-ask (CAPPLAY-18),
33
33
  // controller-port submission, and status formatting.
34
- // Compat: spec.compat = { artifactSchema: 2, runtimeAbi: 1 }
34
+ // Compat: spec.compat = { artifactSchema: 3, runtimeAbi: 1 }
35
35
  // (DR-022; checked at construction by the loading engine).
36
- import { createXStatePlaybookRuntime, defaultComposeCaptainPrompt, normalizeError, normalizeErrorCompact, parseJudgeJson, snapshotJsonValue, } from '../../../src/xstate-runtime.js';
36
+ import { createXStatePlaybookRuntime, defaultComposeCaptainPrompt, emptyPlaybookEffectLedger, normalizeError, normalizeErrorCompact, parseJudgeJson, snapshotJsonValue, } from '../../../src/xstate-runtime.js';
37
37
  import { captainMachine, } from './captain.fsm.js';
38
38
  function assertNonEmptyString(value, label) {
39
39
  if (typeof value !== 'string' || value.trim().length === 0) {
@@ -119,6 +119,18 @@ function validateParsedActingDecision(value) {
119
119
  }
120
120
  return { action: 'deliver' };
121
121
  }
122
+ if (value.action === 'resume') {
123
+ const allowed = new Set(['action', 'playbookId']);
124
+ for (const key of Object.keys(value)) {
125
+ if (!allowed.has(key)) {
126
+ throw new TypeError(`parse-resolved decision carries undeclared ${key}`);
127
+ }
128
+ }
129
+ return {
130
+ action: 'resume',
131
+ playbookId: assertNonEmptyString(value.playbookId, 'parse-resolved decision playbookId'),
132
+ };
133
+ }
122
134
  if (value.action !== 'start' && value.action !== 'switch') {
123
135
  throw new TypeError(`parse-resolved decision names unknown action ${String(value.action)}`);
124
136
  }
@@ -168,8 +180,8 @@ function classifyControllerTurn(text, _ports, _signal, _snapshotOrState, _bounda
168
180
  // the turn as a recoverable hub return with no action executed.
169
181
  // ---------------------------------------------------------------------------
170
182
  const RESTATED_REPLY_CONTRACT = [
171
- 'Reply again with exactly one JSON object `{ "action": …, … }` and no other text, selecting exactly one action from the closed set `respond` | `start` | `switch` | `dismiss` | `deliver` | `runtime`:',
172
- '`{ "action": "respond", "text": … }`, `{ "action": "start", "playbookId": …, "input": … }`, `{ "action": "switch", "playbookId": …, "input": … }`, `{ "action": "dismiss" }`, `{ "action": "deliver" }`, or `{ "action": "runtime", "actionId": … }`; every `input` is one nonempty complete standalone request.',
183
+ 'Reply again with exactly one JSON object `{ "action": …, … }` and no other text, selecting exactly one action from the closed set `respond` | `resume` | `start` | `switch` | `dismiss` | `deliver` | `runtime`:',
184
+ '`{ "action": "respond", "text": … }`, `{ "action": "resume", "playbookId": … }`, `{ "action": "start", "playbookId": …, "input": … }`, `{ "action": "switch", "playbookId": …, "input": … }`, `{ "action": "dismiss" }`, `{ "action": "deliver" }`, or `{ "action": "runtime", "actionId": … }`; every `input` is one nonempty complete standalone request.',
173
185
  ].join('\n');
174
186
  function correctiveDecisionPrompt(prompt, reason) {
175
187
  return [
@@ -221,6 +233,23 @@ function readDecisionReply(reply, options, selfPlaybookId, declaredActions) {
221
233
  return { reason: shape };
222
234
  return { selection: { action, text: parsed.text } };
223
235
  }
236
+ case 'resume': {
237
+ const shape = requireKeys(['playbookId']) ?? nonEmpty('playbookId');
238
+ if (shape !== undefined)
239
+ return { reason: shape };
240
+ const playbookId = parsed.playbookId;
241
+ if (playbookId === selfPlaybookId) {
242
+ return {
243
+ reason: `the ${action} target may never be this Captain playbook itself`,
244
+ };
245
+ }
246
+ if (!options.enabledPlaybooks.some((entry) => entry.id === playbookId)) {
247
+ return {
248
+ reason: `the ${action} target ${JSON.stringify(playbookId)} is not an enabled catalog id`,
249
+ };
250
+ }
251
+ return { selection: { action, playbookId } };
252
+ }
224
253
  case 'start':
225
254
  case 'switch': {
226
255
  const shape = requireKeys(['playbookId', 'input']) ??
@@ -276,6 +305,9 @@ function selectionFromParsedDecision(decision) {
276
305
  if (decision.action === 'deliver') {
277
306
  return { action: 'deliver' };
278
307
  }
308
+ if (decision.action === 'resume') {
309
+ return { action: 'resume', playbookId: decision.playbookId };
310
+ }
279
311
  // A parse-resolved `start` / `switch` is always the exact command remainder
280
312
  // supplied by the host (CAPTAIN-7 command table).
281
313
  return {
@@ -297,10 +329,71 @@ function decisionOutputOf(selection) {
297
329
  }
298
330
  // ---------------------------------------------------------------------------
299
331
  // Settlement validation: the returned settlement is the only evidence of
300
- // effects, and the machine retains only its status, facts, receipt
301
- // disposition, and leaf-state summary (CAPPLAY-10). A malformed settlement
302
- // is a host control-plane failure.
332
+ // effects, and the machine retains only its status, facts, bounded unresolved
333
+ // effects, receipt disposition, and leaf-state summary (CAPPLAY-10). A
334
+ // malformed settlement is a host control-plane failure.
303
335
  // ---------------------------------------------------------------------------
336
+ const SETTLEMENT_GIT_OID_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
337
+ const SETTLEMENT_UNRESOLVED_CLASSIFICATIONS = new Set([
338
+ 'one-descendant-commit',
339
+ 'multiple-commits',
340
+ 'rewritten-or-non-descendant',
341
+ 'worktree-only-change',
342
+ 'concurrent-or-foreign-change',
343
+ 'observation-ambiguous',
344
+ 'incomplete',
345
+ ]);
346
+ function validateSettlementUnresolvedEffects(value) {
347
+ const detached = snapshotJsonValue(value, 'controller settlement unresolvedEffects');
348
+ if (!Array.isArray(detached)) {
349
+ throw new TypeError('controller settlement unresolvedEffects must be an array');
350
+ }
351
+ for (const [index, raw] of detached.entries()) {
352
+ const path = `controller settlement unresolvedEffects[${index}]`;
353
+ if (!isRecord(raw)) {
354
+ throw new TypeError(`${path} must be an object`);
355
+ }
356
+ const allowed = new Set([
357
+ 'classification',
358
+ 'baselineHead',
359
+ 'afterHead',
360
+ 'commitOid',
361
+ ]);
362
+ const unknown = Object.keys(raw).find((key) => !allowed.has(key));
363
+ if (unknown !== undefined) {
364
+ throw new TypeError(`${path} carries undeclared ${unknown}`);
365
+ }
366
+ if (typeof raw.classification !== 'string' ||
367
+ !SETTLEMENT_UNRESOLVED_CLASSIFICATIONS.has(raw.classification)) {
368
+ throw new TypeError(`${path} classification is not supported`);
369
+ }
370
+ if (typeof raw.baselineHead !== 'string' ||
371
+ !SETTLEMENT_GIT_OID_PATTERN.test(raw.baselineHead)) {
372
+ throw new TypeError(`${path} baselineHead must be a Git OID`);
373
+ }
374
+ if ('afterHead' in raw &&
375
+ (typeof raw.afterHead !== 'string' ||
376
+ !SETTLEMENT_GIT_OID_PATTERN.test(raw.afterHead))) {
377
+ throw new TypeError(`${path} afterHead must be a Git OID`);
378
+ }
379
+ if (raw.classification !== 'observation-ambiguous' &&
380
+ raw.classification !== 'incomplete' &&
381
+ !('afterHead' in raw)) {
382
+ throw new TypeError(`${path} afterHead is required for ${raw.classification}`);
383
+ }
384
+ if (raw.classification === 'one-descendant-commit') {
385
+ if (typeof raw.commitOid !== 'string' ||
386
+ !SETTLEMENT_GIT_OID_PATTERN.test(raw.commitOid) ||
387
+ raw.commitOid !== raw.afterHead) {
388
+ throw new TypeError(`${path} commitOid must equal afterHead for one-descendant-commit`);
389
+ }
390
+ }
391
+ else if ('commitOid' in raw) {
392
+ throw new TypeError(`${path} commitOid is permitted only for one-descendant-commit`);
393
+ }
394
+ }
395
+ return detached;
396
+ }
304
397
  function validateSettlement(value) {
305
398
  if (!isRecord(value)) {
306
399
  throw new TypeError('controller settlement must be an object');
@@ -308,6 +401,7 @@ function validateSettlement(value) {
308
401
  const allowed = new Set([
309
402
  'status',
310
403
  'facts',
404
+ 'unresolvedEffects',
311
405
  'reason',
312
406
  'receipt',
313
407
  'leafStateSummary',
@@ -326,6 +420,10 @@ function validateSettlement(value) {
326
420
  value.facts.some((fact) => typeof fact !== 'string')) {
327
421
  throw new TypeError('controller settlement facts must be a string array');
328
422
  }
423
+ if (!Object.prototype.hasOwnProperty.call(value, 'unresolvedEffects')) {
424
+ throw new TypeError('controller settlement unresolvedEffects is required');
425
+ }
426
+ const unresolvedEffects = validateSettlementUnresolvedEffects(value.unresolvedEffects);
329
427
  if ('reason' in value && typeof value.reason !== 'string') {
330
428
  throw new TypeError('controller settlement reason must be a string');
331
429
  }
@@ -372,6 +470,7 @@ function validateSettlement(value) {
372
470
  return Object.freeze({
373
471
  status: value.status,
374
472
  facts: Object.freeze([...value.facts]),
473
+ unresolvedEffects,
375
474
  ...(typeof value.reason === 'string' ? { reason: value.reason } : {}),
376
475
  ...(receipt === undefined ? {} : { receipt }),
377
476
  ...(typeof value.leafStateSummary === 'string'
@@ -494,6 +593,7 @@ function statusesForState(state, context) {
494
593
  return [];
495
594
  }
496
595
  }
596
+ const UNFINISHED_FINAL_STATE_IDS = new Set();
497
597
  // Internal export surface for verification and tests. Not part of the
498
598
  // stable public API; the leading underscore signals "subject to change."
499
599
  export const _internal = {
@@ -509,6 +609,7 @@ export const _internal = {
509
609
  validateParsedActingDecision,
510
610
  statusesForState,
511
611
  normalizeError,
612
+ UNFINISHED_FINAL_STATE_IDS,
512
613
  };
513
614
  // The Captain-specific spec handed to the shared runtime factory
514
615
  // (slc/link.md §Output, DR-019). Generic machinery — actor wiring, boundary
@@ -520,7 +621,10 @@ const runtimeSpec = {
520
621
  // time — a literal, never the loading engine's RUNTIME_ABI self-report,
521
622
  // which would follow whatever engine loads the module and make the
522
623
  // factory's skew check compare that engine with itself.
523
- compat: { artifactSchema: 2, runtimeAbi: 1 },
624
+ compat: { artifactSchema: 3, runtimeAbi: 1 },
625
+ // DR-040: the roleless session Captain has no delegated-player outcome,
626
+ // but schema 3 still makes that absence explicit rather than inferred.
627
+ outcomeAuthority: { governedPlayerStates: {} },
524
628
  snapshotOptions: snapshotCaptainOptions,
525
629
  machineInput: (options) => ({ enabledPlaybooks: options.enabledPlaybooks }),
526
630
  classifyBossText: (text, ports, signal, snapshotOrState, boundary, options) => classifyControllerTurn(text, ports, signal, snapshotOrState, boundary, options),
@@ -544,7 +648,9 @@ const runtimeSpec = {
544
648
  'receiptReason',
545
649
  'receiptError',
546
650
  'leafStateSummary',
651
+ 'settlementUnresolvedEffects',
547
652
  ],
653
+ unfinishedFinalStateIds: UNFINISHED_FINAL_STATE_IDS,
548
654
  statusesForState,
549
655
  };
550
656
  // DR-022's compat check fails fast at factory construction. The Captain is
@@ -553,10 +659,34 @@ const runtimeSpec = {
553
659
  // uncaught ESM-load error that takes even `--help` down; constructing on
554
660
  // the first runtime request keeps the failure inside the caught
555
661
  // host-construction boundary that owes the Boss a setup diagnostic.
662
+ const rejectInternalCaptainRepositoryWork = async () => {
663
+ throw new Error('the roleless session Captain cannot perform repository-governed work');
664
+ };
665
+ const INTERNAL_CAPTAIN_HOST_CAPABILITIES = Object.freeze({
666
+ // The engine does not consult this member for an explicitly empty governed
667
+ // set. Keeping a fail-closed implementation satisfies the schema-3 factory
668
+ // boundary without granting the internal controller repository authority.
669
+ repository: Object.freeze({
670
+ runExclusive: rejectInternalCaptainRepositoryWork,
671
+ runDeferred: rejectInternalCaptainRepositoryWork,
672
+ }),
673
+ effectLedger: Object.freeze({
674
+ snapshot: emptyPlaybookEffectLedger,
675
+ writeAhead: async () => {
676
+ throw new Error('the roleless session Captain cannot write an effect ledger');
677
+ },
678
+ }),
679
+ });
680
+ function buildCaptainPlaybookRuntimeFactory() {
681
+ return createXStatePlaybookRuntime(captainMachine, runtimeSpec);
682
+ }
556
683
  let createCaptainPlaybookRuntime;
557
684
  export function createPlaybookRuntime(options) {
558
- createCaptainPlaybookRuntime ??= createXStatePlaybookRuntime(captainMachine, runtimeSpec);
559
- return createCaptainPlaybookRuntime(options);
685
+ createCaptainPlaybookRuntime ??= buildCaptainPlaybookRuntimeFactory();
686
+ return createCaptainPlaybookRuntime({
687
+ configuredOptions: options,
688
+ hostCapabilities: INTERNAL_CAPTAIN_HOST_CAPABILITIES,
689
+ });
560
690
  }
561
691
  const factory = createPlaybookRuntime;
562
692
  export default factory;
@@ -31,12 +31,13 @@
31
31
  // deterministic entry mapping, the controller captain-call
32
32
  // strategy with its single corrective re-ask (CAPPLAY-18),
33
33
  // controller-port submission, and status formatting.
34
- // Compat: spec.compat = { artifactSchema: 2, runtimeAbi: 1 }
34
+ // Compat: spec.compat = { artifactSchema: 3, runtimeAbi: 1 }
35
35
  // (DR-022; checked at construction by the loading engine).
36
36
 
37
37
  import {
38
38
  createXStatePlaybookRuntime,
39
39
  defaultComposeCaptainPrompt,
40
+ emptyPlaybookEffectLedger,
40
41
  normalizeError,
41
42
  normalizeErrorCompact,
42
43
  parseJudgeJson,
@@ -45,7 +46,8 @@ import {
45
46
  type PlaybookCaptainInput,
46
47
  type ScheduledStatus,
47
48
  type XStateCaptainStrategyRun,
48
- type XStatePlaybookRuntimeSpec,
49
+ type XStatePlaybookRuntimeSpecV3,
50
+ type XStateRepositoryCapability,
49
51
  } from '../../../src/xstate-runtime.js';
50
52
  import {
51
53
  captainMachine,
@@ -56,6 +58,7 @@ import {
56
58
  type ParsedActingDecision,
57
59
  type SettlementEvidence,
58
60
  type SettlementReceiptEvidence,
61
+ type SettlementUnresolvedEffectEvidence,
59
62
  } from './captain.fsm.js';
60
63
  import type {
61
64
  CaptainCallOptions,
@@ -123,9 +126,10 @@ export type {
123
126
  /** One nonempty complete standalone request selected for `start` or `switch`. */
124
127
  export type CaptainControllerInput = string;
125
128
 
126
- /** One validated controller selection submitted through the port (DR-029). */
129
+ /** One validated controller selection submitted through the port (DR-029, DR-038). */
127
130
  export type CaptainControllerSelection =
128
131
  | { readonly action: 'respond'; readonly text: string }
132
+ | { readonly action: 'resume'; readonly playbookId: string }
129
133
  | {
130
134
  readonly action: 'start' | 'switch';
131
135
  readonly playbookId: string;
@@ -294,6 +298,21 @@ function validateParsedActingDecision(value: unknown): ParsedActingDecision {
294
298
  }
295
299
  return { action: 'deliver' };
296
300
  }
301
+ if (value.action === 'resume') {
302
+ const allowed = new Set(['action', 'playbookId']);
303
+ for (const key of Object.keys(value)) {
304
+ if (!allowed.has(key)) {
305
+ throw new TypeError(`parse-resolved decision carries undeclared ${key}`);
306
+ }
307
+ }
308
+ return {
309
+ action: 'resume',
310
+ playbookId: assertNonEmptyString(
311
+ value.playbookId,
312
+ 'parse-resolved decision playbookId',
313
+ ),
314
+ };
315
+ }
297
316
  if (value.action !== 'start' && value.action !== 'switch') {
298
317
  throw new TypeError(
299
318
  `parse-resolved decision names unknown action ${String(value.action)}`,
@@ -363,8 +382,8 @@ function classifyControllerTurn(
363
382
  // ---------------------------------------------------------------------------
364
383
 
365
384
  const RESTATED_REPLY_CONTRACT = [
366
- 'Reply again with exactly one JSON object `{ "action": …, … }` and no other text, selecting exactly one action from the closed set `respond` | `start` | `switch` | `dismiss` | `deliver` | `runtime`:',
367
- '`{ "action": "respond", "text": … }`, `{ "action": "start", "playbookId": …, "input": … }`, `{ "action": "switch", "playbookId": …, "input": … }`, `{ "action": "dismiss" }`, `{ "action": "deliver" }`, or `{ "action": "runtime", "actionId": … }`; every `input` is one nonempty complete standalone request.',
385
+ 'Reply again with exactly one JSON object `{ "action": …, … }` and no other text, selecting exactly one action from the closed set `respond` | `resume` | `start` | `switch` | `dismiss` | `deliver` | `runtime`:',
386
+ '`{ "action": "respond", "text": … }`, `{ "action": "resume", "playbookId": … }`, `{ "action": "start", "playbookId": …, "input": … }`, `{ "action": "switch", "playbookId": …, "input": … }`, `{ "action": "dismiss" }`, `{ "action": "deliver" }`, or `{ "action": "runtime", "actionId": … }`; every `input` is one nonempty complete standalone request.',
368
387
  ].join('\n');
369
388
 
370
389
  function correctiveDecisionPrompt(prompt: string, reason: string): string {
@@ -431,6 +450,24 @@ function readDecisionReply(
431
450
  if (shape !== undefined) return { reason: shape };
432
451
  return { selection: { action, text: parsed.text as string } };
433
452
  }
453
+ case 'resume': {
454
+ const shape = requireKeys(['playbookId']) ?? nonEmpty('playbookId');
455
+ if (shape !== undefined) return { reason: shape };
456
+ const playbookId = parsed.playbookId as string;
457
+ if (playbookId === selfPlaybookId) {
458
+ return {
459
+ reason: `the ${action} target may never be this Captain playbook itself`,
460
+ };
461
+ }
462
+ if (
463
+ !options.enabledPlaybooks.some((entry) => entry.id === playbookId)
464
+ ) {
465
+ return {
466
+ reason: `the ${action} target ${JSON.stringify(playbookId)} is not an enabled catalog id`,
467
+ };
468
+ }
469
+ return { selection: { action, playbookId } };
470
+ }
434
471
  case 'start':
435
472
  case 'switch': {
436
473
  const shape =
@@ -488,6 +525,9 @@ function selectionFromParsedDecision(
488
525
  if (decision.action === 'deliver') {
489
526
  return { action: 'deliver' };
490
527
  }
528
+ if (decision.action === 'resume') {
529
+ return { action: 'resume', playbookId: decision.playbookId };
530
+ }
491
531
  // A parse-resolved `start` / `switch` is always the exact command remainder
492
532
  // supplied by the host (CAPTAIN-7 command table).
493
533
  return {
@@ -513,11 +553,100 @@ function decisionOutputOf(
513
553
 
514
554
  // ---------------------------------------------------------------------------
515
555
  // Settlement validation: the returned settlement is the only evidence of
516
- // effects, and the machine retains only its status, facts, receipt
517
- // disposition, and leaf-state summary (CAPPLAY-10). A malformed settlement
518
- // is a host control-plane failure.
556
+ // effects, and the machine retains only its status, facts, bounded unresolved
557
+ // effects, receipt disposition, and leaf-state summary (CAPPLAY-10). A
558
+ // malformed settlement is a host control-plane failure.
519
559
  // ---------------------------------------------------------------------------
520
560
 
561
+ const SETTLEMENT_GIT_OID_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
562
+ const SETTLEMENT_UNRESOLVED_CLASSIFICATIONS = new Set<
563
+ SettlementUnresolvedEffectEvidence['classification']
564
+ >([
565
+ 'one-descendant-commit',
566
+ 'multiple-commits',
567
+ 'rewritten-or-non-descendant',
568
+ 'worktree-only-change',
569
+ 'concurrent-or-foreign-change',
570
+ 'observation-ambiguous',
571
+ 'incomplete',
572
+ ]);
573
+
574
+ function validateSettlementUnresolvedEffects(
575
+ value: unknown,
576
+ ): readonly SettlementUnresolvedEffectEvidence[] {
577
+ const detached = snapshotJsonValue(
578
+ value,
579
+ 'controller settlement unresolvedEffects',
580
+ );
581
+ if (!Array.isArray(detached)) {
582
+ throw new TypeError(
583
+ 'controller settlement unresolvedEffects must be an array',
584
+ );
585
+ }
586
+ for (const [index, raw] of detached.entries()) {
587
+ const path = `controller settlement unresolvedEffects[${index}]`;
588
+ if (!isRecord(raw)) {
589
+ throw new TypeError(`${path} must be an object`);
590
+ }
591
+ const allowed = new Set([
592
+ 'classification',
593
+ 'baselineHead',
594
+ 'afterHead',
595
+ 'commitOid',
596
+ ]);
597
+ const unknown = Object.keys(raw).find((key) => !allowed.has(key));
598
+ if (unknown !== undefined) {
599
+ throw new TypeError(`${path} carries undeclared ${unknown}`);
600
+ }
601
+ if (
602
+ typeof raw.classification !== 'string' ||
603
+ !SETTLEMENT_UNRESOLVED_CLASSIFICATIONS.has(
604
+ raw.classification as SettlementUnresolvedEffectEvidence['classification'],
605
+ )
606
+ ) {
607
+ throw new TypeError(`${path} classification is not supported`);
608
+ }
609
+ if (
610
+ typeof raw.baselineHead !== 'string' ||
611
+ !SETTLEMENT_GIT_OID_PATTERN.test(raw.baselineHead)
612
+ ) {
613
+ throw new TypeError(`${path} baselineHead must be a Git OID`);
614
+ }
615
+ if (
616
+ 'afterHead' in raw &&
617
+ (typeof raw.afterHead !== 'string' ||
618
+ !SETTLEMENT_GIT_OID_PATTERN.test(raw.afterHead))
619
+ ) {
620
+ throw new TypeError(`${path} afterHead must be a Git OID`);
621
+ }
622
+ if (
623
+ raw.classification !== 'observation-ambiguous' &&
624
+ raw.classification !== 'incomplete' &&
625
+ !('afterHead' in raw)
626
+ ) {
627
+ throw new TypeError(
628
+ `${path} afterHead is required for ${raw.classification}`,
629
+ );
630
+ }
631
+ if (raw.classification === 'one-descendant-commit') {
632
+ if (
633
+ typeof raw.commitOid !== 'string' ||
634
+ !SETTLEMENT_GIT_OID_PATTERN.test(raw.commitOid) ||
635
+ raw.commitOid !== raw.afterHead
636
+ ) {
637
+ throw new TypeError(
638
+ `${path} commitOid must equal afterHead for one-descendant-commit`,
639
+ );
640
+ }
641
+ } else if ('commitOid' in raw) {
642
+ throw new TypeError(
643
+ `${path} commitOid is permitted only for one-descendant-commit`,
644
+ );
645
+ }
646
+ }
647
+ return detached as unknown as readonly SettlementUnresolvedEffectEvidence[];
648
+ }
649
+
521
650
  function validateSettlement(value: unknown): SettlementEvidence {
522
651
  if (!isRecord(value)) {
523
652
  throw new TypeError('controller settlement must be an object');
@@ -525,6 +654,7 @@ function validateSettlement(value: unknown): SettlementEvidence {
525
654
  const allowed = new Set([
526
655
  'status',
527
656
  'facts',
657
+ 'unresolvedEffects',
528
658
  'reason',
529
659
  'receipt',
530
660
  'leafStateSummary',
@@ -549,6 +679,12 @@ function validateSettlement(value: unknown): SettlementEvidence {
549
679
  ) {
550
680
  throw new TypeError('controller settlement facts must be a string array');
551
681
  }
682
+ if (!Object.prototype.hasOwnProperty.call(value, 'unresolvedEffects')) {
683
+ throw new TypeError('controller settlement unresolvedEffects is required');
684
+ }
685
+ const unresolvedEffects = validateSettlementUnresolvedEffects(
686
+ value.unresolvedEffects,
687
+ );
552
688
  if ('reason' in value && typeof value.reason !== 'string') {
553
689
  throw new TypeError('controller settlement reason must be a string');
554
690
  }
@@ -609,6 +745,7 @@ function validateSettlement(value: unknown): SettlementEvidence {
609
745
  return Object.freeze({
610
746
  status: value.status,
611
747
  facts: Object.freeze([...(value.facts as readonly string[])]),
748
+ unresolvedEffects,
612
749
  ...(typeof value.reason === 'string' ? { reason: value.reason } : {}),
613
750
  ...(receipt === undefined ? {} : { receipt }),
614
751
  ...(typeof value.leafStateSummary === 'string'
@@ -773,6 +910,8 @@ function statusesForState(
773
910
  }
774
911
  }
775
912
 
913
+ const UNFINISHED_FINAL_STATE_IDS: ReadonlySet<string> = new Set();
914
+
776
915
  // Internal export surface for verification and tests. Not part of the
777
916
  // stable public API; the leading underscore signals "subject to change."
778
917
  export const _internal = {
@@ -789,19 +928,23 @@ export const _internal = {
789
928
  validateParsedActingDecision,
790
929
  statusesForState,
791
930
  normalizeError,
931
+ UNFINISHED_FINAL_STATE_IDS,
792
932
  };
793
933
 
794
934
  // The Captain-specific spec handed to the shared runtime factory
795
935
  // (slc/link.md §Output, DR-019). Generic machinery — actor wiring, boundary
796
936
  // tracing, lifecycle, abort, the parked-session snapshot, and the DR-029
797
937
  // describe/apply control surface — lives in @sublang/playbook/xstate-runtime.
798
- const runtimeSpec: XStatePlaybookRuntimeSpec<ValidatedCaptainOptions> = {
938
+ const runtimeSpec: XStatePlaybookRuntimeSpecV3<ValidatedCaptainOptions> = {
799
939
  label: 'CAPTAIN',
800
940
  // DR-022 / slc/link.md: the declaration carries the value current at link
801
941
  // time — a literal, never the loading engine's RUNTIME_ABI self-report,
802
942
  // which would follow whatever engine loads the module and make the
803
943
  // factory's skew check compare that engine with itself.
804
- compat: { artifactSchema: 2, runtimeAbi: 1 },
944
+ compat: { artifactSchema: 3, runtimeAbi: 1 },
945
+ // DR-040: the roleless session Captain has no delegated-player outcome,
946
+ // but schema 3 still makes that absence explicit rather than inferred.
947
+ outcomeAuthority: { governedPlayerStates: {} },
805
948
  snapshotOptions: snapshotCaptainOptions,
806
949
  machineInput: (options) => ({ enabledPlaybooks: options.enabledPlaybooks }),
807
950
  classifyBossText: (text, ports, signal, snapshotOrState, boundary, options) =>
@@ -833,7 +976,9 @@ const runtimeSpec: XStatePlaybookRuntimeSpec<ValidatedCaptainOptions> = {
833
976
  'receiptReason',
834
977
  'receiptError',
835
978
  'leafStateSummary',
979
+ 'settlementUnresolvedEffects',
836
980
  ],
981
+ unfinishedFinalStateIds: UNFINISHED_FINAL_STATE_IDS,
837
982
  statusesForState,
838
983
  };
839
984
 
@@ -843,18 +988,45 @@ const runtimeSpec: XStatePlaybookRuntimeSpec<ValidatedCaptainOptions> = {
843
988
  // uncaught ESM-load error that takes even `--help` down; constructing on
844
989
  // the first runtime request keeps the failure inside the caught
845
990
  // host-construction boundary that owes the Boss a setup diagnostic.
991
+ const rejectInternalCaptainRepositoryWork = async (): Promise<never> => {
992
+ throw new Error(
993
+ 'the roleless session Captain cannot perform repository-governed work',
994
+ );
995
+ };
996
+ const INTERNAL_CAPTAIN_HOST_CAPABILITIES = Object.freeze({
997
+ // The engine does not consult this member for an explicitly empty governed
998
+ // set. Keeping a fail-closed implementation satisfies the schema-3 factory
999
+ // boundary without granting the internal controller repository authority.
1000
+ repository: Object.freeze({
1001
+ runExclusive: rejectInternalCaptainRepositoryWork,
1002
+ runDeferred: rejectInternalCaptainRepositoryWork,
1003
+ } satisfies XStateRepositoryCapability),
1004
+ effectLedger: Object.freeze({
1005
+ snapshot: emptyPlaybookEffectLedger,
1006
+ writeAhead: async (): Promise<never> => {
1007
+ throw new Error(
1008
+ 'the roleless session Captain cannot write an effect ledger',
1009
+ );
1010
+ },
1011
+ }),
1012
+ });
1013
+
1014
+ function buildCaptainPlaybookRuntimeFactory() {
1015
+ return createXStatePlaybookRuntime(captainMachine, runtimeSpec);
1016
+ }
1017
+
846
1018
  let createCaptainPlaybookRuntime:
847
- | PlaybookRuntimeFactory<ValidatedCaptainOptions>
1019
+ | ReturnType<typeof buildCaptainPlaybookRuntimeFactory>
848
1020
  | undefined;
849
1021
 
850
1022
  export function createPlaybookRuntime(
851
1023
  options: PlaybookRuntimeOptions,
852
1024
  ): PlaybookRuntime {
853
- createCaptainPlaybookRuntime ??= createXStatePlaybookRuntime(
854
- captainMachine,
855
- runtimeSpec,
856
- );
857
- return createCaptainPlaybookRuntime(options);
1025
+ createCaptainPlaybookRuntime ??= buildCaptainPlaybookRuntimeFactory();
1026
+ return createCaptainPlaybookRuntime({
1027
+ configuredOptions: options,
1028
+ hostCapabilities: INTERNAL_CAPTAIN_HOST_CAPABILITIES,
1029
+ });
858
1030
  }
859
1031
 
860
1032
  const factory: PlaybookRuntimeFactory<PlaybookRuntimeOptions> =