@sublang/playbook 17.8.1 → 17.10.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.
@@ -2,6 +2,7 @@
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
3
 
4
4
  import { randomUUID } from 'node:crypto';
5
+ import { posix, win32 } from 'node:path';
5
6
  import { isDeepStrictEqual } from 'node:util';
6
7
  import PQueue from 'p-queue';
7
8
 
@@ -69,6 +70,7 @@ import { QUOTED_EVIDENCE_LIMIT, workerReportExcerpt, workerReportBlock } from '.
69
70
  interface SessionAgent {
70
71
  /** Mutable next-turn execution capability; never structural identity. */
71
72
  readonly browser?: boolean;
73
+ readonly skills?: readonly string[];
72
74
  readonly adapter: string;
73
75
  readonly model: TuningSelection;
74
76
  readonly effort: TuningSelection<Effort>;
@@ -127,7 +129,7 @@ interface PlaybookCaptainUnresolvedEffectSettlementInput {
127
129
  readonly unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[];
128
130
  }
129
131
 
130
- type SnapshotAgentEnvelope = DeepReadonly<Omit<SessionAgent, TuningKey | 'browser'>>;
132
+ type SnapshotAgentEnvelope = DeepReadonly<Omit<SessionAgent, TuningKey | 'browser' | 'skills'>>;
131
133
 
132
134
  type PlayerLedgerSnapshotEntry = DeepReadonly<PlayerLedgerEntry>;
133
135
 
@@ -1414,6 +1416,31 @@ function boundedPathList(paths: readonly string[]): string {
1414
1416
  : printed.join(', ');
1415
1417
  }
1416
1418
 
1419
+ // DR-090 §5: the report lines the shell appends to a reply carry marks and
1420
+ // identifiers, never an English sentence, so a reply in any language reads
1421
+ // them as structure. The settlement facts a model reads keep their words.
1422
+ const MARK_DONE = '✓';
1423
+ const MARK_FAILED = '✕';
1424
+ const MARK_TO = '⟶';
1425
+ const MARK_BRANCH = '⎇';
1426
+ const MARK_SEP = '·';
1427
+ const MARK_READY = '▸';
1428
+ const MARK_NOT_READY = '▹';
1429
+
1430
+ /** At most 24 items, then `+<n>` for the rest (DR-090 §5). */
1431
+ function markedList(items: readonly string[], separator = ', ', more = 0): string {
1432
+ const printed = items.slice(0, CARRIED_CHANGE_PATH_LIMIT);
1433
+ const omitted = items.length - printed.length + more;
1434
+ if (printed.length === 0) return omitted > 0 ? `+${omitted}` : '';
1435
+ return omitted > 0
1436
+ ? `${printed.join(separator)}${separator}+${omitted}`
1437
+ : printed.join(separator);
1438
+ }
1439
+
1440
+ function markedBranch(branch: string | undefined): string {
1441
+ return branch === undefined ? '' : ` ${MARK_BRANCH} ${branch}`;
1442
+ }
1443
+
1417
1444
  /**
1418
1445
  * The carried pre-existing changes of the boundaries completed after
1419
1446
  * `boundaryPrefixCount`, one entry per commit OID in boundary sequence order.
@@ -1499,6 +1526,14 @@ interface CommitBeforeFailure {
1499
1526
  readonly commitOid: string;
1500
1527
  /** Every new commit, oldest first, where the step made more than one (DR-088). */
1501
1528
  readonly commits?: readonly string[];
1529
+ /** The branch the commits landed on, where the receipt names one (DR-090 §4). */
1530
+ readonly branch?: string;
1531
+ }
1532
+
1533
+ /** DR-090 §4: where a step's commits landed, as its receipt names it. */
1534
+ function onBranch(receipt: PlaybookRepositoryReceipt): string {
1535
+ const branch = receipt.after?.branch;
1536
+ return branch === undefined ? '' : ` on branch ${branch}`;
1502
1537
  }
1503
1538
 
1504
1539
  function commitsBeforeFailure(
@@ -1534,6 +1569,9 @@ function commitsBeforeFailure(
1534
1569
  baselineHead: receipt.baseline.head,
1535
1570
  commitOid: receipt.commitOid,
1536
1571
  ...(receipt.commits === undefined ? {} : { commits: receipt.commits }),
1572
+ ...(receipt.after?.branch === undefined
1573
+ ? {}
1574
+ : { branch: receipt.after.branch }),
1537
1575
  });
1538
1576
  }
1539
1577
  return commits;
