@sublang/playbook 13.2.0 → 14.0.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.
- package/README.md +5 -5
- package/docs/configuration.md +14 -9
- package/docs/embedding.md +20 -1
- package/package.json +6 -1
- package/reference/sdlc/code.playbook/bin/launch-config.js +88 -5
- package/reference/sdlc/code.playbook/bin/playbook.js +4 -0
- package/reference/sdlc/code.playbook/bin/repository-effects.js +11 -3
- package/reference/sdlc/code.playbook/bin/run.js +4 -0
- package/reference/sdlc/code.playbook/bin/session-host.js +13 -3
- package/reference/sdlc/code.playbook/bin/session-store.js +2 -2
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +26 -1
- package/reference/sdlc/code.playbook/playbook-captain.js +233 -8
- package/reference/sdlc/code.playbook/playbook-captain.ts +298 -8
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +5 -6
- package/reference/sdlc/code.playbook/session-host.d.ts +26 -0
- package/reference/sdlc/dev.playbook/dev.playbook.d.ts +3 -10
- package/reference/sdlc/dev.playbook/dev.playbook.js +5 -5
- package/reference/sdlc/dev.playbook/dev.playbook.ts +7 -7
- package/reference/sdlc/pr.md +17 -4
- package/reference/sdlc/pr.playbook/pr.fsm.d.ts +17 -3
- package/reference/sdlc/pr.playbook/pr.fsm.js +49 -18
- package/reference/sdlc/pr.playbook/pr.fsm.ts +63 -20
- package/reference/sdlc/pr.playbook/pr.gears.md +20 -7
- package/reference/sdlc/pr.playbook/pr.playbook.js +2 -1
- package/reference/sdlc/pr.playbook/pr.playbook.ts +2 -1
- package/slc/experiments/fsm-scaffold-guidance.md +31 -0
- package/slc/gears2fsm.md +145 -58
- package/slc/link.md +158 -7
- package/slc/materialize-link.mjs +460 -0
- package/slc/optimize.md +46 -6
- package/slc/scaffold-fsm.mjs +225 -0
- package/slc/slc.pin-inputs.json +13 -0
- package/slc/text2gears.md +38 -3
- package/slc/workflow-contracts.json +371 -0
- package/src/xstate-playbook-runtime.js +35 -1
- package/src/xstate-playbook-runtime.ts +54 -1
|
@@ -77,6 +77,9 @@ function isCanonicalLocalRoleId(value) {
|
|
|
77
77
|
}
|
|
78
78
|
const UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID = 'reconcile:unresolved-effect';
|
|
79
79
|
const UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID = 'abandon:unresolved-effect';
|
|
80
|
+
// CAPTAIN-62: the shell's own control, not a runtime action. The id is machine
|
|
81
|
+
// data the host echoes back; the Boss never sees it (CAPPLAY-5).
|
|
82
|
+
const GIVE_UP_ACTION_ID = 'give-up';
|
|
80
83
|
// The fixed machine-syntax guard of a player's Boss-question suspension
|
|
81
84
|
// (slc/link.md §Boss-reply suspension): the accepted outcome that parks on
|
|
82
85
|
// Boss rather than sparing Boss a relay.
|
|
@@ -1732,6 +1735,12 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
1732
1735
|
// CAPTAIN-40's corrective re-ask on the very call whose prose it surfaces —
|
|
1733
1736
|
// the selection reaches the controller port after that call's frame is gone.
|
|
1734
1737
|
let decisionCall;
|
|
1738
|
+
// CAPTAIN-7: the control the host selected for the turn it is about to
|
|
1739
|
+
// submit, held with the exact Boss text that turn carries so an unrelated
|
|
1740
|
+
// turn never consumes the selection. One turn consumes it, whichever it is,
|
|
1741
|
+
// and one slot holds it whichever surface it came from — the leaf's
|
|
1742
|
+
// advertised actions or the shell's own controls (CAPTAIN-62).
|
|
1743
|
+
let pendingHostSelection;
|
|
1735
1744
|
let lastAction;
|
|
1736
1745
|
let lastSettlementStatus;
|
|
1737
1746
|
const retainedGenerationCandidates = new Map();
|
|
@@ -3312,6 +3321,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3312
3321
|
* may return an executed receipt to its result phase.
|
|
3313
3322
|
*/
|
|
3314
3323
|
const settleUnresolvedEffectAbandonment = async (unresolvedLeaf) => {
|
|
3324
|
+
abandonmentSettlementUnsafe = true;
|
|
3315
3325
|
if (leafFrame() !== unresolvedLeaf) {
|
|
3316
3326
|
throw new Error('unresolved-effect abandonment requires the active leaf');
|
|
3317
3327
|
}
|
|
@@ -3338,6 +3348,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3338
3348
|
rootPlaybookId,
|
|
3339
3349
|
});
|
|
3340
3350
|
await runEffect(() => unresolvedEffectSettlement.complete(settlement));
|
|
3351
|
+
abandonmentSettlementUnsafe = false;
|
|
3341
3352
|
};
|
|
3342
3353
|
const callResultFor = (frame, result) => {
|
|
3343
3354
|
if (result.outcome === 'unresolved-effect') {
|
|
@@ -3889,6 +3900,118 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
3889
3900
|
lines.push(retainedResumptionDigest());
|
|
3890
3901
|
return lines.join('\n');
|
|
3891
3902
|
};
|
|
3903
|
+
// CAPTAIN-60: the same reading the ControlView digest performs, published as
|
|
3904
|
+
// data instead of prose. It states what the digest states and withholds what
|
|
3905
|
+
// the digest withholds: an idle shell, a leaf with no control surface, and a
|
|
3906
|
+
// control view that cannot be read all advertise nothing, and a leaf fenced
|
|
3907
|
+
// for repository-effect reconciliation advertises only its fence controls.
|
|
3908
|
+
// The pairs are detached and frozen, so a host holds the shell's reading
|
|
3909
|
+
// rather than a live view the runtime can rewrite underneath it.
|
|
3910
|
+
const advertisedRuntimeActions = () => {
|
|
3911
|
+
const empty = Object.freeze([]);
|
|
3912
|
+
if (lifecycle !== 'ready' || terminallyDisposed)
|
|
3913
|
+
return empty;
|
|
3914
|
+
// Between turns: while a turn is running, the leaf's control view is the
|
|
3915
|
+
// decision's grounding and the host can act on no selection, so the shell
|
|
3916
|
+
// publishes none and reads nothing from a runtime mid-run.
|
|
3917
|
+
if (activeTurn !== undefined || activeTurnHostCalls !== undefined) {
|
|
3918
|
+
return empty;
|
|
3919
|
+
}
|
|
3920
|
+
const leaf = leafFrame();
|
|
3921
|
+
if (!leaf || typeof leaf.runtime.describe !== 'function')
|
|
3922
|
+
return empty;
|
|
3923
|
+
refreshRetainedEffectFence();
|
|
3924
|
+
const fenced = retainedEffectReconciliation !== undefined;
|
|
3925
|
+
if (fenced && typeof leaf.runtime.apply !== 'function')
|
|
3926
|
+
return empty;
|
|
3927
|
+
let actions;
|
|
3928
|
+
try {
|
|
3929
|
+
actions = leaf.runtime.describe().actions;
|
|
3930
|
+
}
|
|
3931
|
+
catch {
|
|
3932
|
+
// An unreadable control view advertises nothing.
|
|
3933
|
+
return empty;
|
|
3934
|
+
}
|
|
3935
|
+
return Object.freeze(actions
|
|
3936
|
+
.filter((action) => !fenced ||
|
|
3937
|
+
action.id === UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID ||
|
|
3938
|
+
action.id === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID)
|
|
3939
|
+
.map((action) => Object.freeze({ id: action.id, label: action.label })));
|
|
3940
|
+
};
|
|
3941
|
+
// CAPTAIN-62: the controls the shell effects itself, published beside the
|
|
3942
|
+
// leaf's advertised actions and never mixed with them — these are the
|
|
3943
|
+
// shell's own, so an engaged root offers one whatever its leaf advertises,
|
|
3944
|
+
// fenced or not. A fenced root keeps its control because giving up reports
|
|
3945
|
+
// unresolved effects (CAPTAIN-58) rather than reconciling them, and a run
|
|
3946
|
+
// whose effects are unknown is exactly the run a Boss gives up on.
|
|
3947
|
+
const advertisedShellActions = () => {
|
|
3948
|
+
const empty = Object.freeze([]);
|
|
3949
|
+
if (lifecycle !== 'ready' || terminallyDisposed)
|
|
3950
|
+
return empty;
|
|
3951
|
+
// Between turns, on the same rule the leaf's actions take: mid-turn the
|
|
3952
|
+
// host can act on no selection.
|
|
3953
|
+
if (activeTurn !== undefined || activeTurnHostCalls !== undefined) {
|
|
3954
|
+
return empty;
|
|
3955
|
+
}
|
|
3956
|
+
const root = rootFrame();
|
|
3957
|
+
if (!root)
|
|
3958
|
+
return empty;
|
|
3959
|
+
return Object.freeze([
|
|
3960
|
+
Object.freeze({
|
|
3961
|
+
id: GIVE_UP_ACTION_ID,
|
|
3962
|
+
// The label is the turn's durable Boss text, so it names the run the
|
|
3963
|
+
// way the Boss started it rather than the frame that happens to be
|
|
3964
|
+
// parked inside it.
|
|
3965
|
+
label: `Stop ${frameLabel(root)}`,
|
|
3966
|
+
}),
|
|
3967
|
+
]);
|
|
3968
|
+
};
|
|
3969
|
+
// CAPTAIN-7: the host's selection of the shell's own control, validated here
|
|
3970
|
+
// against the same live reading. The returned text is the turn the host
|
|
3971
|
+
// submits, exactly as for an advertised runtime action.
|
|
3972
|
+
const selectShellAction = (actionId) => {
|
|
3973
|
+
if (lifecycle !== 'ready' || terminallyDisposed) {
|
|
3974
|
+
throw new Error('a shell control requires an initialized or restored Captain shell');
|
|
3975
|
+
}
|
|
3976
|
+
if (activeTurn !== undefined || activeTurnHostCalls !== undefined) {
|
|
3977
|
+
throw new Error('a shell control cannot be selected during a Boss turn');
|
|
3978
|
+
}
|
|
3979
|
+
if (typeof actionId !== 'string' || actionId.length === 0) {
|
|
3980
|
+
throw new TypeError('a shell control id must be a nonempty string');
|
|
3981
|
+
}
|
|
3982
|
+
const advertised = advertisedShellActions().find((action) => action.id === actionId);
|
|
3983
|
+
if (advertised === undefined) {
|
|
3984
|
+
throw new Error('the shell does not advertise that control');
|
|
3985
|
+
}
|
|
3986
|
+
pendingHostSelection = { kind: 'give-up', text: advertised.label };
|
|
3987
|
+
return advertised.label;
|
|
3988
|
+
};
|
|
3989
|
+
// CAPTAIN-7: the host's own selection of one advertised action, validated
|
|
3990
|
+
// here against the same live reading and validated again at the controller
|
|
3991
|
+
// port before any effect. The returned text is the turn the host submits.
|
|
3992
|
+
const selectRuntimeAction = (actionId) => {
|
|
3993
|
+
if (lifecycle !== 'ready' || terminallyDisposed) {
|
|
3994
|
+
throw new Error('a runtime action requires an initialized or restored Captain shell');
|
|
3995
|
+
}
|
|
3996
|
+
if (activeTurn !== undefined || activeTurnHostCalls !== undefined) {
|
|
3997
|
+
throw new Error('a runtime action cannot be selected during a Boss turn');
|
|
3998
|
+
}
|
|
3999
|
+
if (typeof actionId !== 'string' || actionId.length === 0) {
|
|
4000
|
+
throw new TypeError('a runtime action id must be a nonempty string');
|
|
4001
|
+
}
|
|
4002
|
+
const advertised = advertisedRuntimeActions().find((action) => action.id === actionId);
|
|
4003
|
+
if (advertised === undefined) {
|
|
4004
|
+
// CAPPLAY-5: the chosen id is control data whether or not the leaf
|
|
4005
|
+
// advertises it, so the refusal names the fact and not the string.
|
|
4006
|
+
throw new Error('the active leaf does not advertise that action');
|
|
4007
|
+
}
|
|
4008
|
+
const text = typeof advertised.label === 'string' ? advertised.label.trim() : '';
|
|
4009
|
+
if (text.length === 0) {
|
|
4010
|
+
throw new Error('the advertised action carries no Boss-facing label to submit as its turn');
|
|
4011
|
+
}
|
|
4012
|
+
pendingHostSelection = { kind: 'runtime', actionId, text };
|
|
4013
|
+
return text;
|
|
4014
|
+
};
|
|
3892
4015
|
// The catalog is registry-authored, not shell-authored: an id, a command,
|
|
3893
4016
|
// and an intent all arrive from an enabled module. They pass the same seam
|
|
3894
4017
|
// the ControlView lines do, so an intent carrying a newline cannot open a
|
|
@@ -4341,6 +4464,42 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
4341
4464
|
? []
|
|
4342
4465
|
: [labeledBlock('Conversation recap', options.reseedDigest)]),
|
|
4343
4466
|
]);
|
|
4467
|
+
// CAPTAIN-7: a turn whose decision the host already made allocates no
|
|
4468
|
+
// decision call. The host's selection is this state's decision reply, so
|
|
4469
|
+
// it passes the runtime's own reply validation and reaches the shell
|
|
4470
|
+
// through the controller port like every other selection — validation,
|
|
4471
|
+
// execution, the outcome report, and the closing reply are the loop's.
|
|
4472
|
+
if (kind === 'decision' && turn?.hostRuntimeActionId !== undefined) {
|
|
4473
|
+
return {
|
|
4474
|
+
status: 'ok',
|
|
4475
|
+
finalText: JSON.stringify({
|
|
4476
|
+
action: 'runtime',
|
|
4477
|
+
actionId: turn.hostRuntimeActionId,
|
|
4478
|
+
}),
|
|
4479
|
+
};
|
|
4480
|
+
}
|
|
4481
|
+
// CAPTAIN-63: a give-up allocates neither call. Its decision is the
|
|
4482
|
+
// host's, and its result phase is answered below from the settlement the
|
|
4483
|
+
// shell already holds, so an exit from a failing provider does not ask
|
|
4484
|
+
// that provider for permission.
|
|
4485
|
+
if (kind === 'decision' && turn?.hostGiveUp === true) {
|
|
4486
|
+
return {
|
|
4487
|
+
status: 'ok',
|
|
4488
|
+
finalText: JSON.stringify({ action: 'dismiss' }),
|
|
4489
|
+
};
|
|
4490
|
+
}
|
|
4491
|
+
if (kind === 'closingReply' && turn?.hostGiveUp === true) {
|
|
4492
|
+
// The durable conversation did not receive this turn, so it is marked
|
|
4493
|
+
// for the CAPTAIN-35 catch-up rather than left believing it is
|
|
4494
|
+
// current: the next call carries the journal records it missed.
|
|
4495
|
+
markConversationCatchUp();
|
|
4496
|
+
// Through the one presentation seam, exactly where a model-composed
|
|
4497
|
+
// reply is presented, so this reply is journaled and single-attempt
|
|
4498
|
+
// like every other. It needs no reply validation or corrective re-ask:
|
|
4499
|
+
// `giveUpReplyText` already validated what it composed.
|
|
4500
|
+
await surfaceSettlement({ context, text: giveUpReplyText() });
|
|
4501
|
+
return { status: 'ok', finalText: 'ok' };
|
|
4502
|
+
}
|
|
4344
4503
|
const outcome = await durableCall(context, compose);
|
|
4345
4504
|
if (kind === 'decision') {
|
|
4346
4505
|
// A model-decided `respond` surfaces this call's own prose, so the
|
|
@@ -4536,9 +4695,25 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
4536
4695
|
if (!root)
|
|
4537
4696
|
return false;
|
|
4538
4697
|
const label = frameLabel(root);
|
|
4698
|
+
if (activeTurn?.hostGiveUp === true &&
|
|
4699
|
+
freezeTurnUnresolvedEffects().length > 0) {
|
|
4700
|
+
await settleUnresolvedEffectAbandonment(leafFrame());
|
|
4701
|
+
facts.push(`Dismissed the ${label} engagement with unresolved repository effects reported.`);
|
|
4702
|
+
return false;
|
|
4703
|
+
}
|
|
4539
4704
|
// Dismissal leaves the procedure unfinished. Persist the latest safe
|
|
4540
|
-
// generation captured for this turn before disposal erases the frames
|
|
4541
|
-
|
|
4705
|
+
// generation captured for this turn before disposal erases the frames —
|
|
4706
|
+
// unless the Boss gave up on it (CAPTAIN-44), in which case retaining it
|
|
4707
|
+
// would offer the abandoned run back on the idle digest's next reading.
|
|
4708
|
+
if (activeTurn?.hostGiveUp === true) {
|
|
4709
|
+
pendingRetentionUpdates.set(root.entry.id, {
|
|
4710
|
+
kind: 'clear',
|
|
4711
|
+
rootPlaybookId: root.entry.id,
|
|
4712
|
+
});
|
|
4713
|
+
}
|
|
4714
|
+
else {
|
|
4715
|
+
retainOrClearDisposedRoot(root);
|
|
4716
|
+
}
|
|
4542
4717
|
try {
|
|
4543
4718
|
await runEffect(() => disposeStack('dismiss'));
|
|
4544
4719
|
facts.push(`Dismissed the ${label} engagement.`);
|
|
@@ -4899,7 +5074,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
4899
5074
|
selection.actionId === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID) &&
|
|
4900
5075
|
fencedLeaf !== undefined;
|
|
4901
5076
|
if (retainedEffectReconciliation !== undefined &&
|
|
4902
|
-
!routesRetainedReconciliation
|
|
5077
|
+
!routesRetainedReconciliation &&
|
|
5078
|
+
!(selection.action === 'dismiss' && turn.hostGiveUp === true)) {
|
|
4903
5079
|
return rejectSelection(selection, 'retained work must reconcile its repository-effect evidence before an ordinary action can run');
|
|
4904
5080
|
}
|
|
4905
5081
|
if (selection.action === 'resume') {
|
|
@@ -4994,10 +5170,16 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
4994
5170
|
if (!leaf) {
|
|
4995
5171
|
return rejectSelection(selection, 'no engagement is active to dismiss');
|
|
4996
5172
|
}
|
|
5173
|
+
// CAPTAIN-63: a give-up ends the run. An ordinary dismissal of a nested
|
|
5174
|
+
// call returns to its caller, which is the right reading of "stop this
|
|
5175
|
+
// call" and the wrong one of "stop this workflow" — the Boss gave up on
|
|
5176
|
+
// the root, not on whichever child was parked when they did.
|
|
5177
|
+
const giveUp = turn.hostGiveUp === true;
|
|
5178
|
+
const dismissed = giveUp ? (rootFrame() ?? leaf) : leaf;
|
|
4997
5179
|
turn.settled = true;
|
|
4998
|
-
journalAction({ action: 'dismiss', playbookId:
|
|
4999
|
-
const label = frameLabel(
|
|
5000
|
-
if (leaf.parent) {
|
|
5180
|
+
journalAction({ action: 'dismiss', playbookId: dismissed.entry.id });
|
|
5181
|
+
const label = frameLabel(dismissed);
|
|
5182
|
+
if (leaf.parent && !giveUp) {
|
|
5001
5183
|
// No boundary around the return itself: `resumeParent` disposes the
|
|
5002
5184
|
// child and drives the parent, and each of those is marked where it
|
|
5003
5185
|
// happens. A visibility rejection raised on the way back is shell
|
|
@@ -5186,7 +5368,6 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
5186
5368
|
await settleUnresolvedEffectAbandonment(leaf);
|
|
5187
5369
|
}
|
|
5188
5370
|
catch (error) {
|
|
5189
|
-
abandonmentSettlementUnsafe = true;
|
|
5190
5371
|
const normalized = normalizeErrorCompact(error) ?? {
|
|
5191
5372
|
name: 'Error',
|
|
5192
5373
|
message: String(error),
|
|
@@ -5291,6 +5472,33 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
5291
5472
|
// The reply composes from the authoritative report, never the early
|
|
5292
5473
|
// `settled` guard. That guard closes duplicate submissions before an effect
|
|
5293
5474
|
// starts; it is not evidence that anything ran.
|
|
5475
|
+
// CAPTAIN-63: the give-up's closing reply, composed from the same settlement
|
|
5476
|
+
// facts the result-phase prompt would have carried. Shell-authored Boss
|
|
5477
|
+
// prose passes the validation every model reply passes (CAPTAIN-34); what it
|
|
5478
|
+
// interpolates is not host-authored all the way down, so a fact set that
|
|
5479
|
+
// fails validation is dropped rather than spoken and the reply still states
|
|
5480
|
+
// the settlement truthfully.
|
|
5481
|
+
const giveUpReplyText = () => {
|
|
5482
|
+
const report = activeTurn?.report;
|
|
5483
|
+
const facts = report?.bossFacts ?? report?.facts ?? [];
|
|
5484
|
+
const stopped = report?.status === 'ok';
|
|
5485
|
+
const opening = stopped
|
|
5486
|
+
? 'Stopped that workflow. I will not resume it.'
|
|
5487
|
+
: 'I could not confirm that the workflow was stopped.';
|
|
5488
|
+
const closing = stopped
|
|
5489
|
+
? 'Send the command again to start fresh work.'
|
|
5490
|
+
: 'The stop did not settle successfully; recover the session before continuing.';
|
|
5491
|
+
const composed = [
|
|
5492
|
+
opening,
|
|
5493
|
+
...(facts.length === 0
|
|
5494
|
+
? []
|
|
5495
|
+
: ['Here is what happened:', ...facts.map((fact) => `- ${fact}`)]),
|
|
5496
|
+
closing,
|
|
5497
|
+
].join('\n');
|
|
5498
|
+
return replyRejection(composed) === undefined
|
|
5499
|
+
? composed
|
|
5500
|
+
: [opening, closing].join('\n');
|
|
5501
|
+
};
|
|
5294
5502
|
const failureReplyText = () => {
|
|
5295
5503
|
const commands = [...enablementById.values()]
|
|
5296
5504
|
.map((enablement) => `/${enablement.command} <task>`)
|
|
@@ -6151,6 +6359,10 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
6151
6359
|
exportSettlement,
|
|
6152
6360
|
restore: restoreShellSnapshot,
|
|
6153
6361
|
installRetainedGenerations,
|
|
6362
|
+
describeRuntimeActions: advertisedRuntimeActions,
|
|
6363
|
+
submitRuntimeAction: selectRuntimeAction,
|
|
6364
|
+
describeShellActions: advertisedShellActions,
|
|
6365
|
+
submitShellAction: selectShellAction,
|
|
6154
6366
|
async handleBossTurn(turn, context) {
|
|
6155
6367
|
if (lifecycle !== 'ready' || terminallyDisposed) {
|
|
6156
6368
|
throw new Error('init must be called first, or restore must complete before handling a Boss turn');
|
|
@@ -6184,13 +6396,25 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
6184
6396
|
const turnHostCalls = new Set();
|
|
6185
6397
|
activeTurnHostCalls = turnHostCalls;
|
|
6186
6398
|
activeContext = context;
|
|
6187
|
-
|
|
6399
|
+
// CAPTAIN-7: one host selection is consumed by one turn. It decides only
|
|
6400
|
+
// the turn carrying the exact text the selection was made for; any other
|
|
6401
|
+
// turn drops it and resolves normally.
|
|
6402
|
+
const selected = pendingHostSelection;
|
|
6403
|
+
pendingHostSelection = undefined;
|
|
6404
|
+
const decided = selected !== undefined && selected.text === turn.prompt
|
|
6405
|
+
? selected
|
|
6406
|
+
: undefined;
|
|
6407
|
+
const hostRuntimeActionId = decided?.kind === 'runtime' ? decided.actionId : undefined;
|
|
6408
|
+
const hostGiveUp = decided?.kind === 'give-up' ? true : undefined;
|
|
6409
|
+
const parsed = decided === undefined ? resolveCommandTurn(turn.prompt) : undefined;
|
|
6188
6410
|
activeTurn = {
|
|
6189
6411
|
id: ++turnSequence,
|
|
6190
6412
|
captainSyncedJournalSeq: journalSeq,
|
|
6191
6413
|
bossText: turn.prompt,
|
|
6192
6414
|
authoritativeText: parsed?.authoritativeText ?? turn.prompt,
|
|
6193
6415
|
...(parsed ? { resolution: parsed.resolution } : {}),
|
|
6416
|
+
...(hostRuntimeActionId === undefined ? {} : { hostRuntimeActionId }),
|
|
6417
|
+
...(hostGiveUp === undefined ? {} : { hostGiveUp }),
|
|
6194
6418
|
settled: false,
|
|
6195
6419
|
presentationAttempted: false,
|
|
6196
6420
|
settlementFacts: [],
|
|
@@ -6324,6 +6548,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
|
|
|
6324
6548
|
failure ??= error;
|
|
6325
6549
|
}
|
|
6326
6550
|
}
|
|
6551
|
+
pendingHostSelection = undefined;
|
|
6327
6552
|
// Quarantine is session-wide by design. Only terminal teardown may drop
|
|
6328
6553
|
// its ownership after every frame host call and the Captain are drained.
|
|
6329
6554
|
playerTransactions.clear();
|