@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.
@@ -1,6 +1,7 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { posix, win32 } from 'node:path';
4
5
  import { isDeepStrictEqual } from 'node:util';
5
6
  import PQueue from 'p-queue';
6
7
  import { isAgentCallSettingsError, } from '@sublang/cligent/tmux-play';
@@ -556,6 +557,29 @@ function boundedPathList(paths) {
556
557
  ? `${printed.join(', ')}, … and ${omitted} more`
557
558
  : printed.join(', ');
558
559
  }
560
+ // DR-090 §5: the report lines the shell appends to a reply carry marks and
561
+ // identifiers, never an English sentence, so a reply in any language reads
562
+ // them as structure. The settlement facts a model reads keep their words.
563
+ const MARK_DONE = '✓';
564
+ const MARK_FAILED = '✕';
565
+ const MARK_TO = '⟶';
566
+ const MARK_BRANCH = '⎇';
567
+ const MARK_SEP = '·';
568
+ const MARK_READY = '▸';
569
+ const MARK_NOT_READY = '▹';
570
+ /** At most 24 items, then `+<n>` for the rest (DR-090 §5). */
571
+ function markedList(items, separator = ', ', more = 0) {
572
+ const printed = items.slice(0, CARRIED_CHANGE_PATH_LIMIT);
573
+ const omitted = items.length - printed.length + more;
574
+ if (printed.length === 0)
575
+ return omitted > 0 ? `+${omitted}` : '';
576
+ return omitted > 0
577
+ ? `${printed.join(separator)}${separator}+${omitted}`
578
+ : printed.join(separator);
579
+ }
580
+ function markedBranch(branch) {
581
+ return branch === undefined ? '' : ` ${MARK_BRANCH} ${branch}`;
582
+ }
559
583
  /**
560
584
  * The carried pre-existing changes of the boundaries completed after
561
585
  * `boundaryPrefixCount`, one entry per commit OID in boundary sequence order.
@@ -621,6 +645,11 @@ function carriedPreExistingFacts(carried) {
621
645
  return `The commit ${commitOid} carries changes that were uncommitted before the step: ${lists.join('; ')}.`;
622
646
  });
623
647
  }
648
+ /** DR-090 §4: where a step's commits landed, as its receipt names it. */
649
+ function onBranch(receipt) {
650
+ const branch = receipt.after?.branch;
651
+ return branch === undefined ? '' : ` on branch ${branch}`;
652
+ }
624
653
  function commitsBeforeFailure(ledger, boundaryPrefixCount, unresolvedEffects) {
625
654
  // An entry names a commit by its proven OID, or — for a receipt listing
626
655
  // several commits, which projects as `multiple-commits` (DR-088 §3) — by
@@ -645,28 +674,32 @@ function commitsBeforeFailure(ledger, boundaryPrefixCount, unresolvedEffects) {
645
674
  baselineHead: receipt.baseline.head,
646
675
  commitOid: receipt.commitOid,
647
676
  ...(receipt.commits === undefined ? {} : { commits: receipt.commits }),
677
+ ...(receipt.after?.branch === undefined
678
+ ? {}
679
+ : { branch: receipt.after.branch }),
648
680
  });
649
681
  }
650
682
  return commits;
651
683
  }
652
684
  /** What one step committed, with every commit where it made several (DR-088). */