@@ -1544,29 +1582,35 @@ function commitBeforeFailureClause({
1544
1582
  baselineHead,
1545
1583
  commitOid,
1546
1584
  commits,
1585
+ branch,
1547
1586
  }: CommitBeforeFailure): string {
1548
1587
  const made =
1549
1588
  commits === undefined
1550
1589
  ? 'made one new commit'
1551
1590
  : `made ${commits.length} new commits, oldest first: ${boundedPathList(commits)}`;
1552
- return `${made}; HEAD moved from ${baselineHead} to the proven commit ${commitOid}.`;
1591
+ const landed = branch === undefined ? '' : ` on branch ${branch}`;
1592
+ return `${made}; HEAD moved from ${baselineHead} to the proven commit ${commitOid}${landed}.`;
1553
1593
  }
1554
1594
 
1555
1595
  function commitBeforeFailureFact(commit: CommitBeforeFailure): string {
1556
1596
  return `Before the failure, a step ${commitBeforeFailureClause(commit)}`;
1557
1597
  }
1558
1598
 
1599
+ // DR-090 §5: one marked line per receipt, `✓ <baselineHead> ⟶ <commits>`
1600
+ // and the branch they landed on.
1559
1601
  function commitsBeforeFailureReport(
1560
1602
  commits: readonly CommitBeforeFailure[],
1561
1603
  ): string | undefined {
1562
1604
  if (commits.length === 0) return undefined;
1563
- return [
1564
- 'Repository changes made before the failure:',
1565
- ...commits.map(
1566
- (commit) => `- A step ${commitBeforeFailureClause(commit)}`,
1567
- ),
1568
- 'The failure does not undo these commits.',
1569
- ].join('\n');
1605
+ return commits
1606
+ .map(
1607
+ ({ baselineHead, commitOid, commits: listed, branch }) =>
1608
+ `${MARK_DONE} ${baselineHead} ${MARK_TO} ${markedList(
1609
+ listed ?? [commitOid],
1610
+ ` ${MARK_SEP} `,
1611
+ )}${markedBranch(branch)}`,
1612
+ )
1613
+ .join('\n');
1570
1614
  }
1571
1615
 
1572
1616
  /**
@@ -1583,19 +1627,29 @@ function finalTextNamesCommit(finalText: string, oid: string): boolean {
1583
1627
  return false;
1584
1628
  }
1585
1629
 
1630
+ /** One step's commit statement and the reply's marked line for it (DR-088 §3, DR-090 §4, §5). */
1631
+ interface StepCommitStatement {
1632
+ readonly text: string;
1633
+ /** The marked line appended to the presented reply, where one is. */
1634
+ readonly shown?: string;
1635
+ }
1636
+
1586
1637
  /**
1587
- * DR-088 §3: what the steps completed after `boundaryPrefixCount` committed,
1588
- * one statement each, in boundary sequence order: every new commit where a
1589
- * receipt proves more than one, and the commit already at HEAD where a step
1590
- * whose outcomes all commit or ask named it over an `unchanged` receipt. A
1591
- * step in a deferred chain reads the chain's cumulative receipt at its last
1592
- * boundary.
1638
+ * DR-088 §3, DR-090 §4: what the steps completed after `boundaryPrefixCount`
1639
+ * committed, one statement each, in boundary sequence order, naming the
1640
+ * branch where the receipt names one: every new commit where a receipt
1641
+ * proves more than one, the commit already at HEAD where a step whose
1642
+ * outcomes all commit or ask named it over an `unchanged` receipt, and —
1643
+ * as a fact for the closing reply only, and only where `oneCommit` asks —
1644
+ * the one new commit of a step that made one. A step in a deferred chain
1645
+ * reads the chain's cumulative receipt at its last boundary.
1593
1646
  */
