@sublang/playbook 8.0.0 → 10.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 (66) hide show
  1. package/README.md +3 -3
  2. package/docs/cli.md +66 -23
  3. package/docs/configuration.md +13 -8
  4. package/docs/embedding.md +45 -14
  5. package/package.json +7 -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 +151 -10
  13. package/reference/sdlc/captain.playbook/captain.playbook.ts +200 -14
  14. package/reference/sdlc/code.md +0 -1
  15. package/reference/sdlc/code.playbook/bin/interactive-session.js +170 -17
  16. package/reference/sdlc/code.playbook/bin/launch-config.js +136 -4
  17. package/reference/sdlc/code.playbook/bin/playbook.js +81 -4
  18. package/reference/sdlc/code.playbook/bin/repository-effects.js +2930 -0
  19. package/reference/sdlc/code.playbook/bin/run.js +365 -63
  20. package/reference/sdlc/code.playbook/bin/session-store.js +2877 -209
  21. package/reference/sdlc/code.playbook/code.fsm.d.ts +11 -1
  22. package/reference/sdlc/code.playbook/code.fsm.js +85 -29
  23. package/reference/sdlc/code.playbook/code.fsm.ts +95 -33
  24. package/reference/sdlc/code.playbook/code.gears.md +0 -2
  25. package/reference/sdlc/code.playbook/code.playbook.d.ts +5 -2
  26. package/reference/sdlc/code.playbook/code.playbook.js +67 -4
  27. package/reference/sdlc/code.playbook/code.playbook.ts +87 -8
  28. package/reference/sdlc/code.playbook/code.registry.d.ts +10 -3
  29. package/reference/sdlc/code.playbook/code.registry.js +10 -3
  30. package/reference/sdlc/code.playbook/code.registry.ts +23 -5
  31. package/reference/sdlc/code.playbook/playbook-captain.d.ts +99 -7
  32. package/reference/sdlc/code.playbook/playbook-captain.js +1894 -82
  33. package/reference/sdlc/code.playbook/playbook-captain.ts +2809 -109
  34. package/reference/sdlc/decide.md +0 -1
  35. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +8 -1
  36. package/reference/sdlc/decide.playbook/decide.fsm.js +80 -29
  37. package/reference/sdlc/decide.playbook/decide.fsm.ts +89 -31
  38. package/reference/sdlc/decide.playbook/decide.gears.md +0 -1
  39. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +15 -5
  40. package/reference/sdlc/decide.playbook/decide.playbook.js +1994 -191
  41. package/reference/sdlc/decide.playbook/decide.playbook.ts +3209 -404
  42. package/reference/sdlc/decide.playbook/decide.registry.d.ts +7 -3
  43. package/reference/sdlc/decide.playbook/decide.registry.js +10 -3
  44. package/reference/sdlc/decide.playbook/decide.registry.ts +20 -5
  45. package/reference/sdlc/review.playbook/review.fsm.d.ts +7 -0
  46. package/reference/sdlc/review.playbook/review.fsm.js +133 -12
  47. package/reference/sdlc/review.playbook/review.fsm.ts +140 -12
  48. package/reference/sdlc/review.playbook/review.playbook.d.ts +5 -2
  49. package/reference/sdlc/review.playbook/review.playbook.js +78 -4
  50. package/reference/sdlc/review.playbook/review.playbook.ts +95 -8
  51. package/reference/sdlc/review.playbook/review.registry.d.ts +10 -3
  52. package/reference/sdlc/review.playbook/review.registry.js +10 -3
  53. package/reference/sdlc/review.playbook/review.registry.ts +23 -5
  54. package/slc/gears2fsm.md +25 -7
  55. package/slc/link.md +727 -82
  56. package/src/accepted-outcome.d.ts +18 -0
  57. package/src/accepted-outcome.js +94 -0
  58. package/src/accepted-outcome.ts +140 -0
  59. package/src/runtime.d.ts +165 -3
  60. package/src/runtime.ts +214 -2
  61. package/src/xstate-playbook-runtime.d.ts +162 -13
  62. package/src/xstate-playbook-runtime.js +3344 -564
  63. package/src/xstate-playbook-runtime.ts +4873 -637
  64. package/src/xstate-runtime.d.ts +76 -8
  65. package/src/xstate-runtime.js +1001 -64
  66. package/src/xstate-runtime.ts +1640 -91