653
- function commitBeforeFailureClause({ baselineHead, commitOid, commits, }) {
685
+ function commitBeforeFailureClause({ baselineHead, commitOid, commits, branch, }) {
654
686
  const made = commits === undefined
655
687
  ? 'made one new commit'
656
688
  : `made ${commits.length} new commits, oldest first: ${boundedPathList(commits)}`;
657
- return `${made}; HEAD moved from ${baselineHead} to the proven commit ${commitOid}.`;
689
+ const landed = branch === undefined ? '' : ` on branch ${branch}`;
690
+ return `${made}; HEAD moved from ${baselineHead} to the proven commit ${commitOid}${landed}.`;
658
691
  }
659
692
  function commitBeforeFailureFact(commit) {
660
693
  return `Before the failure, a step ${commitBeforeFailureClause(commit)}`;
661
694
  }
695
+ // DR-090 §5: one marked line per receipt, `✓ <baselineHead> ⟶ <commits>`
696
+ // and the branch they landed on.
662
697
  function commitsBeforeFailureReport(commits) {
663
698
  if (commits.length === 0)
664
699
  return undefined;
665
- return [
666
- 'Repository changes made before the failure:',
667
- ...commits.map((commit) => `- A step ${commitBeforeFailureClause(commit)}`),
668
- 'The failure does not undo these commits.',
669
- ].join('\n');
700
+ return commits
701
+ .map(({ baselineHead, commitOid, commits: listed, branch }) => `${MARK_DONE} ${baselineHead} ${MARK_TO} ${markedList(listed ?? [commitOid], ` ${MARK_SEP} `)}${markedBranch(branch)}`)
702
+ .join('\n');
670
703
  }
671
704
  /**
672
705
  * DR-088 §2: whether a final text names one commit as a word, by its full OID
@@ -681,14 +714,16 @@ function finalTextNamesCommit(finalText, oid) {
681
714
  return false;
682
715
  }
683
716
  /**
684
- * DR-088 §3: what the steps completed after `boundaryPrefixCount` committed,
685
- * one statement each, in boundary sequence order: every new commit where a
686
- * receipt proves more than one, and the commit already at HEAD where a step
687
- * whose outcomes all commit or ask named it over an `unchanged` receipt. A
688
- * step in a deferred chain reads the chain's cumulative receipt at its last
689
- * boundary.
717
+ * DR-088 §3, DR-090 §4: what the steps completed after `boundaryPrefixCount`
718
+ * committed, one statement each, in boundary sequence order, naming the
719
+ * branch where the receipt names one: every new commit where a receipt
720
+ * proves more than one, the commit already at HEAD where a step whose
721
+ * outcomes all commit or ask named it over an `unchanged` receipt, and —
722
+ * as a fact for the closing reply only, and only where `oneCommit` asks —
723
+ * the one new commit of a step that made one. A step in a deferred chain
724
+ * reads the chain's cumulative receipt at its last boundary.
690
725
  */
691
- function stepCommitStatements(ledger, boundaryPrefixCount) {
726
+ function stepCommitStatements(ledger, boundaryPrefixCount, oneCommit = true) {
692
727
  const statements = [];
693
728
  for (const boundary of ledger.boundaries) {
694
729
  if (boundary.sequence <= boundaryPrefixCount)
@@ -704,7 +739,19 @@ function stepCommitStatements(ledger, boundaryPrefixCount) {
704
739
  continue;
705
740
  if (receipt.classification === 'one-descendant-commit' &&
706
741
  receipt.commits !== undefined) {
707
- statements.push(`The step made ${receipt.commits.length} new commits, oldest first: ${boundedPathList(receipt.commits)}.`);
742
+ statements.push({
743
+ text: `The step made ${receipt.commits.length} new commits${onBranch(receipt)}, oldest first: ${boundedPathList(receipt.commits)}.`,
744
+ shown: `${MARK_DONE} ${markedList(receipt.commits, ` ${MARK_SEP} `)}${markedBranch(receipt.after?.branch)}`,
745
+ });
746
+ continue;
747
+ }
748
+ if (receipt.classification === 'one-descendant-commit' &&
749
+ receipt.commitOid !== undefined) {
750
+ if (oneCommit) {
751
+ statements.push({
752
+ text: `The step made one new commit, ${receipt.commitOid}${receipt.after?.branch === undefined ? '' : `,${onBranch(receipt)}`}.`,
753
+ });
754
+ }
708
755
  continue;
709
756
  }
710
757
  const candidate = boundary.semanticCandidate;
@@ -722,7 +769,10 @@ function stepCommitStatements(ledger, boundaryPrefixCount) {
722
769
  typeof guard === 'string' &&
723
770
  guard !== BOSS_QUESTION_OUTCOME &&
724
771
  finalTextNamesCommit(boundary.finalText?.trim() ?? '', head)) {
725
- statements.push(`The step made no new commit; it named ${head}, already at HEAD, as its work.`);
772
+ statements.push({
773
+ text: `The step made no new commit; it named ${head}, already at HEAD${onBranch(receipt)}, as its work.`,
774
+ shown: `${MARK_DONE} = ${head}${markedBranch(receipt.after?.branch)}`,
775
+ });
726
776
  }
727
777
  }
728
778
  return statements;
@@ -732,15 +782,6 @@ function stepCommitStatements(ledger, boundaryPrefixCount) {
732
782
  // of asking a model to describe facts it may drop. The report exposes no file
733
783
  // content, player prose, or internal identity: paths, revisions, dispositions,
734
784
  // and the reported error message are what a cause carries.
735
- /** The Boss-facing phrase for each closed control-action reason (DR-063 §3). */
736
- const CONTROL_REASON_PHRASES = {
737
- 'receipt-complete': 'nothing has changed since it failed',
738
- };
739
- function controlReasonPhrase(reason) {
740
- if (reason === undefined)
741
- return 'no reason was published';
742
- return CONTROL_REASON_PHRASES[reason] ?? reason;
743
- }
744
785
  function causePathList(paths, truncated) {
745
786
  const list = paths ?? [];
746
787
  const printed = list.slice(0, CARRIED_CHANGE_PATH_LIMIT);
@@ -1105,11 +1146,56 @@ function failureLine(reason) {
1105
1146
  };
1106
1147
  }
1107
1148
  function failureControlLine(action) {
1108
- // DR-063 §3: an action that publishes no standing is `ready`.
1149
+ // DR-063 §3: an action that publishes no standing is `ready`. DR-090 §5:
1150
+ // the standing and its reason read as their closed identifiers.
1109
1151
  const standing = action.standing ?? 'ready';
1110
1152
  return standing === 'ready'
1111
- ? `- ${action.label} (ready)`
1112
- : `- ${action.label} (${standing}: ${controlReasonPhrase(action.reason)})`;
1153
+ ? `${MARK_READY} ${action.label}`
1154
+ : `${MARK_NOT_READY} ${action.label} ${MARK_SEP} ${standing}${action.reason === undefined ? '' : ` ${MARK_SEP} ${action.reason}`}`;
1155
+ }
1156
+ /**
1157
+ * DR-090 §5: the failure line's marked record of a cause — its code and the
1158
+ * identities its evidence carries, a nested cause after its playbook. No
1159
+ * recorded reason or message reaches it: the settlement facts the closing
1160
+ * reply reads state the failure in words (CAPTAIN-71).
1161
+ */
1162
+ function markedFailure(cause, describeNestedPlaybook) {
1163
+ const { evidence } = cause;
1164
+ const parts = [cause.code];
1165
+ if (evidence.roleId !== undefined)
1166
+ parts.push(evidence.roleId);
1167
+ if (evidence.playbookId !== undefined) {
1168
+ parts.push(describeNestedPlaybook(evidence.playbookId));
1169
+ }
1170
+ if (cause.code === 'attribution-ambiguous' &&
1171
+ evidence.required !== undefined &&
1172
+ evidence.observed !== undefined) {
1173
+ parts.push(`${evidence.required} ${MARK_TO} ${evidence.observed}`);
1174
+ }
1175
+ if (evidence.baselineHead !== undefined) {
1176
+ parts.push(evidence.afterHead === undefined
1177
+ ? evidence.baselineHead
1178
+ : `${evidence.baselineHead} ${MARK_TO} ${evidence.afterHead}`);
1179
+ }
1180
+ if (evidence.commitOid !== undefined &&
1181
+ evidence.commitOid !== evidence.afterHead) {
1182
+ parts.push(evidence.commitOid);
1183
+ }
1184
+ const paths = evidence.paths;
1185
+ if (paths !== undefined) {
1186
+ const listed = markedList([
1187
+ ...(paths.uncommitted ?? []),
1188
+ ...(paths.altered ?? []),
1189
+ ...(paths.lost ?? []),
1190
+ ...(paths.changed ?? []),
1191
+ ], ', ', paths.truncated ?? 0);
1192
+ if (listed !== '')
1193
+ parts.push(listed);
1194
+ }
1195
+ if (evidence.cause !== undefined) {
1196
+ parts.push(markedFailure(evidence.cause, describeNestedPlaybook));
1197
+ }
1198
+ return parts.join(` ${MARK_SEP} `);
1113
1199
  }
1114
1200
  /**
1115
1201
  * Whether `container` already states `statement`: the same words, case and
@@ -1170,10 +1256,7 @@ function guardedStatement(text, withholds, refuses, phrased = [], shellWords = N
1170
1256
  for (const reason of phrased) {
1171
1257
  if (!fails(reason))
1172
1258
  continue;
1173
- guarded =
1174
- guarded === `Failure: ${reason}`
1175
- ? `Failure: the step failed with ${WITHHELD_REASON}.`
1176
- : guarded.split(`: ${reason}`).join(` with ${WITHHELD_REASON}.`);
1259
+ guarded = guarded.split(`: ${reason}`).join(` with ${WITHHELD_REASON}.`);
1177
1260
  }
1178
1261
  for (const subject of shellWords.subjects) {
1179
1262
  if (guarded.startsWith(`${subject} `) && fails(subject)) {
@@ -1199,8 +1282,10 @@ function guardedStatement(text, withholds, refuses, phrased = [], shellWords = N
1199
1282
  */
1200
1283
  function assembleStatements(statements, reasons = [], guard = (text) => text) {
1201
1284
  const rendered = [...new Set(reasons)];
1285
+ // DR-090 §5: the marked failure line is no sentence and gains no mark.
1286
+ const finish = (kind, text) => kind === 'failure' ? text.trim() : oneTerminalMark(text);
1202
1287
  const weighed = statements.map(({ text, kind = 'fact', reason }) => {
1203
- const statement = oneTerminalMark(text);
1288
+ const statement = finish(kind, text);
1204
1289
  if (reason !== undefined)
1205
1290
  return { text: statement, kind, own: [reason] };
1206
1291
  const held = rendered.filter((candidate) => alreadyStates(statement, candidate));
@@ -1221,7 +1306,7 @@ function assembleStatements(statements, reasons = [], guard = (text) => text) {
1221
1306
  });
1222
1307
  return weighed.map(({ text, kind, own }, index) => stays[index] &&
1223
1308
  (kind !== 'failure' || !carried(own, [...entries, ...facts]))
1224
- ? oneTerminalMark(guard(text))
1309
+ ? finish(kind, guard(text))
1225
1310
  : undefined);
1226
1311
  }
1227
1312
  /** The statements of `statements` that stay, as CAPTAIN-69 assembles them. */
@@ -1248,11 +1333,12 @@ function presentationSuffix(turn, assembled) {
1248
1333
  });
1249
1334
  const failure = turn.failureReport;
1250
1335
  if (failure !== undefined) {
1251
- parts.push([
1336
+ const lines = [
1252
1337
  ...(assembled.failure === undefined ? [] : [assembled.failure]),
1253
- 'Controls:',
1254
- ...(failure.controls.length === 0 ? ['- none'] : failure.controls),
1255
- ].join('\n'));
1338
+ ...failure.controls,
1339
+ ];
1340
+ if (lines.length > 0)
1341
+ parts.push(lines.join('\n'));
1256
1342
  }
1257
1343
  return parts.length === 0 ? undefined : parts.join('\n\n');
1258
1344
  }
@@ -2225,6 +2311,12 @@ function snapshotNonblankTuning(value, path) {
2225
2311
  }
2226
2312
  return value;
2227
2313
  }
2314
+ function snapshotSkillLocators(value, path) {
2315
+ if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || item.includes('\0') || !(posix.isAbsolute(item) || win32.isAbsolute(item)))) {
2316
+ throw new Error(`${path} must be an array of absolute skill paths`);
2317
+ }
2318
+ return [...value];
2319
+ }
2228
2320
  function snapshotSessionAgent(value, path) {
2229
2321
  const agent = snapshotRecord(value, path);
2230
2322
  rejectSnapshotKeys(agent, [
@@ -2235,6 +2327,7 @@ function snapshotSessionAgent(value, path) {
2235
2327
  'subagentModel',
2236
2328
  'subagentEffort',
2237
2329
  'browser',
2330
+ 'skills',
2238
2331
  'instruction',
2239
2332
  'permissions',
2240
2333
  ], path);
@@ -2243,7 +2336,8 @@ function snapshotSessionAgent(value, path) {
2243
2336
  key !== 'fastMode' &&
2244
2337
  key !== 'subagentModel' &&
2245
2338
  key !== 'subagentEffort' &&
2246
- key !== 'browser')), path);
2339
+ key !== 'browser' &&
2340
+ key !== 'skills')), path);
2247
2341
  return {
2248
2342
  adapter: fixed.adapter,
2249
2343
  ...(fixed.instruction === undefined
@@ -2252,6 +2346,7 @@ function snapshotSessionAgent(value, path) {
2252
2346
  ...(fixed.permissions === undefined
2253
2347
  ? {}
2254
2348
  : { permissions: livePermissions(fixed.permissions) }),
2349
+ ...(agent.skills === undefined ? {} : { skills: snapshotSkillLocators(agent.skills, `${path}.skills`) }),
2255
2350
  ...(agent.browser === undefined ? {} : { browser: snapshotFastMode(agent.browser, `${path}.browser`) }),
2256
2351
  model: snapshotTuningSelection(agent.model, `${path}.model`),
2257
2352
  effort: snapshotEffortSelection(agent.effort, `${path}.effort`),
@@ -2287,6 +2382,7 @@ function callSettings(agent, tuning = agent) {
2287
2382
  model: tuning.model,
2288
2383
  effort: tuning.effort,
2289
2384
  browser: agent.browser ?? false,
2385
+ skills: agent.skills === undefined ? [] : [...agent.skills],
2290
2386
  ...(tuning.fastMode === undefined ? {} : { fastMode: tuning.fastMode }),
2291
2387
  ...(tuning.subagentModel === undefined
2292
2388
  ? {}
@@ -2299,7 +2395,7 @@ function callSettings(agent, tuning = agent) {
2299
2395
  };
2300
2396
  }
2301
2397
  function controlCallSettings(agent) {
2302
- return { ...callSettings(agent), browser: false, mcpServers: {} };
2398
+ return { ...callSettings(agent), browser: false, skills: [], mcpServers: {} };
2303
2399
  }
2304
2400
  function promptIdentity(binding) {
2305
2401
  return binding.model.kind === 'value'
@@ -2917,7 +3013,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2917
3013
  try {
2918
3014
  const ledger = assertPlaybookEffectLedger(currentEffectLedger());
2919
3015
  carried = carriedPreExistingChanges(ledger, presentedEffectPrefix);
2920
- commitStatements = stepCommitStatements(ledger, presentedEffectPrefix);
3016
+ // DR-090 §4: a failed turn states its one-commit steps in the
3017
+ // commits-before-failure report instead (DR-089 §3), never twice.
3018
+ commitStatements = stepCommitStatements(ledger, presentedEffectPrefix, !leafParkedInFailure());
2921
3019
  turn.carriedEffectPrefix = ledger.boundaries.length;
2922
3020
  }
2923
3021
  catch {
@@ -2931,12 +3029,15 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2931
3029
  joinSettlementFact(turn, fact);
2932
3030
  }
2933
3031
  }
2934
- // DR-088 §3: the commits each step took, read from the same boundaries.
2935
- if (commitStatements.length > 0) {
2936
- appendMandatoryPresentationSuffix(turn, commitStatements.join('\n'), commitStatements);
2937
- for (const fact of commitStatements) {
2938
- joinSettlementFact(turn, fact);
2939
- }
3032
+ // DR-088 §3, DR-090 §4: the commits each step took, read from the same
3033
+ // boundaries; a step's one new commit reaches the closing reply's facts
3034
+ // only.
3035
+ const shownStatements = commitStatements.filter((statement) => statement.shown !== undefined);
3036
+ if (shownStatements.length > 0) {
3037
+ appendMandatoryPresentationSuffix(turn, shownStatements.map(({ shown }) => shown).join('\n'), shownStatements.map(({ text }) => text));
3038
+ }
3039
+ for (const { text } of commitStatements) {
3040
+ joinSettlementFact(turn, text);
2940
3041
  }
2941
3042
  };
2942
3043
  /**
@@ -5455,7 +5556,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5455
5556
  ? []
5456
5557
  : [
5457
5558
  {
5458
- text: `Failure: ${failure.sentence}`,
5559
+ // CAPTAIN-69: a mark whose words CAPTAIN-9 refuses — a path
5560
+ // naming control vocabulary, say — reads its code alone.
5561
+ text: `${MARK_FAILED} ${replyRejection(failure.mark) === undefined
5562
+ ? failure.mark
5563
+ : failure.code}`,
5459
5564
  kind: 'failure',
5460
5565
  reason: failure.reason,
5461
5566
  },
@@ -6291,6 +6396,21 @@ export function createPlaybookCaptainShell(options, deps = {}) {
6291
6396
  // DR-063 §4: appended once, beside the unresolved-effect and pre-existing
6292
6397
  // change reports, whenever the turn settles with the leaf parked in its
6293
6398
  // failure state or behind the retained-effect fence.
6399
+ // The condition under which reportLeafFailure states a failure: the leaf
6400
+ // parked in its failure state, or behind the retained-effect fence.
6401
+ const leafParkedInFailure = () => {
6402
+ if (retainedEffectReconciliation !== undefined)
6403
+ return true;
6404
+ const leaf = leafFrame();
6405
+ if (!leaf || typeof leaf.runtime.describe !== 'function')
6406
+ return false;
6407
+ try {
6408
+ return leaf.runtime.describe().state.stateId === 'failed';
6409
+ }
6410
+ catch {
6411
+ return false;
6412
+ }
6413
+ };
6294
6414
  const reportLeafFailure = (turn, unresolvedEffects) => {
6295
6415
  if (turn.failureReported)
6296
6416
  return;
@@ -6329,6 +6449,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
6329
6449
  : statement.restate(failure);
6330
6450
  const speak = statement?.speak ?? ((text) => text);
6331
6451
  turn.failureReport = {
6452
+ mark: markedFailure(cause, describeNestedPlaybook),
6453
+ code: cause.code,
6332
6454
  ...failureLine(noteReason(failureReason(failure, describeNestedPlaybook, speakableWords))),
6333
6455
  controls: failureControlLines(view, fenced),
6334
6456
  };