@sublang/playbook 17.4.0 → 17.5.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.
@@ -446,12 +446,27 @@ export interface PlaybookCaptainShell extends Captain {
446
446
  // normalized `captain.options.playbooks.<id>` role map.
447
447
  interface Enablement {
448
448
  entry: PlaybookCaptainRegistryEntry;
449
+ readonly configuredFrom: string;
449
450
  artifactSchema: 3;
450
451
  command: string;
451
452
  options: JsonValue;
452
453
  roleBindings: ReadonlyMap<string, EffectivePlayerBinding>;
453
454
  }
454
455
 
456
+ function missingPackagedReview(
457
+ enablementById: ReadonlyMap<string, Enablement>,
458
+ playbookId: string,
459
+ ): string | undefined {
460
+ const selected = enablementById.get(playbookId);
461
+ if (
462
+ selected === undefined ||
463
+ enablementById.has('review') ||
464
+ !['code', 'decide'].includes(playbookId) ||
465
+ selected.configuredFrom !== `@sublang/playbook/${playbookId}/registry`
466
+ ) return undefined;
467
+ return `/${selected.command} requires enabled playbook "review"; enable REVIEW before starting this packaged workflow`;
468
+ }
469
+
455
470
  function createRuntimeForEnablement(
456
471
  enablement: Enablement,
457
472
  hostCapabilitiesById: ReadonlyMap<
@@ -4181,6 +4196,7 @@ async function buildEnablements(
4181
4196
  hostCapabilitiesById.set(entry.id, hostCapability);
4182
4197
  enablementById.set(entry.id, {
4183
4198
  entry,
4199
+ configuredFrom: from,
4184
4200
  artifactSchema,
4185
4201
  command,
4186
4202
  options: validatedOptions,
@@ -6142,13 +6158,16 @@ export function createPlaybookCaptainShell(
6142
6158
  enablement,
6143
6159
  hostCapabilitiesById,
6144
6160
  );
6161
+ // A nested child inherits only its parent's selection, never the turn's
6162
+ // unselected references; only a root reads the turn's selection.
6163
+ const inherited = parent ? parent.frame.attachments : (activeTurn?.selectedAttachments ?? activeTurn?.attachments);
6145
6164
  return {
6146
6165
  entry,
6147
6166
  enablement,
6148
6167
  runtime,
6149
6168
  sessionId,
6150
6169
  rootSessionId: parent?.frame.rootSessionId ?? sessionId,
6151
- ...((parent?.frame.attachments ?? activeTurn?.selectedAttachments ?? activeTurn?.attachments)?.length ? { attachments: [...(parent?.frame.attachments ?? activeTurn?.selectedAttachments ?? activeTurn!.attachments)] } : {}),
6170
+ ...(inherited?.length ? { attachments: [...inherited] } : {}),
6152
6171
  depth: parent ? parent.frame.depth + 1 : 0,
6153
6172
  playerBindings,
6154
6173
  ...(parent ? { parent } : {}),
@@ -6486,7 +6505,6 @@ export function createPlaybookCaptainShell(
6486
6505
  } finally {
6487
6506
  if (leafFrame() === frame) {
6488
6507
  frames.pop();
6489
- if (!frame.parent) pendingAttachments = [];
6490
6508
  if (frame.parent) {
6491
6509
  pendingChildParents.delete(frame.parent.frame);
6492
6510
  }
@@ -6930,6 +6948,23 @@ export function createPlaybookCaptainShell(
6930
6948
  return;
6931
6949
  }
6932
6950
  assertRetainableResult(frame, result);
6951
+ // A deferred checkpoint refusal changes the runtime's control overlay
6952
+ // without publishing an FSM transition. Refresh its derived question
6953
+ // mirror from an exact safe snapshot before reporting or saving the stop.
6954
+ if (leafFrame() === frame && !frame.disposing &&
6955
+ retainedEffectReconciliation === undefined) {
6956
+ try {
6957
+ const exported = frame.runtime.exportSnapshot?.();
6958
+ if (exported !== undefined) {
6959
+ const snapshot = assertPlaybookRuntimeSnapshot(exported, frame.entry.id);
6960
+ if (isDeepStrictEqual(snapshot.state, result.state) &&
6961
+ isDeepStrictEqual(snapshot.effectLedger, currentEffectLedger())) {
6962
+ pendingBossQuestions = snapshot.pendingBossQuestions.length === 0 && pendingBossQuestions === undefined
6963
+ ? undefined : mirroredBossQuestions(snapshot.pendingBossQuestions);
6964
+ }
6965
+ }
6966
+ } catch { /* An inconsistent runtime must still fail exact capture. */ }
6967
+ }
6933
6968
  if (result.outcome === 'aborted' && runFailureFacts) {
6934
6969
  runFailureFacts.push(
6935
6970
  `${frameLabel(frame)} was aborted before its outcome could be confirmed; it was not repeated automatically.`,
@@ -7011,6 +7046,9 @@ export function createPlaybookCaptainShell(
7011
7046
  );
7012
7047
  }
7013
7048
 
7049
+ const reviewRefusal = missingPackagedReview(enablementById, entry.id);
7050
+ if (reviewRefusal !== undefined) throw new Error(reviewRefusal);
7051
+
7014
7052
  pendingChildParents.add(parent);
7015
7053
  let child: EngagementFrame;
7016
7054
  try {
@@ -9004,7 +9042,13 @@ export function createPlaybookCaptainShell(
9004
9042
  // its decision-call prose crosses the presentation boundary. Acting
9005
9043
  // selections freeze after their work and before reporting begins.
9006
9044
  if (selection.action === 'respond') freezeControllerEvidence();
9007
- let settlement = await executeSelection(selection, signal, automaticRecovery);
9045
+ signal.throwIfAborted();
9046
+ const reviewRefusal = selection.action === 'start' || selection.action === 'switch'
9047
+ ? missingPackagedReview(enablementById, selection.playbookId)
9048
+ : undefined;
9049
+ let settlement = reviewRefusal === undefined
9050
+ ? await executeSelection(selection, signal, automaticRecovery)
9051
+ : await rejectSelection(selection, reviewRefusal);
9008
9052
  // One controller action can include bounded prerequisite recovery.
9009
9053
  // Neither the model nor a retry manufactures an unadvertised transition.
9010
9054
  if (turn && abortPreparation && settlement.status !== 'rejected' && !['respond', 'dismiss', 'recover'].includes(selection.action)) {
@@ -9548,6 +9592,9 @@ export function createPlaybookCaptainShell(
9548
9592
  const selected = selection.attachmentIds === undefined ? turn.attachments : selection.attachmentIds.map((id) => available.get(id as SessionAssetRef['assetId'])!);
9549
9593
  const nextAttachments = mergeAttachments(leaf.attachments ?? [], selected);
9550
9594
  if (nextAttachments.length) leaf.attachments = nextAttachments;
9595
+ // Delivered references now belong to the leaf; the rest stay pending.
9596
+ const delivered = new Set(selected.map((reference) => reference.assetId));
9597
+ pendingAttachments = pendingAttachments.filter((reference) => !delivered.has(reference.assetId));
9551
9598
  // CAPTAIN-8: delivery carries text only, and the shell is authoritative
9552
9599
  // for that text — any text carried on the selection is ignored.
9553
9600
  turn.settled = true;
@@ -102,9 +102,11 @@ export function createAssetStore(options) {
102
102
  try {
103
103
  const opened = await directoryHandle.stat();
104
104
  if (opened.dev !== before.dev || opened.ino !== before.ino) throw new Error('asset owner directory changed during preparation');
105
- if (process.platform !== 'win32') await directoryHandle.chmod(0o700);
105
+ // fchmod always moves ctime, which verified readers pin: change only modes that differ.
106
+ if (process.platform !== 'win32' && (opened.mode & 0o777) !== 0o700) await directoryHandle.chmod(0o700);
106
107
  for (const name of await readdir(directory)) {
107
- if (!/^(?:[0-9a-f]{64}(?:\.json)?|\.import-[0-9a-f-]+\.tmp)$/.test(name)) throw new Error('unknown asset owner entry');
108
+ // Foreign names (Finder or editor files) are neither assets nor ours to modify.
109
+ if (!/^(?:[0-9a-f]{64}(?:\.json)?|\.import-[0-9a-f-]+\.tmp)$/.test(name)) continue;
108
110
  const path = join(directory, name);
109
111
  const stat = await lstat(path);
110
112
  if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error('asset entry must be a single-link regular file');
@@ -114,7 +116,7 @@ export function createAssetStore(options) {
114
116
  try {
115
117
  const pinned = await handle.stat();
116
118
  if (!sameFile(stat, pinned)) throw new Error('asset entry changed during preparation');
117
- if (process.platform !== 'win32') await handle.chmod(0o600);
119
+ if (process.platform !== 'win32' && (pinned.mode & 0o777) !== 0o600) await handle.chmod(0o600);
118
120
  assertFile(await handle.stat(), 'asset entry');
119
121
  const final = await lstat(path);
120
122
  if (final.dev !== pinned.dev || final.ino !== pinned.ino || final.nlink !== 1) throw new Error('asset entry changed during preparation');
@@ -286,9 +288,8 @@ export function createAssetStore(options) {
286
288
  const entries = await readdir(directory);
287
289
  const result = [];
288
290
  for (const name of entries.sort()) {
289
- if (/^\.import-[0-9a-f-]+\.tmp$/.test(name)) continue;
290
- if (/^[0-9a-f]{64}\.json$/.test(name)) continue;
291
- if (!/^[0-9a-f]{64}$/.test(name)) throw new Error(`unknown asset owner entry ${JSON.stringify(basename(name))}`);
291
+ // Descriptors, import temporaries and foreign files are not enumerated.
292
+ if (!/^[0-9a-f]{64}$/.test(name)) continue;
292
293
  if (!entries.includes(`${name}.json`)) { const orphan = await checkedOpen(name, 'unreferenced asset content'); await orphan.handle.close(); continue; }
293
294
  const reader = await openAsset(`sha256:${name}`); await reader.close();
294
295
  result.push(Object.freeze({ assetId: reader.reference.assetId, path: name, metadataPath: `${name}.json`, byteLength: reader.reference.byteLength }));
@@ -43,8 +43,15 @@ export declare function installRetainedGenerationsForLaunch(options: {
43
43
  export declare function executionConfigFromPlan(plan: any): SessionExecutionProjection;
44
44
  export declare function validateFrozenExecutionConfig(structural: SessionStructuralProjection, execution: SessionExecutionProjection, dependencies: { loadModule: (specifier: string) => Promise<any>; prepareRegistryModule?: (request: {id: string; from: string; authoredFrom: string}) => Promise<string | void> }): Promise<SessionExecutionProjection>;
45
45
  export declare function driveHeadlessCaptainTurn(options: any): Promise<any>;
46
- export declare function normalizeLaunchPlan(top: any, options?: any): Promise<any>;
47
- export declare function loadLaunchPlan(options: any): Promise<any>;
46
+ /** Playbook id to registry module specifier, supplied at launch ahead of `playbooks.<id>.from` (DR-083). */
47
+ export type PlaybookModuleLocations = Readonly<Record<string, string>>;
48
+ export interface LaunchPlanModuleOptions {
49
+ /** Absolute paths become file URLs; file URLs and package specifiers are kept; relative paths are refused. */
50
+ readonly modules?: PlaybookModuleLocations;
51
+ readonly [key: string]: any;
52
+ }
53
+ export declare function normalizeLaunchPlan(top: any, options?: LaunchPlanModuleOptions): Promise<any>;
54
+ export declare function loadLaunchPlan(options: LaunchPlanModuleOptions): Promise<any>;
48
55
  export declare function composeGenericConfig(top: any, loadModule: (specifier: string) => Promise<any>, configPath?: string): Promise<any>;
49
56
  export declare function projectTmuxConfig(plan: any): any;
50
57
  export declare function resolveLaunchSessionsDir(options: any): string;
package/slc/gears2fsm.md CHANGED
@@ -602,6 +602,9 @@ safety beyond the guarded direct transition.
602
602
  ## Context and prompts
603
603
 
604
604
  Context fields used to drive guards or compose prompts shall be **typed and named**.
605
+ Where Source owns array-valued retained discussion whose initial state is no history, initialize that context field to `[]` and reset it to `[]` only at its Source lifecycle boundaries.
606
+ Carry its declared array shape through ordinary and continuation `invoke.input`; do not leave the empty structural history absent so a prompt-contract probe mistakes it for a scalar.
607
+ This empty history is not a default caller intent, prerequisite report, domain evidence, approval, or prior-stage result; those remain unavailable until actually established.
605
608
  The compiler shall not branch on untyped properties of `lastResult`; persistent routing decisions belong in typed context fields. (`lastResult` is for inspection only.)
606
609
  Where Source declares a finite ordered plan, represent it as a typed readonly
607
610
  JSON-safe array and validate that shape on the actor-output transition; an
@@ -835,8 +838,8 @@ Only
835
838
  The canonical storage paths are `context.pendingBossQuestion` and `context.bossReply` for the scalar form, or `context.pendingBossQuestions[stateId]` and `context.bossReplies[stateId]` for the keyed form.
836
839
  A private wrapper such as `context.continuation` shall not replace these fields directly on machine context.
837
840
 
838
- A machine with at most one active Captain or player task may use the scalar
839
- form:
841
+ A machine that declares no root parallel group shall use the scalar form;
842
+ keyed storage alone is not visible to the flat shared factory:
840
843
 
841
844
  - An `awaitBossReply` state with stable `id: 'awaitBossReply'`, tag
842
845
  `playbook.parked`, and description
@@ -847,7 +850,9 @@ form:
847
850
  - `resumableStates(ids)`, `setPendingBossQuestion`, and
848
851
  `clearBossReplyContext` helpers with the existing single-question behavior.
849
852
 
850
- A machine with parallel delegated-player tasks shall use the keyed form:
853
+ A machine that declares a root parallel group shall use the keyed form
854
+ throughout the machine, including any sequential working leaves and their
855
+ root reply wait:
851
856
 
852
857
  - One local waiting leaf per branch, tagged `playbook.parked`.
853
858
  - A `BOSS_REPLY` event carrying `{ questionId: string; answer: string }`.
@@ -884,6 +889,15 @@ pending question and reply selected for that working leaf as singular
884
889
  `pendingBossQuestion` and `bossReply` fields, regardless of the scalar or keyed
885
890
  context representation, so prompt composition has one stable contract.
886
891
  When Source requires earlier discussion or constraints on a later invocation, the compiler shall persist that source-owned history in serializable machine context before replacing or clearing the pending Q/A fields.
892
+ Where that Source-owned value is the complete received Boss reply, retain it
893
+ directly from the current working leaf's canonical reply context at the
894
+ Source-authored accepted-outcome boundary, before clearing that leaf's Q/A.
895
+ Preserve its exact string, including surrounding whitespace, CRLF/LF separators,
896
+ blank lines and trailing separators; do not require a model-produced echo or
897
+ replace it with one. Acceptance remains the acting agent's semantic judgment,
898
+ guarded by the current matching question's state, Source-item and asker binding
899
+ and actual receipt of a nonempty reply. Neither stale question/reply context
900
+ nor a different actor-supplied value establishes that binding or approval.
887
901
  The later `invoke.input` shall relay that persisted context on both resumed and fresh calls that Source says need it, preserving Source-owned relevance and format without imposing all-history semantics on sources that do not require it.
888
902
  Shared Boss-reply continuation carries only the latest Q/A pair, and a backend continuation token is not durable Source history.
889
903
  When both fields are present, the linked runtime shall compose the continuation preamble and labelled Q&A blocks per [link.md "Player prompt composition"](link.md#player-prompt-composition).
package/slc/text2gears.md CHANGED
@@ -189,6 +189,13 @@ between the Source and the catalog: leave the Target unwritten and report it
189
189
  as an incompatible compiler input rather than rename or invent.
190
190
  A Source outside those ids is named from its own words as above.
191
191
 
192
+ A value Source assigns to the received Boss input or reply is already runtime
193
+ input, not a value the acting agent produces. Where Source requires that
194
+ complete value retained for later calls, preserve the exact input's retention
195
+ and the Source's acceptance condition in non-acting GEARS prose; do not declare
196
+ a Judge-extracted echo property for it. The semantic approval or refusal stays
197
+ in the acting result contract.
198
+
192
199
  A produced value consumed later shall have a declared producer: where any
193
200
  later item's blockquote reads a value through a `<placeholder>`, the item
194
201
  whose behavior produces that value shall declare the `Results:` contract
@@ -866,12 +866,16 @@ export function defaultExtractRequiredFields(description) {
866
866
  fields.push(m[1]);
867
867
  return fields;
868
868
  }
869
+ const CURRENT_CALL_ADJUDICATION_GUIDANCE = "Classify the invoked role's current call by the result it reports at call end, using the full output and declared outcome meanings. " +
870
+ 'An earlier interim question is not outstanding when later prose explicitly resolves or supersedes it for this call; a prerequisite only for a later workflow stage does not block an affirmatively completed current call. ' +
871
+ 'A genuinely unanswered question needed to finish the current call remains outstanding. A commit statement alone does not establish semantic completion; do not force contradictory or insufficient evidence into a completed outcome.';
869
872
  /** Default delegated-player adjudicator prompt. */
870
873
  export function defaultBuildJudgePrompt(input, finalText) {
871
874
  const lines = [];
872
875
  lines.push('This is hidden control work. Do not call tools, inspect files, or ' +
873
876
  'seek external evidence. Decide only from the supplied player output ' +
874
877
  'and outcome descriptions. Reply with exactly one JSON object and no prose.');
878
+ lines.push(CURRENT_CALL_ADJUDICATION_GUIDANCE);
875
879
  lines.push('');
876
880
  lines.push(`The ${input.role} role just produced this output:`);
877
881
  lines.push('');
@@ -957,6 +961,7 @@ function buildGovernedJudgePrompt(input, finalText, outcomes, correction) {
957
961
  const lines = [
958
962
  'This is hidden control work. Do not call tools, inspect files, or seek external evidence.',
959
963
  'Decide only from the supplied player output and declared outcomes.',
964
+ CURRENT_CALL_ADJUDICATION_GUIDANCE,
960
965
  'Reply with exactly one JSON object and no prose.',
961
966
  '',
962
967
  `The ${input.role} role just produced this output:`,
@@ -1706,6 +1706,11 @@ export function defaultExtractRequiredFields(description: string): string[] {
1706
1706
  return fields;
1707
1707
  }
1708
1708
 
1709
+ const CURRENT_CALL_ADJUDICATION_GUIDANCE =
1710
+ "Classify the invoked role's current call by the result it reports at call end, using the full output and declared outcome meanings. " +
1711
+ 'An earlier interim question is not outstanding when later prose explicitly resolves or supersedes it for this call; a prerequisite only for a later workflow stage does not block an affirmatively completed current call. ' +
1712
+ 'A genuinely unanswered question needed to finish the current call remains outstanding. A commit statement alone does not establish semantic completion; do not force contradictory or insufficient evidence into a completed outcome.';
1713
+
1709
1714
  /** Default delegated-player adjudicator prompt. */
1710
1715
  export function defaultBuildJudgePrompt(
1711
1716
  input: PlaybookPlayerInput,
@@ -1717,6 +1722,7 @@ export function defaultBuildJudgePrompt(
1717
1722
  'seek external evidence. Decide only from the supplied player output ' +
1718
1723
  'and outcome descriptions. Reply with exactly one JSON object and no prose.',
1719
1724
  );
1725
+ lines.push(CURRENT_CALL_ADJUDICATION_GUIDANCE);
1720
1726
  lines.push('');
1721
1727
  lines.push(`The ${input.role} role just produced this output:`);
1722
1728
  lines.push('');
@@ -1822,6 +1828,7 @@ function buildGovernedJudgePrompt(
1822
1828
  const lines = [
1823
1829
  'This is hidden control work. Do not call tools, inspect files, or seek external evidence.',
1824
1830
  'Decide only from the supplied player output and declared outcomes.',
1831
+ CURRENT_CALL_ADJUDICATION_GUIDANCE,
1825
1832
  'Reply with exactly one JSON object and no prose.',
1826
1833
  '',
1827
1834
  `The ${input.role} role just produced this output:`,