@@ -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, RUNTIME_ABI, } 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
@@ -516,7 +617,14 @@ export const _internal = {
516
617
  // describe/apply control surface — lives in @sublang/playbook/xstate-runtime.
517
618
  const runtimeSpec = {
518
619
  label: 'CAPTAIN',
519
- compat: { artifactSchema: 2, runtimeAbi: RUNTIME_ABI },
620
+ // DR-022 / slc/link.md: the declaration carries the value current at link
621
+ // time — a literal, never the loading engine's RUNTIME_ABI self-report,
622
+ // which would follow whatever engine loads the module and make the
623
+ // factory's skew check compare that engine with itself.
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: {} },
520
628
  snapshotOptions: snapshotCaptainOptions,
521
629
  machineInput: (options) => ({ enabledPlaybooks: options.enabledPlaybooks }),
522
630
  classifyBossText: (text, ports, signal, snapshotOrState, boundary, options) => classifyControllerTurn(text, ports, signal, snapshotOrState, boundary, options),
@@ -540,12 +648,45 @@ const runtimeSpec = {
540
648
  'receiptReason',
541
649
  'receiptError',
542
650
  'leafStateSummary',
651
+ 'settlementUnresolvedEffects',
543
652
  ],
653
+ unfinishedFinalStateIds: UNFINISHED_FINAL_STATE_IDS,
544
654
  statusesForState,
545
655
  };
546
- const createCaptainPlaybookRuntime = createXStatePlaybookRuntime(captainMachine, runtimeSpec);
656
+ // DR-022's compat check fails fast at factory construction. The Captain is
657
+ // statically imported by the shell and both CLI front ends, so constructing
658
+ // here at module evaluation would turn a future compat mismatch into an
659
+ // uncaught ESM-load error that takes even `--help` down; constructing on
660
+ // the first runtime request keeps the failure inside the caught
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
+ }
683
+ let createCaptainPlaybookRuntime;
547
684
  export function createPlaybookRuntime(options) {
548
- return createCaptainPlaybookRuntime(options);
685
+ createCaptainPlaybookRuntime ??= buildCaptainPlaybookRuntimeFactory();
686
+ return createCaptainPlaybookRuntime({
687
+ configuredOptions: options,
688
+ hostCapabilities: INTERNAL_CAPTAIN_HOST_CAPABILITIES,
689
+ });
549
690
  }
550
691
  const factory = createPlaybookRuntime;
551
692
  export default factory;
@@ -31,22 +31,23 @@
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,
43
44
  snapshotJsonValue,
44
- RUNTIME_ABI,
45
45
  type PlaybookActorOutput,
46
46
  type PlaybookCaptainInput,
47
47
  type ScheduledStatus,
48
48
  type XStateCaptainStrategyRun,
49
- type XStatePlaybookRuntimeSpec,
49
+ type XStatePlaybookRuntimeSpecV3,
50
+ type XStateRepositoryCapability,
50
51
  } from '../../../src/xstate-runtime.js';