1594
1647
  function stepCommitStatements(
1595
1648
  ledger: PlaybookEffectLedger,
1596
1649
  boundaryPrefixCount: number,
1597
- ): readonly string[] {
1598
- const statements: string[] = [];
1650
+ oneCommit = true,
1651
+ ): readonly StepCommitStatement[] {
1652
+ const statements: StepCommitStatement[] = [];
1599
1653
  for (const boundary of ledger.boundaries) {
1600
1654
  if (boundary.sequence <= boundaryPrefixCount) continue;
1601
1655
  let receipt = boundary.physicalReceipt;
@@ -1611,9 +1665,23 @@ function stepCommitStatements(
1611
1665
  receipt.classification === 'one-descendant-commit' &&
1612
1666
  receipt.commits !== undefined
1613
1667
  ) {
1614
- statements.push(
1615
- `The step made ${receipt.commits.length} new commits, oldest first: ${boundedPathList(receipt.commits)}.`,
1616
- );
1668
+ statements.push({
1669
+ text: `The step made ${receipt.commits.length} new commits${onBranch(receipt)}, oldest first: ${boundedPathList(receipt.commits)}.`,
1670
+ shown: `${MARK_DONE} ${markedList(receipt.commits, ` ${MARK_SEP} `)}${markedBranch(receipt.after?.branch)}`,
1671
+ });
1672
+ continue;
1673
+ }
1674
+ if (
1675
+ receipt.classification === 'one-descendant-commit' &&
1676
+ receipt.commitOid !== undefined
1677
+ ) {
1678
+ if (oneCommit) {
1679
+ statements.push({
1680
+ text: `The step made one new commit, ${receipt.commitOid}${
1681
+ receipt.after?.branch === undefined ? '' : `,${onBranch(receipt)}`
1682
+ }.`,
1683
+ });
1684
+ }
1617
1685
  continue;
1618
1686
  }
1619
1687
  const candidate = boundary.semanticCandidate;
@@ -1634,9 +1702,10 @@ function stepCommitStatements(
1634
1702
  guard !== BOSS_QUESTION_OUTCOME &&
1635
1703
  finalTextNamesCommit(boundary.finalText?.trim() ?? '', head)
1636
1704
  ) {
1637
- statements.push(
1638
- `The step made no new commit; it named ${head}, already at HEAD, as its work.`,
1639
- );
1705
+ statements.push({
1706
+ text: `The step made no new commit; it named ${head}, already at HEAD${onBranch(receipt)}, as its work.`,
1707
+ shown: `${MARK_DONE} = ${head}${markedBranch(receipt.after?.branch)}`,
1708
+ });
1640
1709
  }
1641
1710
  }
1642
1711
  return statements;
@@ -1648,16 +1717,6 @@ function stepCommitStatements(
1648
1717
  // content, player prose, or internal identity: paths, revisions, dispositions,
1649
1718
  // and the reported error message are what a cause carries.
1650
1719
 
1651
- /** The Boss-facing phrase for each closed control-action reason (DR-063 §3). */
1652
- const CONTROL_REASON_PHRASES: Readonly<Record<string, string>> = {
1653
- 'receipt-complete': 'nothing has changed since it failed',
1654
- };
1655
-
1656
- function controlReasonPhrase(reason: string | undefined): string {
1657
- if (reason === undefined) return 'no reason was published';
1658
- return CONTROL_REASON_PHRASES[reason] ?? reason;
1659
- }
1660
-
1661
1720
  function causePathList(
1662
1721
  paths: readonly string[] | undefined,
1663
1722
  truncated: number | undefined,
@@ -2170,11 +2229,70 @@ function failureControlLine(action: {
2170
2229
  readonly standing?: PlaybookControlStanding;
2171
2230
  readonly reason?: string;
2172
2231
  }): string {
2173
- // DR-063 §3: an action that publishes no standing is `ready`.
2232
+ // DR-063 §3: an action that publishes no standing is `ready`. DR-090 §5:
2233
+ // the standing and its reason read as their closed identifiers.
2174
2234
  const standing = action.standing ?? 'ready';
2175
2235
  return standing === 'ready'
2176
- ? `- ${action.label} (ready)`
2177
- : `- ${action.label} (${standing}: ${controlReasonPhrase(action.reason)})`;
2236
+ ? `${MARK_READY} ${action.label}`
2237
+ : `${MARK_NOT_READY} ${action.label} ${MARK_SEP} ${standing}${
2238
+ action.reason === undefined ? '' : ` ${MARK_SEP} ${action.reason}`
2239
+ }`;
2240
+ }
2241
+
2242
+ /**
2243
+ * DR-090 §5: the failure line's marked record of a cause — its code and the
2244
+ * identities its evidence carries, a nested cause after its playbook. No
2245
+ * recorded reason or message reaches it: the settlement facts the closing
2246
+ * reply reads state the failure in words (CAPTAIN-71).
2247
+ */
2248
+ function markedFailure(
2249
+ cause: PlaybookFailureCause,
2250
+ describeNestedPlaybook: (playbookId: string) => string,
2251
+ ): string {
2252
+ const { evidence } = cause;
2253
+ const parts: string[] = [cause.code];
2254
+ if (evidence.roleId !== undefined) parts.push(evidence.roleId);
2255
+ if (evidence.playbookId !== undefined) {
2256
+ parts.push(describeNestedPlaybook(evidence.playbookId));
2257
+ }
2258
+ if (
2259
+ cause.code === 'attribution-ambiguous' &&
2260
+ evidence.required !== undefined &&
2261
+ evidence.observed !== undefined
2262
+ ) {
2263
+ parts.push(`${evidence.required} ${MARK_TO} ${evidence.observed}`);
2264
+ }
2265
+ if (evidence.baselineHead !== undefined) {
2266
+ parts.push(
2267
+ evidence.afterHead === undefined
2268
+ ? evidence.baselineHead
2269
+ : `${evidence.baselineHead} ${MARK_TO} ${evidence.afterHead}`,
2270
+ );
2271
+ }
2272
+ if (
2273
+ evidence.commitOid !== undefined &&
2274
+ evidence.commitOid !== evidence.afterHead
2275
+ ) {
2276
+ parts.push(evidence.commitOid);
2277
+ }
2278
+ const paths = evidence.paths;
2279
+ if (paths !== undefined) {
2280
+ const listed = markedList(
2281
+ [
2282
+ ...(paths.uncommitted ?? []),
2283
+ ...(paths.altered ?? []),
2284
+ ...(paths.lost ?? []),
2285
+ ...(paths.changed ?? []),
2286
+ ],
2287
+ ', ',
2288
+ paths.truncated ?? 0,
2289
+ );
2290
+ if (listed !== '') parts.push(listed);
2291
+ }
2292
+ if (evidence.cause !== undefined) {
2293
+ parts.push(markedFailure(evidence.cause, describeNestedPlaybook));
2294
+ }
2295
+ return parts.join(` ${MARK_SEP} `);
2178
2296
  }
2179
2297
 
2180
2298
  /** A report appended to the visible reply (CAPTAIN-69). */
@@ -2192,7 +2310,11 @@ type PresentationReport =
2192
2310
  | { readonly kind: 'effects'; readonly lines: readonly string[] };
2193
2311
 
2194
2312
  interface FailureReport {
2195
- /** The `Failure:` line's sentence. */
2313
+ /** The failure line's marked record (DR-090 §5). */
2314
+ readonly mark: string;
2315
+ /** The cause's code, the whole record where the mark's words fail CAPTAIN-9. */
2316
+ readonly code: PlaybookFailureCode;
2317
+ /** The failure statement in words, which the settlement facts carry. */
2196
2318
  readonly sentence: string;
2197
2319
  /** The reason the line states, which weighs it (CAPTAIN-69). */
2198
2320
  readonly reason: string;
@@ -2319,10 +2441,7 @@ function guardedStatement(
2319
2441
  };
2320
2442
  for (const reason of phrased) {
2321
2443
  if (!fails(reason)) continue;
2322
- guarded =
2323
- guarded === `Failure: ${reason}`
2324
- ? `Failure: the step failed with ${WITHHELD_REASON}.`
2325
- : guarded.split(`: ${reason}`).join(` with ${WITHHELD_REASON}.`);
2444
+ guarded = guarded.split(`: ${reason}`).join(` with ${WITHHELD_REASON}.`);
2326
2445
  }
2327
2446
  for (const subject of shellWords.subjects) {
2328
2447
  if (guarded.startsWith(`${subject} `) && fails(subject)) {
@@ -2353,8 +2472,11 @@ function assembleStatements(
2353
2472
  guard: (text: string) => string = (text) => text,
2354
2473
  ): readonly (string | undefined)[] {
2355
2474
  const rendered = [...new Set(reasons)];
2475
+ // DR-090 §5: the marked failure line is no sentence and gains no mark.
2476
+ const finish = (kind: string, text: string): string =>
2477
+ kind === 'failure' ? text.trim() : oneTerminalMark(text);
2356
2478
  const weighed = statements.map(({ text, kind = 'fact', reason }) => {
2357
- const statement = oneTerminalMark(text);
2479
+ const statement = finish(kind, text);
2358
2480
  if (reason !== undefined) return { text: statement, kind, own: [reason] };
2359
2481
  const held = rendered.filter((candidate) =>
2360
2482
  alreadyStates(statement, candidate),
@@ -2378,7 +2500,7 @@ function assembleStatements(
2378
2500
  return weighed.map(({ text, kind, own }, index) =>
2379
2501
  stays[index] &&
2380
2502
  (kind !== 'failure' || !carried(own, [...entries, ...facts]))
2381
- ? oneTerminalMark(guard(text))
2503
+ ? finish(kind, guard(text))
2382
2504
  : undefined,
2383
2505
  );
2384
2506
  }
@@ -2431,13 +2553,11 @@ function presentationSuffix(
2431
2553
  });
2432
2554
  const failure = turn.failureReport;
2433
2555
  if (failure !== undefined) {
2434
- parts.push(
2435
- [
2436
- ...(assembled.failure === undefined ? [] : [assembled.failure]),
2437
- 'Controls:',
2438
- ...(failure.controls.length === 0 ? ['- none'] : failure.controls),
2439
- ].join('\n'),
2440
- );
2556
+ const lines = [
2557
+ ...(assembled.failure === undefined ? [] : [assembled.failure]),
2558
+ ...failure.controls,
2559
+ ];
2560
+ if (lines.length > 0) parts.push(lines.join('\n'));
2441
2561
  }
2442
2562
  return parts.length === 0 ? undefined : parts.join('\n\n');
2443
2563
  }
@@ -3941,6 +4061,13 @@ function snapshotNonblankTuning(
3941
4061
  return value;
3942
4062
  }
3943
4063
 
4064
+ function snapshotSkillLocators(value: JsonValue, path: string): readonly string[] {
4065
+ if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || item.includes('\0') || !(posix.isAbsolute(item) || win32.isAbsolute(item)))) {
4066
+ throw new Error(`${path} must be an array of absolute skill paths`);
4067
+ }
4068
+ return [...value] as string[];
4069
+ }
4070
+
3944
4071
  function snapshotSessionAgent(
3945
4072
  value: JsonValue | undefined,
3946
4073
  path: string,
@@ -3956,6 +4083,7 @@ function snapshotSessionAgent(
3956
4083
  'subagentModel',
3957
4084
  'subagentEffort',
3958
4085
  'browser',
4086
+ 'skills',
3959
4087
  'instruction',
3960
4088
  'permissions',
3961
4089
  ],
@@ -3970,7 +4098,8 @@ function snapshotSessionAgent(
3970
4098
  key !== 'fastMode' &&
3971
4099
  key !== 'subagentModel' &&
3972
4100
  key !== 'subagentEffort' &&
3973
- key !== 'browser',
4101
+ key !== 'browser' &&
4102
+ key !== 'skills',
3974
4103
  ),
3975
4104
  ) as JsonValue,
3976
4105
  path,
@@ -3983,6 +4112,7 @@ function snapshotSessionAgent(
3983
4112
  ...(fixed.permissions === undefined
3984
4113
  ? {}
3985
4114
  : { permissions: livePermissions(fixed.permissions) }),
4115
+ ...(agent.skills === undefined ? {} : { skills: snapshotSkillLocators(agent.skills, `${path}.skills`) }),
3986
4116
  ...(agent.browser === undefined ? {} : { browser: snapshotFastMode(agent.browser, `${path}.browser`) }),
3987
4117
  model: snapshotTuningSelection(agent.model, `${path}.model`),
3988
4118
  effort: snapshotEffortSelection(agent.effort, `${path}.effort`),
@@ -4008,7 +4138,7 @@ function snapshotSessionAgent(
4008
4138
  };
4009
4139
  }
4010
4140
 
4011
- function fixedAgent(agent: SessionAgent): Omit<SessionAgent, TuningKey | 'browser'> {
4141
+ function fixedAgent(agent: SessionAgent): Omit<SessionAgent, TuningKey | 'browser' | 'skills'> {
4012
4142
  return {
4013
4143
  adapter: agent.adapter,
4014
4144
  ...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
@@ -4029,6 +4159,7 @@ function callSettings(
4029
4159
  model: tuning.model,
4030
4160
  effort: tuning.effort,
4031
4161
  browser: agent.browser ?? false,
4162
+ skills: agent.skills === undefined ? [] : [...agent.skills],
4032
4163
  ...(tuning.fastMode === undefined ? {} : { fastMode: tuning.fastMode }),
4033
4164
  ...(tuning.subagentModel === undefined
4034
4165
  ? {}
@@ -4042,7 +4173,7 @@ function callSettings(
4042
4173
  }
4043
4174
 
4044
4175
  function controlCallSettings(agent: SessionAgent): AgentCallSettings {
4045
- return { ...callSettings(agent), browser: false, mcpServers: {} };
4176
+ return { ...callSettings(agent), browser: false, skills: [], mcpServers: {} };
4046
4177
  }
4047
4178
 
4048
4179
  function promptIdentity(binding: EffectivePlayerBinding): string {
@@ -4939,11 +5070,17 @@ export function createPlaybookCaptainShell(
4939
5070
  const reportCarriedPreExistingChanges = (turn: ActiveTurn): void => {
4940
5071
  if (turn.carriedEffectPrefix !== undefined) return;
4941
5072
  let carried: readonly CarriedPreExistingChanges[];
4942
- let commitStatements: readonly string[];
5073
+ let commitStatements: readonly StepCommitStatement[];
4943
5074
  try {
4944
5075
  const ledger = assertPlaybookEffectLedger(currentEffectLedger());
4945
5076
  carried = carriedPreExistingChanges(ledger, presentedEffectPrefix);
4946
- commitStatements = stepCommitStatements(ledger, presentedEffectPrefix);
5077
+ // DR-090 §4: a failed turn states its one-commit steps in the
5078
+ // commits-before-failure report instead (DR-089 §3), never twice.
5079
+ commitStatements = stepCommitStatements(
5080
+ ledger,
5081
+ presentedEffectPrefix,
5082
+ !leafParkedInFailure(),
5083
+ );
4947
5084
  turn.carriedEffectPrefix = ledger.boundaries.length;
4948
5085
  } catch {
4949
5086
  return;
@@ -4956,16 +5093,22 @@ export function createPlaybookCaptainShell(
4956
5093
  joinSettlementFact(turn, fact);
4957
5094
  }
4958
5095
  }
4959
- // DR-088 §3: the commits each step took, read from the same boundaries.
4960
- if (commitStatements.length > 0) {
5096
+ // DR-088 §3, DR-090 §4: the commits each step took, read from the same
5097
+ // boundaries; a step's one new commit reaches the closing reply's facts
5098
+ // only.
5099
+ const shownStatements = commitStatements.filter(
5100
+ (statement): statement is StepCommitStatement & { readonly shown: string } =>
5101
+ statement.shown !== undefined,
5102
+ );
5103
+ if (shownStatements.length > 0) {
4961
5104
  appendMandatoryPresentationSuffix(
4962
5105
  turn,
4963
- commitStatements.join('\n'),
4964
- commitStatements,
5106
+ shownStatements.map(({ shown }) => shown).join('\n'),
5107
+ shownStatements.map(({ text }) => text),
4965
5108
  );
4966
- for (const fact of commitStatements) {
4967
- joinSettlementFact(turn, fact);
4968
- }
5109
+ }
5110
+ for (const { text } of commitStatements) {
5111
+ joinSettlementFact(turn, text);
4969
5112
  }
4970
5113
  };
4971
5114
 
@@ -8057,7 +8200,13 @@ export function createPlaybookCaptainShell(
8057
8200
  ? []
8058
8201
  : [
8059
8202
  {
8060
- text: `Failure: ${failure.sentence}`,
8203
+ // CAPTAIN-69: a mark whose words CAPTAIN-9 refuses — a path
8204
+ // naming control vocabulary, say — reads its code alone.
8205
+ text: `${MARK_FAILED} ${
8206
+ replyRejection(failure.mark) === undefined
8207
+ ? failure.mark
8208
+ : failure.code
8209
+ }`,
8061
8210
  kind: 'failure' as const,
8062
8211
  reason: failure.reason,
8063
8212
  },
@@ -9098,6 +9247,19 @@ export function createPlaybookCaptainShell(
9098
9247
  // DR-063 §4: appended once, beside the unresolved-effect and pre-existing
9099
9248
  // change reports, whenever the turn settles with the leaf parked in its
9100
9249
  // failure state or behind the retained-effect fence.
9250
+ // The condition under which reportLeafFailure states a failure: the leaf
9251
+ // parked in its failure state, or behind the retained-effect fence.
9252
+ const leafParkedInFailure = (): boolean => {
9253
+ if (retainedEffectReconciliation !== undefined) return true;
9254
+ const leaf = leafFrame();
9255
+ if (!leaf || typeof leaf.runtime.describe !== 'function') return false;
9256
+ try {
9257
+ return leaf.runtime.describe().state.stateId === 'failed';
9258
+ } catch {
9259
+ return false;
9260
+ }
9261
+ };
9262
+
9101
9263
  const reportLeafFailure = (
9102
9264
  turn: ActiveTurn,
9103
9265
  unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[],
@@ -9138,6 +9300,8 @@ export function createPlaybookCaptainShell(
9138
9300
  : statement.restate(failure);
9139
9301
  const speak = statement?.speak ?? ((text: string) => text);
9140
9302
  turn.failureReport = {
9303
+ mark: markedFailure(cause, describeNestedPlaybook),
9304
+ code: cause.code,
9141
9305
  ...failureLine(
9142
9306
  noteReason(
9143
9307
  failureReason(failure, describeNestedPlaybook, speakableWords),
package/slc/link.md CHANGED
@@ -332,6 +332,7 @@ interface PlaybookRepositoryObservation {
332
332
  readonly worktree: string;
333
333
  readonly gitDir: string;
334
334
  readonly head: string;
335
+ readonly branch?: string;
335
336
  readonly projection: Readonly<Record<string, JsonValue>>;
336
337
  readonly projectionDigest: string;
337
338
  }
@@ -521,6 +522,13 @@ interface PlaybookRuntimeSnapshot {
521
522
  /** Interrupted invocation, captured before its external call (DR-069). */
522
523
  recoveryCheckpoint?: PlaybookRecoveryCheckpoint;
523
524
  suspendedCall?: PlaybookSuspendedCall;
525
+ /** The run's Boss exchanges so far, in order (DR-090). */
526
+ bossExchanges?: readonly PlaybookBossExchange[];
527
+ }
528
+
529
+ interface PlaybookBossExchange {
530
+ readonly question: string;
531
+ readonly reply: string;
524
532
  }
525
533
 
526
534
  type PlaybookRunResult =
@@ -1084,6 +1092,15 @@ A resumed call omits that question; a fresh call includes it before `Boss reply:
1084
1092
  Both retain the authored task body exactly once, with two newlines between blocks.
1085
1093
  These framework blocks never enter `invoke.input.prompt` or persisted FSM context.
1086
1094
 
1095
+ The shared runtime hands a composer the call's input without `pendingBossQuestion` and `bossReply` and builds the continuation itself, so a player of any language reads it as structure (DR-090).
1096
+ Before the composer's result it places, in order:
1097
+
1098
+ - `Role: <roleId> · Model: <promptIdentity>`, naming the call's local role and its current binding's prompt identity, or `Role: <roleId>` alone without bindings;
1099
+ - once the run has heard a Boss reply, the run's Boss exchanges so far, each a `? ⟶` line and the verbatim question, then a `Boss ⟶` line and the verbatim reply, whatever the role and whether or not the Source retains them, leaving out the one exchange the call's own continuation carries;
1100
+ - for a call after a Boss reply, the marked verbatim question, only for a fresh conversation, and the marked verbatim reply.
1101
+
1102
+ A run begins at the entry event or a finished machine's restart, and its exchanges travel in the runtime snapshot as `bossExchanges`.
1103
+
1087
1104
  The runtime chooses the compact prompt only after selecting a conversation token and carries the complete prompt as optional `PlayerCallOptions.freshPrompt`.
1088
1105
  A host starting fresh after a definite pre-execution token rejection uses `freshPrompt`, when supplied, instead of the compact prompt.
1089
1106
  Without that option, the given prompt remains complete.
@@ -1290,9 +1307,11 @@ output is delivered.
1290
1307
  That type is the value shape the compiled FSM's own validator admits for the
1291
1308
  field: the shared engine reads it once at construction by presenting each
1292
1309
  governed outcome's accepting arm with sample values of a closed set of shapes,
1293
- a field whose shape the FSM admits as a string or does not reveal stays a
1294
- string, and the judge prompt and validation error name every non-string shape
1295
- ([DR-089](../specs/decisions/089-judge-replies-take-the-machines-shapes.md)).
1310
+ a field whose shape the FSM admits as a string stays a string, a field whose
1311
+ shape it does not reveal takes any JSON value for the FSM's own guard to
1312
+ decide, and the judge prompt and validation error name every non-string shape
1313
+ ([DR-089](../specs/decisions/089-judge-replies-take-the-machines-shapes.md),
1314
+ [DR-091](../specs/decisions/091-an-unrevealed-shape-is-the-machines-to-judge.md)).
1296
1315
  The judge prompt shall render each governed outcome's description with its
1297
1316
  meaning verbatim and its `Output shall include` clause replaced by that reply
1298
1317
  contract — exactly `guard` plus the semantic-owned fields, each keeping its
@@ -1972,6 +1991,9 @@ At a safe capture point it shall return a JSON-safe
1972
1991
  `{ kind: 'captain' }` or `{ kind: 'role', roleId }`, empty when the
1973
1992
  parked state awaits no reply. This list exists so hosts can surface the
1974
1993
  question without parsing status lines or telemetry.
1994
+ - `bossExchanges`: omitted when the run has heard no Boss reply; otherwise
1995
+ every `{ question, reply }` exchange of the run so far, in order, which
1996
+ `restore` and adoption take back.
1975
1997
  - `suspendedCall`: omitted when no nested call is pending; otherwise the
1976
1998
  shared nested bridge's complete `callId`, source `stateId`, target
1977
1999
  `playbookId`, exact handed-off `text`, and `childSessionId`, enriched with
package/src/runtime.d.ts CHANGED
@@ -236,6 +236,12 @@ export interface PlaybookRepositoryObservation {
236
236
  readonly worktree: string;
237
237
  readonly gitDir: string;
238
238
  readonly head: string;
239
+ /**
240
+ * The branch HEAD names, absent where HEAD is detached or the worktree is
241
+ * not a repository yet (DR-090 §4). Evidence for the Boss only: no
242
+ * comparison of two observations reads it.
243
+ */
244
+ readonly branch?: string;
239
245
  readonly projection: Readonly<Record<string, JsonValue>>;
240
246
  readonly projectionDigest: string;
241
247
  }
@@ -367,6 +373,11 @@ export interface PlaybookEffectLedgerCapability {
367
373
  snapshot(): PlaybookEffectLedger;
368
374
  writeAhead(commands: PlaybookEffectLedgerCommandBatch): Promise<PlaybookEffectLedger>;
369
375
  }
376
+ /** One Boss exchange of a run: the question as asked and the reply verbatim (DR-090 §1). */
377
+ export interface PlaybookBossExchange {
378
+ readonly question: string;
379
+ readonly reply: string;
380
+ }
370
381
  export interface PlaybookRuntimeSnapshot {
371
382
  schemaVersion: 4;
372
383
  playbookId: string;
@@ -402,6 +413,8 @@ export interface PlaybookRuntimeSnapshot {
402
413
  /** Interrupted invocation, captured before its external call (DR-069). */
403
414
  recoveryCheckpoint?: PlaybookRecoveryCheckpoint;
404
415
  suspendedCall?: PlaybookSuspendedCall;
416
+ /** The run's Boss exchanges so far, in order; absent when there are none (DR-090 §1). */
417
+ bossExchanges?: readonly PlaybookBossExchange[];
405
418
  }
406
419
  export interface PlaybookRecoveryCheckpoint {
407
420
  readonly id?: string;
package/src/runtime.ts CHANGED
@@ -590,6 +590,12 @@ export interface PlaybookRepositoryObservation {
590
590
  readonly worktree: string;
591
591
  readonly gitDir: string;
592
592
  readonly head: string;
593
+ /**
594
+ * The branch HEAD names, absent where HEAD is detached or the worktree is
595
+ * not a repository yet (DR-090 §4). Evidence for the Boss only: no
596
+ * comparison of two observations reads it.
597
+ */
598
+ readonly branch?: string;
593
599
  readonly projection: Readonly<Record<string, JsonValue>>;
594
600
  readonly projectionDigest: string;
595
601
  }
@@ -752,6 +758,12 @@ export interface PlaybookEffectLedgerCapability {
752
758
  ): Promise<PlaybookEffectLedger>;
753
759
  }
754
760
 
761
+ /** One Boss exchange of a run: the question as asked and the reply verbatim (DR-090 §1). */
762
+ export interface PlaybookBossExchange {
763
+ readonly question: string;
764
+ readonly reply: string;
765
+ }
766
+
755
767
  // DR-014 §1 / DR-031 §5 / DR-032: JSON-safe capture of a parked or nested-call
756
768
  // suspended session. `machine` is the opaque XState persisted snapshot;
757
769
  // pending Boss questions and a suspended call are first-class so a
@@ -789,6 +801,8 @@ export interface PlaybookRuntimeSnapshot {
789
801
  /** Interrupted invocation, captured before its external call (DR-069). */
790
802
  recoveryCheckpoint?: PlaybookRecoveryCheckpoint;
791
803
  suspendedCall?: PlaybookSuspendedCall;
804
+ /** The run's Boss exchanges so far, in order; absent when there are none (DR-090 §1). */
805
+ bossExchanges?: readonly PlaybookBossExchange[];
792
806
  }
793
807
 
794
808
  export interface PlaybookRecoveryCheckpoint {
@@ -565,9 +565,10 @@ export declare function stateDescriptionsFromMachine(machine: AnyStateMachine):
565
565
  */
566
566
  export declare function terminalOutcomesFromMachine(machine: AnyStateMachine, label?: string): ReadonlyMap<string, 'success' | 'failure'>;
567
567
  /**
568
- * DR-089: the non-string JSON value shape the compiled machine admits for
569
- * each semantic-owned field of each governed outcome, keyed by state id and
570
- * then by outcome. Exported so linked machinery of an artifact's own reads
568
+ * DR-089: the JSON value shape the compiled machine admits for each
569
+ * semantic-owned field of each governed outcome, keyed by state id and then
570
+ * by outcome. A field, outcome, or state the probe reveals no shape for is
571
+ * left out (DR-091). Exported so linked machinery of an artifact's own reads
571
572
  * the same contract the shared factory enforces.
572
573
  */
573
574
  export declare function inferSemanticValueShapes(machine: AnyStateMachine, authority: XStateOutcomeAuthoritySpec): ReadonlyMap<string, PlaybookSemanticValueShapes>;