@sublang/playbook 14.0.0 → 14.1.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 (34) hide show
  1. package/docs/cli.md +14 -14
  2. package/package.json +1 -1
  3. package/reference/sdlc/branch.playbook/branch.fsm.d.ts +1 -1
  4. package/reference/sdlc/code.playbook/bin/repository-effects.js +187 -14
  5. package/reference/sdlc/code.playbook/code.fsm.d.ts +1 -1
  6. package/reference/sdlc/code.playbook/host-capabilities.d.ts +6 -0
  7. package/reference/sdlc/code.playbook/playbook-captain.d.ts +3 -1
  8. package/reference/sdlc/code.playbook/playbook-captain.js +357 -16
  9. package/reference/sdlc/code.playbook/playbook-captain.ts +459 -34
  10. package/reference/sdlc/code.playbook/session-host.d.ts +6 -3
  11. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +1 -1
  12. package/reference/sdlc/decide.playbook/decide.playbook.js +25 -1
  13. package/reference/sdlc/decide.playbook/decide.playbook.ts +35 -2
  14. package/reference/sdlc/dev.md +15 -8
  15. package/reference/sdlc/dev.playbook/dev.fsm.d.ts +1 -1
  16. package/reference/sdlc/dev.playbook/dev.fsm.js +18 -12
  17. package/reference/sdlc/dev.playbook/dev.fsm.ts +18 -13
  18. package/reference/sdlc/dev.playbook/dev.gears.md +19 -12
  19. package/reference/sdlc/dev.playbook/dev.playbook.js +1 -1
  20. package/reference/sdlc/dev.playbook/dev.playbook.ts +1 -2
  21. package/reference/sdlc/dev.playbook/dev.registry.js +1 -1
  22. package/reference/sdlc/dev.playbook/dev.registry.ts +1 -1
  23. package/reference/sdlc/pr.playbook/pr.fsm.d.ts +1 -1
  24. package/reference/sdlc/review.playbook/review.fsm.d.ts +1 -1
  25. package/slc/link.md +32 -0
  26. package/src/runtime.d.ts +70 -0
  27. package/src/runtime.js +232 -1
  28. package/src/runtime.ts +358 -1
  29. package/src/xstate-playbook-runtime.d.ts +9 -10
  30. package/src/xstate-playbook-runtime.js +372 -20
  31. package/src/xstate-playbook-runtime.ts +460 -19
  32. package/src/xstate-runtime.d.ts +9 -1
  33. package/src/xstate-runtime.js +299 -19
  34. package/src/xstate-runtime.ts +362 -21
