@sublang/playbook 13.3.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 (56) hide show
  1. package/README.md +5 -5
  2. package/docs/cli.md +14 -14
  3. package/docs/configuration.md +10 -2
  4. package/docs/embedding.md +20 -1
  5. package/package.json +6 -1
  6. package/reference/sdlc/branch.playbook/branch.fsm.d.ts +1 -1
  7. package/reference/sdlc/code.playbook/bin/launch-config.js +88 -5
  8. package/reference/sdlc/code.playbook/bin/playbook.js +4 -0
  9. package/reference/sdlc/code.playbook/bin/repository-effects.js +198 -17
  10. package/reference/sdlc/code.playbook/bin/run.js +4 -0
  11. package/reference/sdlc/code.playbook/bin/session-store.js +2 -2
  12. package/reference/sdlc/code.playbook/code.fsm.d.ts +1 -1
  13. package/reference/sdlc/code.playbook/host-capabilities.d.ts +6 -0
  14. package/reference/sdlc/code.playbook/playbook-captain.d.ts +3 -1
  15. package/reference/sdlc/code.playbook/playbook-captain.js +374 -20
  16. package/reference/sdlc/code.playbook/playbook-captain.ts +478 -38
  17. package/reference/sdlc/code.playbook/session-host.d.ts +6 -3
  18. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +1 -1
  19. package/reference/sdlc/decide.playbook/decide.playbook.js +25 -1
  20. package/reference/sdlc/decide.playbook/decide.playbook.ts +35 -2
  21. package/reference/sdlc/dev.md +15 -8
  22. package/reference/sdlc/dev.playbook/dev.fsm.d.ts +1 -1
  23. package/reference/sdlc/dev.playbook/dev.fsm.js +18 -12
  24. package/reference/sdlc/dev.playbook/dev.fsm.ts +18 -13
  25. package/reference/sdlc/dev.playbook/dev.gears.md +19 -12
  26. package/reference/sdlc/dev.playbook/dev.playbook.d.ts +3 -10
  27. package/reference/sdlc/dev.playbook/dev.playbook.js +6 -6
  28. package/reference/sdlc/dev.playbook/dev.playbook.ts +8 -9
  29. package/reference/sdlc/dev.playbook/dev.registry.js +1 -1
  30. package/reference/sdlc/dev.playbook/dev.registry.ts +1 -1
  31. package/reference/sdlc/pr.md +17 -4
  32. package/reference/sdlc/pr.playbook/pr.fsm.d.ts +18 -4
  33. package/reference/sdlc/pr.playbook/pr.fsm.js +49 -18
  34. package/reference/sdlc/pr.playbook/pr.fsm.ts +63 -20
  35. package/reference/sdlc/pr.playbook/pr.gears.md +20 -7
  36. package/reference/sdlc/pr.playbook/pr.playbook.js +2 -1
  37. package/reference/sdlc/pr.playbook/pr.playbook.ts +2 -1
  38. package/reference/sdlc/review.playbook/review.fsm.d.ts +1 -1
  39. package/slc/experiments/fsm-scaffold-guidance.md +31 -0
  40. package/slc/gears2fsm.md +145 -58
  41. package/slc/link.md +190 -7
  42. package/slc/materialize-link.mjs +460 -0
  43. package/slc/optimize.md +46 -6
  44. package/slc/scaffold-fsm.mjs +225 -0
  45. package/slc/slc.pin-inputs.json +13 -0
  46. package/slc/text2gears.md +38 -3
  47. package/slc/workflow-contracts.json +371 -0
  48. package/src/runtime.d.ts +70 -0
  49. package/src/runtime.js +232 -1
  50. package/src/runtime.ts +358 -1
  51. package/src/xstate-playbook-runtime.d.ts +9 -10
  52. package/src/xstate-playbook-runtime.js +407 -21
  53. package/src/xstate-playbook-runtime.ts +514 -20
  54. package/src/xstate-runtime.d.ts +9 -1
  55. package/src/xstate-runtime.js +299 -19
  56. 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> => {
@@ -5229,6 +5504,7 @@ export function createPlaybookCaptainShell(
5229
5504
  const settleUnresolvedEffectAbandonment = async (
5230
5505
  unresolvedLeaf: EngagementFrame,
5231
5506
  ): Promise<void> => {
5507
+ abandonmentSettlementUnsafe = true;
5232
5508
  if (leafFrame() !== unresolvedLeaf) {
5233
5509
  throw new Error(
5234
5510
  'unresolved-effect abandonment requires the active leaf',
@@ -5263,6 +5539,7 @@ export function createPlaybookCaptainShell(
5263
5539
  rootPlaybookId,
5264
5540
  });
5265
5541
  await runEffect(() => unresolvedEffectSettlement.complete(settlement));
5542
+ abandonmentSettlementUnsafe = false;
5266
5543
  };
5267
5544
 
5268
5545
  const callResultFor = (
@@ -5764,6 +6041,30 @@ export function createPlaybookCaptainShell(
5764
6041
  ];
5765
6042
  };
5766
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
+
5767
6068
  const retainedResumptionDigest = (): string => {
5768
6069
  if (rootFrame() !== undefined) {
5769
6070
  return 'Retained resumptions: unavailable while a playbook is engaged.';
@@ -5796,10 +6097,7 @@ export function createPlaybookCaptainShell(
5796
6097
  }
5797
6098
  refreshRetainedEffectFence();
5798
6099
  if (retainedEffectReconciliation !== undefined) {
5799
- let reconciliationActions: readonly {
5800
- readonly id: string;
5801
- readonly label: string;
5802
- }[] = [];
6100
+ let reconciliationActions: readonly PlaybookControlAction[] = [];
5803
6101
  if (
5804
6102
  typeof leaf.runtime.describe === 'function' &&
5805
6103
  typeof leaf.runtime.apply === 'function'
@@ -5823,16 +6121,7 @@ export function createPlaybookCaptainShell(
5823
6121
  `Leaf ${frameLabel(leaf)} is parked for repository-effect reconciliation.`,
5824
6122
  );
5825
6123
  lines.push('Pending Boss questions: withheld until reconciliation.');
5826
- lines.push(
5827
- reconciliationActions.length === 0
5828
- ? 'Advertised actions: none.'
5829
- : [
5830
- 'Advertised actions:',
5831
- ...reconciliationActions.map(
5832
- (action) => digestLine`- ${action.id}: ${action.label}`,
5833
- ),
5834
- ].join('\n'),
5835
- );
6124
+ lines.push(advertisedActionDigest(reconciliationActions));
5836
6125
  lines.push(
5837
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.',
5838
6127
  );
@@ -5937,16 +6226,7 @@ export function createPlaybookCaptainShell(
5937
6226
  })}`,
5938
6227
  );
5939
6228
  }
5940
- lines.push(
5941
- view.actions.length === 0
5942
- ? 'Advertised actions: none.'
5943
- : [
5944
- 'Advertised actions:',
5945
- ...view.actions.map(
5946
- (action) => digestLine`- ${action.id}: ${action.label}`,
5947
- ),
5948
- ].join('\n'),
5949
- );
6229
+ lines.push(advertisedActionDigest(view.actions));
5950
6230
  lines.push(retainedResumptionDigest());
5951
6231
  return lines.join('\n');
5952
6232
  };
@@ -5987,7 +6267,17 @@ export function createPlaybookCaptainShell(
5987
6267
  action.id === UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID ||
5988
6268
  action.id === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID,
5989
6269
  )
5990
- .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
+ ),
5991
6281
  );
5992
6282
  };
5993
6283
 
@@ -6014,6 +6304,9 @@ export function createPlaybookCaptainShell(
6014
6304
  // way the Boss started it rather than the frame that happens to be
6015
6305
  // parked inside it.
6016
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,
6017
6310
  }),
6018
6311
  ]);
6019
6312
  };
@@ -6913,6 +7206,14 @@ export function createPlaybookCaptainShell(
6913
7206
  const root = rootFrame();
6914
7207
  if (!root) return false;
6915
7208
  const label = frameLabel(root);
7209
+ if (
7210
+ activeTurn?.hostGiveUp === true &&
7211
+ freezeTurnUnresolvedEffects().length > 0
7212
+ ) {
7213
+ await settleUnresolvedEffectAbandonment(leafFrame()!);
7214
+ facts.push(`Dismissed the ${label} engagement with unresolved repository effects reported.`);
7215
+ return false;
7216
+ }
6916
7217
  // Dismissal leaves the procedure unfinished. Persist the latest safe
6917
7218
  // generation captured for this turn before disposal erases the frames —
6918
7219
  // unless the Boss gave up on it (CAPTAIN-44), in which case retaining it
@@ -7176,6 +7477,123 @@ export function createPlaybookCaptainShell(
7176
7477
  );
7177
7478
  };
7178
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
+
7179
7597
  const settleSelection = async (
7180
7598
  selection: CaptainControllerSelection,
7181
7599
  signal: AbortSignal,
@@ -7192,6 +7610,10 @@ export function createPlaybookCaptainShell(
7192
7610
  if (turn !== undefined && report !== undefined) {
7193
7611
  appendMandatoryPresentationSuffix(turn, report);
7194
7612
  }
7613
+ if (turn !== undefined) reportCarriedPreExistingChanges(turn);
7614
+ if (turn !== undefined) {
7615
+ reportLeafFailure(turn, frozenUnresolvedEffects);
7616
+ }
7195
7617
  return frozenUnresolvedEffects;
7196
7618
  };
7197
7619
  const finalizeSettlement = (
@@ -7368,7 +7790,8 @@ export function createPlaybookCaptainShell(
7368
7790
  fencedLeaf !== undefined;
7369
7791
  if (
7370
7792
  retainedEffectReconciliation !== undefined &&
7371
- !routesRetainedReconciliation
7793
+ !routesRetainedReconciliation &&
7794
+ !(selection.action === 'dismiss' && turn.hostGiveUp === true)
7372
7795
  ) {
7373
7796
  return rejectSelection(
7374
7797
  selection,
@@ -7726,7 +8149,6 @@ export function createPlaybookCaptainShell(
7726
8149
  try {
7727
8150
  await settleUnresolvedEffectAbandonment(leaf);
7728
8151
  } catch (error) {
7729
- abandonmentSettlementUnsafe = true;
7730
8152
  const normalized = normalizeErrorCompact(error) ?? {
7731
8153
  name: 'Error',
7732
8154
  message: String(error),
@@ -7852,8 +8274,13 @@ export function createPlaybookCaptainShell(
7852
8274
  const giveUpReplyText = (): string => {
7853
8275
  const report = activeTurn?.report;
7854
8276
  const facts = report?.bossFacts ?? report?.facts ?? [];
7855
- const opening = 'Stopped that workflow. I will not resume it.';
7856
- const closing = 'Send the command again to start fresh work.';
8277
+ const stopped = report?.status === 'ok';
8278
+ const opening = stopped
8279
+ ? 'Stopped that workflow. I will not resume it.'
8280
+ : 'I could not confirm that the workflow was stopped.';
8281
+ const closing = stopped
8282
+ ? 'Send the command again to start fresh work.'
8283
+ : 'The stop did not settle successfully; recover the session before continuing.';
7857
8284
  const composed = [
7858
8285
  opening,
7859
8286
  ...(facts.length === 0
@@ -8997,12 +9424,25 @@ export function createPlaybookCaptainShell(
8997
9424
  const hostGiveUp = decided?.kind === 'give-up' ? true : undefined;
8998
9425
  const parsed =
8999
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
+ }
9000
9437
  activeTurn = {
9001
9438
  id: ++turnSequence,
9002
9439
  captainSyncedJournalSeq: journalSeq,
9003
9440
  bossText: turn.prompt,
9004
9441
  authoritativeText: parsed?.authoritativeText ?? turn.prompt,
9005
9442
  ...(parsed ? { resolution: parsed.resolution } : {}),
9443
+ ...(effectBoundaryPrefixCount === undefined
9444
+ ? {}
9445
+ : { effectBoundaryPrefixCount }),
9006
9446
  ...(hostRuntimeActionId === undefined ? {} : { hostRuntimeActionId }),
9007
9447
  ...(hostGiveUp === undefined ? {} : { hostGiveUp }),
9008
9448
  settled: false,