51
52
  import {
52
53
  captainMachine,
@@ -57,6 +58,7 @@ import {
57
58
  type ParsedActingDecision,
58
59
  type SettlementEvidence,
59
60
  type SettlementReceiptEvidence,
61
+ type SettlementUnresolvedEffectEvidence,
60
62
  } from './captain.fsm.js';
61
63
  import type {
62
64
  CaptainCallOptions,
@@ -124,9 +126,10 @@ export type {
124
126
  /** One nonempty complete standalone request selected for `start` or `switch`. */
125
127
  export type CaptainControllerInput = string;
126
128
 
127
- /** One validated controller selection submitted through the port (DR-029). */
129
+ /** One validated controller selection submitted through the port (DR-029, DR-038). */
128
130
  export type CaptainControllerSelection =
129
131
  | { readonly action: 'respond'; readonly text: string }
132
+ | { readonly action: 'resume'; readonly playbookId: string }
130
133
  | {
131
134
  readonly action: 'start' | 'switch';
132
135
  readonly playbookId: string;
@@ -295,6 +298,21 @@ function validateParsedActingDecision(value: unknown): ParsedActingDecision {
295
298
  }
296
299
  return { action: 'deliver' };
297
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
+ }
298
316
  if (value.action !== 'start' && value.action !== 'switch') {
299
317
  throw new TypeError(
300
318
  `parse-resolved decision names unknown action ${String(value.action)}`,
@@ -364,8 +382,8 @@ function classifyControllerTurn(
364
382
  // ---------------------------------------------------------------------------
365
383
 
366
384
  const RESTATED_REPLY_CONTRACT = [
367
- '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`:',
368
- '`{ "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.',
369
387
  ].join('\n');
370
388
 
371
389
  function correctiveDecisionPrompt(prompt: string, reason: string): string {
@@ -432,6 +450,24 @@ function readDecisionReply(
432
450
  if (shape !== undefined) return { reason: shape };
433
451
  return { selection: { action, text: parsed.text as string } };
434
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
+ }
435
471
  case 'start':
436
472
  case 'switch': {
437
473
  const shape =
@@ -489,6 +525,9 @@ function selectionFromParsedDecision(
489
525
  if (decision.action === 'deliver') {
490
526
  return { action: 'deliver' };
491
527
  }
528
+ if (decision.action === 'resume') {
529
+ return { action: 'resume', playbookId: decision.playbookId };
530
+ }
492
531
  // A parse-resolved `start` / `switch` is always the exact command remainder
493
532
  // supplied by the host (CAPTAIN-7 command table).
494
533
  return {
@@ -514,11 +553,100 @@ function decisionOutputOf(
514
553
 
515
554
  // ---------------------------------------------------------------------------
516
555
  // Settlement validation: the returned settlement is the only evidence of
517
- // effects, and the machine retains only its status, facts, receipt
518
- // disposition, and leaf-state summary (CAPPLAY-10). A malformed settlement
519
- // 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.
520
559
  // ---------------------------------------------------------------------------
521
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
+
522
650
  function validateSettlement(value: unknown): SettlementEvidence {
523
651
  if (!isRecord(value)) {
524
652
  throw new TypeError('controller settlement must be an object');
@@ -526,6 +654,7 @@ function validateSettlement(value: unknown): SettlementEvidence {
526
654
  const allowed = new Set([
527
655
  'status',
528
656
  'facts',
657
+ 'unresolvedEffects',
529
658
  'reason',
530
659
  'receipt',
531
660
  'leafStateSummary',
@@ -550,6 +679,12 @@ function validateSettlement(value: unknown): SettlementEvidence {
550
679
  ) {
551
680
  throw new TypeError('controller settlement facts must be a string array');
552
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
+ );
553
688
  if ('reason' in value && typeof value.reason !== 'string') {
554
689
  throw new TypeError('controller settlement reason must be a string');
555
690
  }
@@ -610,6 +745,7 @@ function validateSettlement(value: unknown): SettlementEvidence {
610
745
  return Object.freeze({
611
746
  status: value.status,
612
747
  facts: Object.freeze([...(value.facts as readonly string[])]),
748
+ unresolvedEffects,
613
749
  ...(typeof value.reason === 'string' ? { reason: value.reason } : {}),
614
750
  ...(receipt === undefined ? {} : { receipt }),
615
751
  ...(typeof value.leafStateSummary === 'string'
@@ -774,6 +910,8 @@ function statusesForState(
774
910
  }
775
911
  }
776
912
 
913
+ const UNFINISHED_FINAL_STATE_IDS: ReadonlySet<string> = new Set();
914
+
777
915
  // Internal export surface for verification and tests. Not part of the
778
916
  // stable public API; the leading underscore signals "subject to change."
779
917
  export const _internal = {
@@ -790,15 +928,23 @@ export const _internal = {
790
928
  validateParsedActingDecision,
791
929
  statusesForState,
792
930
  normalizeError,
931
+ UNFINISHED_FINAL_STATE_IDS,
793
932
  };
794
933
 
795
934
  // The Captain-specific spec handed to the shared runtime factory
796
935
  // (slc/link.md §Output, DR-019). Generic machinery — actor wiring, boundary
797
936
  // tracing, lifecycle, abort, the parked-session snapshot, and the DR-029
798
937
  // describe/apply control surface — lives in @sublang/playbook/xstate-runtime.
799
- const runtimeSpec: XStatePlaybookRuntimeSpec<ValidatedCaptainOptions> = {
938
+ const runtimeSpec: XStatePlaybookRuntimeSpecV3<ValidatedCaptainOptions> = {
800
939
  label: 'CAPTAIN',
801
- compat: { artifactSchema: 2, runtimeAbi: RUNTIME_ABI },
940
+ // DR-022 / slc/link.md: the declaration carries the value current at link
941
+ // time — a literal, never the loading engine's RUNTIME_ABI self-report,
942
+ // which would follow whatever engine loads the module and make the
943
+ // factory's skew check compare that engine with itself.
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: {} },
802
948
  snapshotOptions: snapshotCaptainOptions,
803
949
  machineInput: (options) => ({ enabledPlaybooks: options.enabledPlaybooks }),
804
950
  classifyBossText: (text, ports, signal, snapshotOrState, boundary, options) =>
@@ -830,17 +976,57 @@ const runtimeSpec: XStatePlaybookRuntimeSpec<ValidatedCaptainOptions> = {
830
976
  'receiptReason',
831
977
  'receiptError',
832
978
  'leafStateSummary',
979
+ 'settlementUnresolvedEffects',
833
980
  ],
981
+ unfinishedFinalStateIds: UNFINISHED_FINAL_STATE_IDS,
834
982
  statusesForState,
835
983
  };
836
984
 
837
- const createCaptainPlaybookRuntime: PlaybookRuntimeFactory<ValidatedCaptainOptions> =
838
- createXStatePlaybookRuntime(captainMachine, runtimeSpec);
985
+ // DR-022's compat check fails fast at factory construction. The Captain is
986
+ // statically imported by the shell and both CLI front ends, so constructing
987
+ // here at module evaluation would turn a future compat mismatch into an
988
+ // uncaught ESM-load error that takes even `--help` down; constructing on
989
+ // the first runtime request keeps the failure inside the caught
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
+
1018
+ let createCaptainPlaybookRuntime:
1019
+ | ReturnType<typeof buildCaptainPlaybookRuntimeFactory>
1020
+ | undefined;
839
1021
 
840
1022
  export function createPlaybookRuntime(
841
1023
  options: PlaybookRuntimeOptions,
842
1024
  ): PlaybookRuntime {
843
- return createCaptainPlaybookRuntime(options);
1025
+ createCaptainPlaybookRuntime ??= buildCaptainPlaybookRuntimeFactory();
1026
+ return createCaptainPlaybookRuntime({
1027
+ configuredOptions: options,
1028
+ hostCapabilities: INTERNAL_CAPTAIN_HOST_CAPABILITIES,
1029
+ });
844
1030
  }
845
1031
 
846
1032
  const factory: PlaybookRuntimeFactory<PlaybookRuntimeOptions> =
@@ -48,7 +48,6 @@ At the start of *every* phase, Captain shall append the following instruction:
48
48
  Do not re-run tests or builds whose inputs have not changed since any previous reported run.
49
49
  Make the phase's minimal changes and then one new commit, following @specs/packages/git.md; never amend an existing commit.
50
50
  Make the commit message explain concisely what changed and why, including relevant verification.
51
- Report it as exactly one final-response line beginning `Commit: `, followed only by the exact commit identity.
52
51
  Coder is <coder-llm>; format the model token in conventional human form.
53
52
  ```
54
53