@@ -19,6 +19,7 @@ import type { Effort, PermissionPolicy } from '@sublang/cligent';
19
19
  import type {
20
20
  JsonValue,
21
21
  NormalizedError,
22
+ PlaybookControlStanding,
22
23
  PlaybookEffectLedger,
23
24
  PlaybookEffectLedgerCommandBatch,
24
25
  PlaybookCallRequest,
@@ -26,6 +27,8 @@ import type {
26
27
  PlaybookCallStart,
27
28
  PlaybookControlAction,
28
29
  PlaybookControlView,
30
+ PlaybookFailureCause,
31
+ PlaybookFailureCode,
29
32
  PlaybookPorts,
30
33
  PlaybookRunResult,
31
34
  PlaybookRuntime,
@@ -34,6 +37,7 @@ import type {
34
37
  PlayerSessionStore,
35
38
  PlaybookState,
36
39
  } from '@sublang/playbook/runtime';
40
+ import { assertPlaybookFailureCause } from '@sublang/playbook/runtime';
37
41
  import {
38
42
  assertPlaybookRuntimeSnapshot,
39
43
  assertPlaybookEffectLedger,
@@ -273,7 +277,12 @@ type PlaybookCaptainShellSnapshotValue =
273
277
  readonly frames: readonly PlaybookCaptainFrameSnapshot[];
274
278
  readonly retainedEffectReconciliation?: PlaybookCaptainRetainedEffectReconciliation;
275
279
  readonly pendingBossQuestions?: JsonValue;
276
- readonly lastError?: { readonly name: string; readonly message: string };
280
+ /** DR-063 §2: the parked failure's cause travels with its error. */
281
+ readonly lastError?: {
282
+ readonly name: string;
283
+ readonly message: string;
284
+ readonly cause?: PlaybookFailureCause;
285
+ };
277
286
  }
278
287
  );
279
288
 
@@ -517,7 +526,7 @@ interface ControlLedger {
517
526
  latestSubRuntimeStateId?: string;
518
527
  latestSubRuntimeState?: PlaybookState;
519
528
  pendingBossQuestions?: unknown;
520
- lastError?: { name: string; message: string };
529
+ lastError?: { name: string; message: string; cause?: PlaybookFailureCause };
521
530
  // CAPTAIN-5/CAPTAIN-6: the session Captain's own identity plus the durable
522
531
  // conversation and journal by presence only — never the pinned token value.
523
532
  captainSessionId?: string;
@@ -708,6 +717,17 @@ interface ActiveTurn {
708
717
  outcomeRecorded: boolean;
709
718
  /** Canonical host evidence frozen before the controller result phase. */
710
719
  unresolvedEffects?: readonly PlaybookCaptainUnresolvedEffect[];
720
+ /**
721
+ * DR-062 §5: the effect ledger's boundary count when this turn was
722
+ * admitted. A boundary beyond it completed during this turn; `undefined`
723
+ * where the count could not be read, which reports nothing rather than
724
+ * report a boundary an earlier turn already reported.
725
+ */
726
+ readonly effectBoundaryPrefixCount?: number;
727
+ /** Set once this turn's carried pre-existing changes have been reported. */
728
+ carriedPreExistingReported?: boolean;
729
+ /** DR-063 §4: set once this turn's failure report has been appended. */
730
+ failureReported?: boolean;
711
731
  }
712
732
 
713
733
  function parseRegisteredCommand(
@@ -1203,6 +1223,199 @@ function unresolvedEffectBossReport(
1203
1223
  ].join('\n');
1204
1224
  }
1205
1225
 
1226
+ // DR-062 §5: a settled turn whose accepted commit absorbed or altered the
1227
+ // Boss's own uncommitted changes says so, bounded to paths.
1228
+ const CARRIED_CHANGE_PATH_LIMIT = 24;
1229
+
1230
+ interface CarriedPreExistingChanges {
1231
+ readonly commitOid: string;
1232
+ readonly absorbed: readonly string[];
1233
+ readonly altered: readonly string[];
1234
+ }
1235
+
1236
+ function boundedPathList(paths: readonly string[]): string {
1237
+ const printed = paths.slice(0, CARRIED_CHANGE_PATH_LIMIT);
1238
+ const omitted = paths.length - printed.length;
1239
+ return omitted > 0
1240
+ ? `${printed.join(', ')}, … and ${omitted} more`
1241
+ : printed.join(', ');
1242
+ }
1243
+
1244
+ /**
1245
+ * The carried pre-existing changes of the boundaries completed after
1246
+ * `boundaryPrefixCount`, one entry per commit OID in boundary sequence order.
1247
+ */
1248
+ function carriedPreExistingChanges(
1249
+ ledger: PlaybookEffectLedger,
1250
+ boundaryPrefixCount: number,
1251
+ ): readonly CarriedPreExistingChanges[] {
1252
+ const byCommit = new Map<string, { absorbed: string[]; altered: string[] }>();
1253
+ for (const boundary of ledger.boundaries) {
1254
+ if (boundary.sequence <= boundaryPrefixCount) continue;
1255
+ const receipt = boundary.physicalReceipt;
1256
+ if (
1257
+ receipt?.classification !== 'one-descendant-commit' ||
1258
+ receipt.commitOid === undefined ||
1259
+ receipt.preExisting === undefined
1260
+ ) {
1261
+ continue;
1262
+ }
1263
+ const { absorbed, altered } = receipt.preExisting;
1264
+ if (absorbed.length === 0 && altered.length === 0) continue;
1265
+ const carried = byCommit.get(receipt.commitOid) ?? {
1266
+ absorbed: [],
1267
+ altered: [],
1268
+ };
1269
+ carried.absorbed.push(...absorbed);
1270
+ carried.altered.push(...altered);
1271
+ byCommit.set(receipt.commitOid, carried);
1272
+ }
1273
+ return [...byCommit].map(([commitOid, { absorbed, altered }]) => ({
1274
+ commitOid,
1275
+ absorbed: [...new Set(absorbed)].sort(),
1276
+ altered: [...new Set(altered)].sort(),
1277
+ }));
1278
+ }
1279
+
1280
+ function carriedPreExistingReport(
1281
+ carried: readonly CarriedPreExistingChanges[],
1282
+ ): string | undefined {
1283
+ if (carried.length === 0) return undefined;
1284
+ return carried
1285
+ .map(({ commitOid, absorbed, altered }) =>
1286
+ [
1287
+ `Pre-existing changes carried by commit ${commitOid}:`,
1288
+ ...(absorbed.length === 0
1289
+ ? []
1290
+ : [`- absorbed as found: ${boundedPathList(absorbed)}`]),
1291
+ ...(altered.length === 0
1292
+ ? []
1293
+ : [`- altered before committing: ${boundedPathList(altered)}`]),
1294
+ 'These changes were uncommitted before the step.',
1295
+ ].join('\n'),
1296
+ )
1297
+ .join('\n\n');
1298
+ }
1299
+
1300
+ function carriedPreExistingFacts(
1301
+ carried: readonly CarriedPreExistingChanges[],
1302
+ ): readonly string[] {
1303
+ return carried.map(({ commitOid, absorbed, altered }) => {
1304
+ const paths = [...new Set([...absorbed, ...altered])].sort();
1305
+ return `The commit ${commitOid} carries ${paths.length} changes that were uncommitted before the step: ${boundedPathList(paths)}.`;
1306
+ });
1307
+ }
1308
+
1309
+ // DR-063 §4: the Boss-visible failure report. Every host sees the same
1310
+ // sentences because the shell composes them from the structured cause instead
1311
+ // of asking a model to describe facts it may drop. The report exposes no file
1312
+ // content, player prose, or internal identity: paths, revisions, dispositions,
1313
+ // and the reported error message are what a cause carries.
1314
+
1315
+ /** The Boss-facing phrase for each closed control-action reason (DR-063 §3). */
1316
+ const CONTROL_REASON_PHRASES: Readonly<Record<string, string>> = {
1317
+ 'receipt-complete': 'nothing has changed since it failed',
1318
+ };
1319
+
1320
+ function controlReasonPhrase(reason: string | undefined): string {
1321
+ if (reason === undefined) return 'no reason was published';
1322
+ return CONTROL_REASON_PHRASES[reason] ?? reason;
1323
+ }
1324
+
1325
+ function causePathList(
1326
+ paths: readonly string[] | undefined,
1327
+ truncated: number | undefined,
1328
+ ): string {
1329
+ const list = paths ?? [];
1330
+ const printed = list.slice(0, CARRIED_CHANGE_PATH_LIMIT);
1331
+ const omitted = list.length - printed.length + (truncated ?? 0);
1332
+ return omitted > 0
1333
+ ? `${printed.join(', ')}, … and ${omitted} more`
1334
+ : printed.join(', ');
1335
+ }
1336
+
1337
+ /** One sentence per code, with that code's bounded evidence (DR-063 §4). */
1338
+ function failureCauseSentence(
1339
+ cause: PlaybookFailureCause,
1340
+ describePlaybook: (playbookId: string) => string,
1341
+ ): string {
1342
+ const evidence = cause.evidence;
1343
+ const paths = evidence.paths;
1344
+ const code: PlaybookFailureCode = cause.code;
1345
+ switch (code) {
1346
+ case 'commit-missing':
1347
+ return (paths?.uncommitted ?? []).length === 0
1348
+ ? 'the step had to commit its work and committed nothing.'
1349
+ : `the step had to commit its work and committed nothing; these changes are uncommitted: ${causePathList(paths?.uncommitted, paths?.truncated)}.`;
1350
+ case 'commit-residual': {
1351
+ const subject =
1352
+ evidence.commitOid === undefined
1353
+ ? "the step's commit"
1354
+ : `the commit ${evidence.commitOid}`;
1355
+ if ((paths?.uncommitted ?? []).length > 0) {
1356
+ return `${subject} left changes uncommitted: ${causePathList(paths?.uncommitted, paths?.truncated)}.`;
1357
+ }
1358
+ if ((paths?.altered ?? []).length > 0) {
1359
+ return `${subject} left altered changes uncommitted: ${causePathList(paths?.altered, paths?.truncated)}.`;
1360
+ }
1361
+ return `${subject} left changes uncommitted beside it.`;
1362
+ }
1363
+ case 'pre-existing-lost':
1364
+ return `uncommitted changes you had were lost: ${causePathList(paths?.lost, paths?.truncated)}.`;
1365
+ case 'commits-more-than-one':
1366
+ return `the step made more than one commit; HEAD moved from ${evidence.baselineHead} to ${evidence.afterHead}.`;
1367
+ case 'history-rewritten':
1368
+ return `the repository history was rewritten; HEAD moved from ${evidence.baselineHead} to ${evidence.afterHead}.`;
1369
+ case 'foreign-change':
1370
+ return `the repository changed outside this step: ${causePathList(paths?.changed, paths?.truncated)}.`;
1371
+ case 'observation-unstable':
1372
+ return `the repository could not be observed after the step; it stood at ${evidence.baselineHead} before it.`;
1373
+ case 'attribution-ambiguous':
1374
+ return `the repository change could not be attributed to this step; it required ${evidence.required} and observed ${evidence.observed}.`;
1375
+ case 'receipt-missing':
1376
+ return `a step left no complete repository receipt; the repository stood at ${evidence.baselineHead} before it.`;
1377
+ case 'judge-failed':
1378
+ return evidence.error === undefined
1379
+ ? `the hidden adjudication failed: ${evidence.reason}.`
1380
+ : `the hidden adjudication failed: ${evidence.reason} (${evidence.error.name}: ${compactEvidence(evidence.error.message)}).`;
1381
+ case 'player-failed':
1382
+ return `the ${evidence.roleId} call failed: ${compactEvidence(evidence.error?.message ?? 'no detail was reported')}.`;
1383
+ case 'aborted':
1384
+ return 'the turn was aborted before the work settled.';
1385
+ case 'child-failed': {
1386
+ const nested = describePlaybook(evidence.playbookId ?? 'nested playbook');
1387
+ return evidence.cause === undefined
1388
+ ? `the nested ${nested} run failed.`
1389
+ : `the nested ${nested} run failed: ${failureCauseSentence(evidence.cause, describePlaybook)}`;
1390
+ }
1391
+ case 'runtime-defect':
1392
+ return `the runtime could not settle the step: ${compactEvidence(evidence.reason ?? 'no reason was recorded')}.`;
1393
+ }
1394
+ }
1395
+
1396
+ function failureControlLine(action: {
1397
+ readonly label: string;
1398
+ readonly standing?: PlaybookControlStanding;
1399
+ readonly reason?: string;
1400
+ }): string {
1401
+ // DR-063 §3: an action that publishes no standing is `ready`.
1402
+ const standing = action.standing ?? 'ready';
1403
+ return standing === 'ready'
1404
+ ? `- ${action.label} (ready)`
1405
+ : `- ${action.label} (${standing}: ${controlReasonPhrase(action.reason)})`;
1406
+ }
1407
+
1408
+ function failureReportText(
1409
+ sentence: string,
1410
+ controls: readonly string[],
1411
+ ): string {
1412
+ return [
1413
+ `Failure: ${sentence}`,
1414
+ 'Controls:',
1415
+ ...(controls.length === 0 ? ['- none'] : controls),
1416
+ ].join('\n');
1417
+ }
1418
+
1206
1419
  function appendMandatoryPresentationSuffix(
1207
1420
  turn: ActiveTurn,
1208
1421
  suffix: string,
@@ -2374,16 +2587,23 @@ export function assertPlaybookCaptainShellSnapshot(
2374
2587
  });
2375
2588
  }
2376
2589
  let normalizedLastError:
2377
- | { readonly name: string; readonly message: string }
2590
+ | {
2591
+ readonly name: string;
2592
+ readonly message: string;
2593
+ readonly cause?: PlaybookFailureCause;
2594
+ }
2378
2595
  | undefined;
2379
2596
  if (snapshot.lastError !== undefined) {
2380
2597
  const error = snapshotRecord(
2381
2598
  snapshot.lastError,
2382
2599
  'Captain shell snapshot.lastError',
2383
2600
  );
2601
+ // DR-063 §2: the cause enters the stored record inside `lastError`, so a
2602
+ // parked failure explains itself after a restart. An unrecognized shape is
2603
+ // not a cause and the closed validator refuses the snapshot.
2384
2604
  rejectSnapshotKeys(
2385
2605
  error,
2386
- ['name', 'message'],
2606
+ ['name', 'message', 'cause'],
2387
2607
  'Captain shell snapshot.lastError',
2388
2608
  );
2389
2609
  normalizedLastError = {
@@ -2397,6 +2617,9 @@ export function assertPlaybookCaptainShellSnapshot(
2397
2617
  'Captain shell snapshot.lastError.message',
2398
2618
  true,
2399
2619
  ),
2620
+ ...(error.cause === undefined
2621
+ ? {}
2622
+ : { cause: assertPlaybookFailureCause(error.cause) }),
2400
2623
  };
2401
2624
  }
2402
2625
  const activePlaybooks = new Set<string>();
@@ -3083,7 +3306,9 @@ export function createPlaybookCaptainShell(
3083
3306
  | PlaybookCaptainRetainedEffectReconciliation
3084
3307
  | undefined;
3085
3308
  let pendingBossQuestions: unknown;
3086
- let lastError: { name: string; message: string } | undefined;
3309
+ let lastError:
3310
+ | { name: string; message: string; cause?: PlaybookFailureCause }
3311
+ | undefined;
3087
3312
  let activeTurnSummary: ActiveTurnSummary | undefined;
3088
3313
  let activeTurnHostCalls: Set<Promise<unknown>> | undefined;
3089
3314
  const issuedSessionIds = new Set<string>();
@@ -3351,6 +3576,15 @@ export function createPlaybookCaptainShell(
3351
3576
  original.projection,
3352
3577
  after.projection,
3353
3578
  );
3579
+ // DR-062 §3: a pure projection never downgrades a classification the
3580
+ // physical receipt proved. The latest receipt already accounted for every
3581
+ // pre-existing entry of its own baseline, so when that baseline is the
3582
+ // original one its accounting is the operation's accounting, and the
3583
+ // projection may not demand a byte-equal after projection on top of it.
3584
+ const receiptAccountsFromOriginal = isDeepStrictEqual(
3585
+ receipt.baseline.projection,
3586
+ original.projection,
3587
+ );
3354
3588
  if (after.head === original.head) {
3355
3589
  if (
3356
3590
  receipt.classification !== 'unchanged' &&
@@ -3369,9 +3603,12 @@ export function createPlaybookCaptainShell(
3369
3603
  isDeepStrictEqual(entry, after.projection[path]),
3370
3604
  );
3371
3605
  return {
3372
- classification: preservesOriginal
3373
- ? 'worktree-only-change'
3374
- : 'observation-ambiguous',
3606
+ classification:
3607
+ preservesOriginal ||
3608
+ (receipt.classification === 'worktree-only-change' &&
3609
+ receiptAccountsFromOriginal)
3610
+ ? 'worktree-only-change'
3611
+ : 'observation-ambiguous',
3375
3612
  baselineHead: original.head,
3376
3613
  afterHead: after.head,
3377
3614
  };
@@ -3379,7 +3616,7 @@ export function createPlaybookCaptainShell(
3379
3616
 
3380
3617
  if (
3381
3618
  receipt.classification === 'one-descendant-commit' &&
3382
- sameProjection
3619
+ (sameProjection || receiptAccountsFromOriginal)
3383
3620
  ) {
3384
3621
  return {
3385
3622
  classification: 'one-descendant-commit',
@@ -3523,6 +3760,44 @@ export function createPlaybookCaptainShell(
3523
3760
  return frozen;
3524
3761
  };
3525
3762
 
3763
+ /**
3764
+ * DR-062 §5: one deterministic Boss-visible report, once per turn, for the
3765
+ * pre-existing changes the commits completed during this turn carried, and
3766
+ * the same information as settlement facts the closing-reply prompt reads.
3767
+ * It supplements the unresolved-effect report and enters no run result.
3768
+ */
3769
+ const reportCarriedPreExistingChanges = (turn: ActiveTurn): void => {
3770
+ if (turn.carriedPreExistingReported) return;
3771
+ if (turn.effectBoundaryPrefixCount === undefined) return;
3772
+ let carried: readonly CarriedPreExistingChanges[];
3773
+ try {
3774
+ carried = carriedPreExistingChanges(
3775
+ assertPlaybookEffectLedger(currentEffectLedger()),
3776
+ turn.effectBoundaryPrefixCount,
3777
+ );
3778
+ } catch {
3779
+ return;
3780
+ }
3781
+ const report = carriedPreExistingReport(carried);
3782
+ if (report === undefined) return;
3783
+ turn.carriedPreExistingReported = true;
3784
+ appendMandatoryPresentationSuffix(turn, report);
3785
+ const facts = carriedPreExistingFacts(carried);
3786
+ turn.settlementFacts.push(...facts);
3787
+ // The action's own report was assembled from the facts as they stood when
3788
+ // it settled; the result-phase prompt reads this report, so the same facts
3789
+ // join it here rather than only the turn's running list.
3790
+ if (turn.report !== undefined) {
3791
+ turn.report = {
3792
+ ...turn.report,
3793
+ facts: [...turn.report.facts, ...facts],
3794
+ ...(turn.report.bossFacts === undefined
3795
+ ? {}
3796
+ : { bossFacts: [...turn.report.bossFacts, ...facts] }),
3797
+ };
3798
+ }
3799
+ };
3800
+
3526
3801
  const normalizeInstalledRetainedGenerations = (
3527
3802
  value: Readonly<Record<string, PlaybookCaptainRetainedGeneration>>,
3528
3803
  ): Map<string, PlaybookCaptainRetainedGeneration> => {
@@ -5766,6 +6041,30 @@ export function createPlaybookCaptainShell(
5766
6041
  ];
5767
6042
  };
5768
6043
 
6044
+ // DR-063 §3: every advertised action names its standing in the digest, so a
6045
+ // model is never invited to select an action that changes nothing.
6046
+ const advertisedActionDigest = (
6047
+ actions: readonly PlaybookControlAction[],
6048
+ ): string => {
6049
+ if (actions.length === 0) return 'Advertised actions: none.';
6050
+ const lines = [
6051
+ 'Advertised actions:',
6052
+ ...actions.map((action) =>
6053
+ (action.standing ?? 'ready') === 'ready'
6054
+ ? digestLine`- ${action.id}: ${action.label}`
6055
+ : digestLine`- ${action.id}: ${action.label} (${action.standing!}: ${
6056
+ action.reason ?? 'no reason was published'
6057
+ })`,
6058
+ ),
6059
+ ];
6060
+ if (actions.some(({ standing }) => standing !== undefined && standing !== 'ready')) {
6061
+ lines.push(
6062
+ 'An action marked no-op runs and changes nothing; a blocked action cannot run at all.',
6063
+ );
6064
+ }
6065
+ return lines.join('\n');
6066
+ };
6067
+
5769
6068
  const retainedResumptionDigest = (): string => {
5770
6069
  if (rootFrame() !== undefined) {
5771
6070
  return 'Retained resumptions: unavailable while a playbook is engaged.';
@@ -5798,10 +6097,7 @@ export function createPlaybookCaptainShell(
5798
6097
  }
5799
6098
  refreshRetainedEffectFence();
5800
6099
  if (retainedEffectReconciliation !== undefined) {
5801
- let reconciliationActions: readonly {
5802
- readonly id: string;
5803
- readonly label: string;
5804
- }[] = [];
6100
+ let reconciliationActions: readonly PlaybookControlAction[] = [];
5805
6101
  if (
5806
6102
  typeof leaf.runtime.describe === 'function' &&
5807
6103
  typeof leaf.runtime.apply === 'function'
@@ -5825,16 +6121,7 @@ export function createPlaybookCaptainShell(
5825
6121
  `Leaf ${frameLabel(leaf)} is parked for repository-effect reconciliation.`,
5826
6122
  );
5827
6123
  lines.push('Pending Boss questions: withheld until reconciliation.');
5828
- lines.push(
5829
- reconciliationActions.length === 0
5830
- ? 'Advertised actions: none.'
5831
- : [
5832
- 'Advertised actions:',
5833
- ...reconciliationActions.map(
5834
- (action) => digestLine`- ${action.id}: ${action.label}`,
5835
- ),
5836
- ].join('\n'),
5837
- );
6124
+ lines.push(advertisedActionDigest(reconciliationActions));
5838
6125
  lines.push(
5839
6126
  'Ordinary delivery, switching, dismissal, and runtime actions are unavailable while retained effect evidence is unresolved. Only the advertised unresolved-effect controls may run. Conversation is unaffected: `respond` stays valid for any turn.',
5840
6127
  );
@@ -5939,16 +6226,7 @@ export function createPlaybookCaptainShell(
5939
6226
  })}`,
5940
6227
  );
5941
6228
  }
5942
- lines.push(
5943
- view.actions.length === 0
5944
- ? 'Advertised actions: none.'
5945
- : [
5946
- 'Advertised actions:',
5947
- ...view.actions.map(
5948
- (action) => digestLine`- ${action.id}: ${action.label}`,
5949
- ),
5950
- ].join('\n'),
5951
- );
6229
+ lines.push(advertisedActionDigest(view.actions));
5952
6230
  lines.push(retainedResumptionDigest());
5953
6231
  return lines.join('\n');
5954
6232
  };
@@ -5989,7 +6267,17 @@ export function createPlaybookCaptainShell(
5989
6267
  action.id === UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID ||
5990
6268
  action.id === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID,
5991
6269
  )
5992
- .map((action) => Object.freeze({ id: action.id, label: action.label })),
6270
+ // DR-063 §3: the leaf's own standing is published with the pair, so a
6271
+ // host drawing the control says what running it would do. A leaf that
6272
+ // publishes none advertises `ready`, which is what it always meant.
6273
+ .map((action) =>
6274
+ Object.freeze({
6275
+ id: action.id,
6276
+ label: action.label,
6277
+ standing: action.standing ?? 'ready',
6278
+ ...(action.reason === undefined ? {} : { reason: action.reason }),
6279
+ }),
6280
+ ),
5993
6281
  );
5994
6282
  };
5995
6283
 
@@ -6016,6 +6304,9 @@ export function createPlaybookCaptainShell(
6016
6304
  // way the Boss started it rather than the frame that happens to be
6017
6305
  // parked inside it.
6018
6306
  label: `Stop ${frameLabel(root)}`,
6307
+ // DR-063 §3: giving up always ends the engagement, whatever the leaf
6308
+ // is parked on.
6309
+ standing: 'ready' as const,
6019
6310
  }),
6020
6311
  ]);
6021
6312
  };
@@ -7186,6 +7477,123 @@ export function createPlaybookCaptainShell(
7186
7477
  );
7187
7478
  };
7188
7479
 
7480
+ // DR-063 §4: the leaf's failure as the shell reads it — the cause the runtime
7481
+ // decided, or, behind the retained-effect fence where the leaf itself holds
7482
+ // no error, the incomplete receipt the frozen evidence names.
7483
+ const leafFailureCause = (
7484
+ view: PlaybookControlView | undefined,
7485
+ fenced: boolean,
7486
+ unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[],
7487
+ ): PlaybookFailureCause | undefined => {
7488
+ const recorded = view?.lastError ?? lastError;
7489
+ if (recorded?.cause !== undefined) {
7490
+ try {
7491
+ return assertPlaybookFailureCause(recorded.cause);
7492
+ } catch {
7493
+ // A cause the closed validator refuses is not a cause.
7494
+ }
7495
+ }
7496
+ if (fenced) {
7497
+ const incomplete = unresolvedEffects.find(
7498
+ ({ classification }) => classification === 'incomplete',
7499
+ );
7500
+ if (incomplete !== undefined) {
7501
+ return assertPlaybookFailureCause({
7502
+ code: 'receipt-missing',
7503
+ evidence: { baselineHead: incomplete.baselineHead },
7504
+ });
7505
+ }
7506
+ }
7507
+ // There is no failure without a cause: a parked failure whose error
7508
+ // carries none is a runtime defect named by that error.
7509
+ if (view?.state.stateId === 'failed' && recorded !== undefined) {
7510
+ return assertPlaybookFailureCause({
7511
+ code: 'runtime-defect',
7512
+ evidence: {
7513
+ reason:
7514
+ recorded.message.trim().length > 0
7515
+ ? recorded.message
7516
+ : recorded.name,
7517
+ },
7518
+ });
7519
+ }
7520
+ return undefined;
7521
+ };
7522
+
7523
+ const failureControlLines = (
7524
+ view: PlaybookControlView | undefined,
7525
+ fenced: boolean,
7526
+ ): readonly string[] => {
7527
+ const runtimeActions = (view?.actions ?? []).filter(
7528
+ (action) =>
7529
+ !fenced ||
7530
+ action.id === UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID ||
7531
+ action.id === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID,
7532
+ );
7533
+ const root = rootFrame();
7534
+ return [
7535
+ ...runtimeActions.map((action) => failureControlLine(action)),
7536
+ // The shell's own control is published beside the leaf's, so the report
7537
+ // names every door out of this failure (CAPTAIN-62).
7538
+ ...(root === undefined
7539
+ ? []
7540
+ : [
7541
+ failureControlLine({
7542
+ label: `Stop ${frameLabel(root)}`,
7543
+ standing: 'ready',
7544
+ }),
7545
+ ]),
7546
+ ];
7547
+ };
7548
+
7549
+ const describeNestedPlaybook = (playbookId: string): string => {
7550
+ const enablement = enablementById.get(playbookId);
7551
+ return enablement === undefined ? playbookId : `/${enablement.command}`;
7552
+ };
7553
+
7554
+ // DR-063 §4: appended once, beside the unresolved-effect and pre-existing
7555
+ // change reports, whenever the turn settles with the leaf parked in its
7556
+ // failure state or behind the retained-effect fence.
7557
+ const reportLeafFailure = (
7558
+ turn: ActiveTurn,
7559
+ unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[],
7560
+ ): void => {
7561
+ if (turn.failureReported) return;
7562
+ const leaf = leafFrame();
7563
+ if (!leaf) return;
7564
+ const fenced = retainedEffectReconciliation !== undefined;
7565
+ let view: PlaybookControlView | undefined;
7566
+ if (typeof leaf.runtime.describe === 'function') {
7567
+ try {
7568
+ view = leaf.runtime.describe();
7569
+ } catch {
7570
+ // An unreadable control view leaves the mirrored error as evidence.
7571
+ }
7572
+ }
7573
+ if (!fenced && view?.state.stateId !== 'failed') return;
7574
+ const cause = leafFailureCause(view, fenced, unresolvedEffects);
7575
+ if (cause === undefined) return;
7576
+ turn.failureReported = true;
7577
+ const sentence = failureCauseSentence(cause, describeNestedPlaybook);
7578
+ appendMandatoryPresentationSuffix(
7579
+ turn,
7580
+ failureReportText(sentence, failureControlLines(view, fenced)),
7581
+ );
7582
+ // The result-phase prompt reads the turn's report, so the same fact joins
7583
+ // it here rather than only the turn's running list.
7584
+ const fact = `The workflow failed: ${sentence}`;
7585
+ turn.settlementFacts.push(fact);
7586
+ if (turn.report !== undefined) {
7587
+ turn.report = {
7588
+ ...turn.report,
7589
+ facts: [...turn.report.facts, fact],
7590
+ ...(turn.report.bossFacts === undefined
7591
+ ? {}
7592
+ : { bossFacts: [...turn.report.bossFacts, fact] }),
7593
+ };
7594
+ }
7595
+ };
7596
+
7189
7597
  const settleSelection = async (
7190
7598
  selection: CaptainControllerSelection,
7191
7599
  signal: AbortSignal,
@@ -7202,6 +7610,10 @@ export function createPlaybookCaptainShell(
7202
7610
  if (turn !== undefined && report !== undefined) {
7203
7611
  appendMandatoryPresentationSuffix(turn, report);
7204
7612
  }
7613
+ if (turn !== undefined) reportCarriedPreExistingChanges(turn);
7614
+ if (turn !== undefined) {
7615
+ reportLeafFailure(turn, frozenUnresolvedEffects);
7616
+ }
7205
7617
  return frozenUnresolvedEffects;
7206
7618
  };
7207
7619
  const finalizeSettlement = (
@@ -9012,12 +9424,25 @@ export function createPlaybookCaptainShell(
9012
9424
  const hostGiveUp = decided?.kind === 'give-up' ? true : undefined;
9013
9425
  const parsed =
9014
9426
  decided === undefined ? resolveCommandTurn(turn.prompt) : undefined;
9427
+ // DR-062 §5: the boundary count this turn starts from. Every boundary
9428
+ // beyond it completed during this turn.
9429
+ let effectBoundaryPrefixCount: number | undefined;
9430
+ try {
9431
+ effectBoundaryPrefixCount = assertPlaybookEffectLedger(
9432
+ currentEffectLedger(),
9433
+ ).boundaries.length;
9434
+ } catch {
9435
+ effectBoundaryPrefixCount = undefined;
9436
+ }
9015
9437
  activeTurn = {
9016
9438
  id: ++turnSequence,
9017
9439
  captainSyncedJournalSeq: journalSeq,
9018
9440
  bossText: turn.prompt,
9019
9441
  authoritativeText: parsed?.authoritativeText ?? turn.prompt,
9020
9442
  ...(parsed ? { resolution: parsed.resolution } : {}),
9443
+ ...(effectBoundaryPrefixCount === undefined
9444
+ ? {}
9445
+ : { effectBoundaryPrefixCount }),
9021
9446
  ...(hostRuntimeActionId === undefined ? {} : { hostRuntimeActionId }),
9022
9447
  ...(hostGiveUp === undefined ? {} : { hostGiveUp }),
9023
9448
  settled: false,
@@ -72,7 +72,9 @@ export interface SessionHostController {
72
72
  /**
73
73
  * The active leaf's currently advertised runtime actions, read live between
74
74
  * turns; empty whenever nothing is advertised, a turn is active, or the
75
- * leaf's control view cannot be read.
75
+ * leaf's control view cannot be read. Each pair carries the leaf's own
76
+ * `standing` and its `reason`, so a host never draws a control without
77
+ * saying what running it would do (DR-063).
76
78
  */
77
79
  listRuntimeActions(): readonly PlaybookControlAction[];
78
80
  /**
@@ -83,8 +85,9 @@ export interface SessionHostController {
83
85
  submitRuntimeAction(actionId: string): Promise<SessionRecovery>;
84
86
  /**
85
87
  * The controls the shell offers on its own behalf, read live between turns;
86
- * today one give-up while a root is engaged, and empty while idle or while a
87
- * turn is active. Disjoint from `listRuntimeActions`, which reads the leaf.
88
+ * today one `ready` give-up while a root is engaged, and empty while idle or
89
+ * while a turn is active. Disjoint from `listRuntimeActions`, which reads the
90
+ * leaf.
88
91
  */
89
92
  listShellActions(): readonly PlaybookControlAction[];
90
93
  /**
@@ -214,7 +214,7 @@ export declare const decideMachine: import("xstate").StateMachine<DecideContext,
214
214
  } | {
215
215
  type: "validReviewSuccess";
216
216
  params: unknown;
217
- }, never, "done" | "failed" | "awaitBossReply" | "ready" | "reportedReviewFailure" | "commitCoderProposal" | "reviewCommit" | {
217
+ }, never, "done" | "failed" | "ready" | "awaitBossReply" | "reportedReviewFailure" | "commitCoderProposal" | "reviewCommit" | {
218
218
  independentProposals: {
219
219
  coder: "complete" | "working" | "waiting";
220
220
  reviewer: "complete" | "working" | "waiting";