@sublang/playbook 17.2.0 → 17.4.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 -2
- package/docs/cli.md +6 -5
- package/docs/configuration.md +72 -18
- package/docs/embedding.md +94 -0
- package/package.json +34 -5
- package/reference/sdlc/captain.md +6 -4
- package/reference/sdlc/captain.playbook/captain.fsm.d.ts +5 -0
- package/reference/sdlc/captain.playbook/captain.fsm.js +4 -3
- package/reference/sdlc/captain.playbook/captain.fsm.ts +9 -4
- package/reference/sdlc/captain.playbook/captain.gears.md +6 -3
- package/reference/sdlc/captain.playbook/captain.playbook.d.ts +2 -0
- package/reference/sdlc/captain.playbook/captain.playbook.js +7 -3
- package/reference/sdlc/captain.playbook/captain.playbook.ts +8 -4
- package/reference/sdlc/code.playbook/asset-types.d.ts +32 -0
- package/reference/sdlc/code.playbook/bin/launch-config.js +84 -31
- package/reference/sdlc/code.playbook/bin/playbook.js +5 -3
- package/reference/sdlc/code.playbook/bin/portable-codec.js +12 -5
- package/reference/sdlc/code.playbook/bin/run.js +74 -7
- package/reference/sdlc/code.playbook/bin/session-host.js +16 -4
- package/reference/sdlc/code.playbook/bin/session-media.js +46 -0
- package/reference/sdlc/code.playbook/bin/session-store.js +164 -25
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +23 -4
- package/reference/sdlc/code.playbook/playbook-captain.js +188 -23
- package/reference/sdlc/code.playbook/playbook-captain.ts +223 -31
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +22 -5
- package/reference/sdlc/code.playbook/session-assets.d.ts +53 -0
- package/reference/sdlc/code.playbook/session-assets.js +302 -0
- package/reference/sdlc/code.playbook/session-host.d.ts +9 -1
- package/reference/sdlc/code.playbook/session-store.d.ts +26 -2
- package/reference/sdlc/code.playbook/worker-evidence.d.ts +8 -0
- package/reference/sdlc/code.playbook/worker-evidence.js +51 -0
- package/reference/sdlc/inspect.md +40 -0
- package/reference/sdlc/inspect.playbook/inspect.fsm.d.ts +168 -0
- package/reference/sdlc/inspect.playbook/inspect.fsm.js +354 -0
- package/reference/sdlc/inspect.playbook/inspect.fsm.ts +455 -0
- package/reference/sdlc/inspect.playbook/inspect.gears.md +36 -0
- package/reference/sdlc/inspect.playbook/inspect.playbook.d.ts +17 -0
- package/reference/sdlc/inspect.playbook/inspect.playbook.js +142 -0
- package/reference/sdlc/inspect.playbook/inspect.playbook.ts +184 -0
- package/reference/sdlc/inspect.playbook/inspect.registry.d.ts +41 -0
- package/reference/sdlc/inspect.playbook/inspect.registry.js +51 -0
- package/reference/sdlc/inspect.playbook/inspect.registry.ts +110 -0
- package/slc/workflow-contracts.json +43 -1
|
@@ -226,6 +226,9 @@ function readDecisionReply(reply, options, selfPlaybookId, declaredActions) {
|
|
|
226
226
|
const nonEmpty = (key) => typeof parsed[key] === 'string' && parsed[key].trim().length > 0
|
|
227
227
|
? undefined
|
|
228
228
|
: `the ${action} selection's \`${key}\` must be a non-empty string`;
|
|
229
|
+
const attachments = parsed.attachmentIds;
|
|
230
|
+
if (attachments !== undefined && (!Array.isArray(attachments) || attachments.some((id) => typeof id !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(id)) || new Set(attachments).size !== attachments.length))
|
|
231
|
+
return { reason: 'attachmentIds must be unique supplied asset identifiers' };
|
|
229
232
|
switch (action) {
|
|
230
233
|
case 'respond': {
|
|
231
234
|
const shape = requireKeys(['text']) ?? nonEmpty('text');
|
|
@@ -252,7 +255,7 @@ function readDecisionReply(reply, options, selfPlaybookId, declaredActions) {
|
|
|
252
255
|
}
|
|
253
256
|
case 'start':
|
|
254
257
|
case 'switch': {
|
|
255
|
-
const shape = requireKeys(['playbookId', 'input']) ??
|
|
258
|
+
const shape = requireKeys(['playbookId', 'input'], ['attachmentIds']) ??
|
|
256
259
|
nonEmpty('playbookId') ??
|
|
257
260
|
nonEmpty('input');
|
|
258
261
|
if (shape !== undefined)
|
|
@@ -273,6 +276,7 @@ function readDecisionReply(reply, options, selfPlaybookId, declaredActions) {
|
|
|
273
276
|
action,
|
|
274
277
|
playbookId,
|
|
275
278
|
input: parsed.input,
|
|
279
|
+
...(attachments === undefined ? {} : { attachmentIds: attachments }),
|
|
276
280
|
},
|
|
277
281
|
};
|
|
278
282
|
}
|
|
@@ -287,10 +291,10 @@ function readDecisionReply(reply, options, selfPlaybookId, declaredActions) {
|
|
|
287
291
|
// A deliver selection carries no text payload: the host is
|
|
288
292
|
// authoritative for the delivered text, so a carried `text` is
|
|
289
293
|
// ignored and never delivered (CAPPLAY-9).
|
|
290
|
-
const shape = requireKeys([], ['text']);
|
|
294
|
+
const shape = requireKeys([], ['text', 'attachmentIds']);
|
|
291
295
|
if (shape !== undefined)
|
|
292
296
|
return { reason: shape };
|
|
293
|
-
return { selection: { action } };
|
|
297
|
+
return { selection: { action, ...(attachments === undefined ? {} : { attachmentIds: attachments }) } };
|
|
294
298
|
}
|
|
295
299
|
case 'runtime': {
|
|
296
300
|
const shape = requireKeys(['actionId']) ?? nonEmpty('actionId');
|
|
@@ -135,9 +135,10 @@ export type CaptainControllerSelection =
|
|
|
135
135
|
readonly playbookId: string;
|
|
136
136
|
/** Complete standalone request synthesized from the remembered Boss conversation. */
|
|
137
137
|
readonly input: CaptainControllerInput;
|
|
138
|
+
readonly attachmentIds?: readonly string[];
|
|
138
139
|
}
|
|
139
140
|
| { readonly action: 'dismiss' | 'recover' }
|
|
140
|
-
| { readonly action: 'deliver' }
|
|
141
|
+
| { readonly action: 'deliver'; readonly attachmentIds?: readonly string[] }
|
|
141
142
|
| { readonly action: 'runtime'; readonly actionId: string };
|
|
142
143
|
|
|
143
144
|
/**
|
|
@@ -444,6 +445,8 @@ function readDecisionReply(
|
|
|
444
445
|
typeof parsed[key] === 'string' && parsed[key].trim().length > 0
|
|
445
446
|
? undefined
|
|
446
447
|
: `the ${action} selection's \`${key}\` must be a non-empty string`;
|
|
448
|
+
const attachments = parsed.attachmentIds;
|
|
449
|
+
if (attachments !== undefined && (!Array.isArray(attachments) || attachments.some((id) => typeof id !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(id)) || new Set(attachments).size !== attachments.length)) return { reason: 'attachmentIds must be unique supplied asset identifiers' };
|
|
447
450
|
switch (action) {
|
|
448
451
|
case 'respond': {
|
|
449
452
|
const shape = requireKeys(['text']) ?? nonEmpty('text');
|
|
@@ -471,7 +474,7 @@ function readDecisionReply(
|
|
|
471
474
|
case 'start':
|
|
472
475
|
case 'switch': {
|
|
473
476
|
const shape =
|
|
474
|
-
requireKeys(['playbookId', 'input']) ??
|
|
477
|
+
requireKeys(['playbookId', 'input'], ['attachmentIds']) ??
|
|
475
478
|
nonEmpty('playbookId') ??
|
|
476
479
|
nonEmpty('input');
|
|
477
480
|
if (shape !== undefined) return { reason: shape };
|
|
@@ -493,6 +496,7 @@ function readDecisionReply(
|
|
|
493
496
|
action,
|
|
494
497
|
playbookId,
|
|
495
498
|
input: parsed.input as string,
|
|
499
|
+
...(attachments === undefined ? {} : { attachmentIds: attachments as string[] }),
|
|
496
500
|
},
|
|
497
501
|
};
|
|
498
502
|
}
|
|
@@ -506,9 +510,9 @@ function readDecisionReply(
|
|
|
506
510
|
// A deliver selection carries no text payload: the host is
|
|
507
511
|
// authoritative for the delivered text, so a carried `text` is
|
|
508
512
|
// ignored and never delivered (CAPPLAY-9).
|
|
509
|
-
const shape = requireKeys([], ['text']);
|
|
513
|
+
const shape = requireKeys([], ['text', 'attachmentIds']);
|
|
510
514
|
if (shape !== undefined) return { reason: shape };
|
|
511
|
-
return { selection: { action } };
|
|
515
|
+
return { selection: { action, ...(attachments === undefined ? {} : { attachmentIds: attachments as string[] }) } };
|
|
512
516
|
}
|
|
513
517
|
case 'runtime': {
|
|
514
518
|
const shape = requireKeys(['actionId']) ?? nonEmpty('actionId');
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
|
+
|
|
4
|
+
/** Cancellation only needs the standard synchronous abort check. */
|
|
5
|
+
export interface AssetAbortSignal { throwIfAborted(): void; }
|
|
6
|
+
export type AssetId = `sha256:${string}`;
|
|
7
|
+
export interface SessionAssetRef {
|
|
8
|
+
readonly assetId: AssetId;
|
|
9
|
+
readonly byteLength: number;
|
|
10
|
+
readonly mimeType: string;
|
|
11
|
+
readonly name?: string;
|
|
12
|
+
}
|
|
13
|
+
export type AssetImport = (
|
|
14
|
+
| { readonly bytes: Uint8Array; readonly path?: never }
|
|
15
|
+
| { readonly path: string; readonly bytes?: never }
|
|
16
|
+
) & { readonly mimeType: string; readonly name?: string; readonly signal?: AssetAbortSignal };
|
|
17
|
+
export interface AssetFile {
|
|
18
|
+
readonly assetId: AssetId;
|
|
19
|
+
/** Relative filename within this owner's asset directory. */
|
|
20
|
+
readonly path: string;
|
|
21
|
+
readonly byteLength: number;
|
|
22
|
+
readonly metadataPath: string;
|
|
23
|
+
}
|
|
24
|
+
export interface AssetReader {
|
|
25
|
+
readonly reference: SessionAssetRef;
|
|
26
|
+
read(options: { readonly offset: number; readonly length: number; readonly signal?: AssetAbortSignal }): Promise<Uint8Array>;
|
|
27
|
+
close(): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
export interface SessionTurnInput {
|
|
30
|
+
readonly text: string;
|
|
31
|
+
readonly attachments?: readonly SessionAssetRef[];
|
|
32
|
+
}
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
parseDocument as parseYamlDocument,
|
|
29
29
|
stringify as stringifyYaml,
|
|
30
30
|
} from "yaml";
|
|
31
|
+
import { isSubagentModelSupported } from "@sublang/cligent";
|
|
31
32
|
import { loadTmuxPlayConfig } from "@sublang/cligent/tmux-play";
|
|
32
33
|
import {
|
|
33
34
|
defaultCaptainSessionsDir,
|
|
@@ -379,12 +380,14 @@ export async function normalizeSelectedLaunchPlanDataOnly(
|
|
|
379
380
|
const configuredAgents = new Map();
|
|
380
381
|
for (const playerId of expectedMembers.playerIds) {
|
|
381
382
|
assertPlayerId(playerId, `players.${playerId}`);
|
|
382
|
-
const agent =
|
|
383
|
-
"id",
|
|
384
|
-
|
|
383
|
+
const agent = resolveDelegationDefault(
|
|
384
|
+
resolveAgent(playersCfg[playerId], `players.${playerId}`, ["id"]),
|
|
385
|
+
);
|
|
385
386
|
configuredAgents.set(playerId, agent);
|
|
386
387
|
}
|
|
387
|
-
let captain =
|
|
388
|
+
let captain = resolveDelegationDefault(
|
|
389
|
+
resolveAgent(top.captain, "captain", ["from", "options"]),
|
|
390
|
+
);
|
|
388
391
|
|
|
389
392
|
const playbooksCfg = requireObject(top.playbooks, "playbooks");
|
|
390
393
|
const tuningChecks = [];
|
|
@@ -443,7 +446,8 @@ export async function normalizeSelectedLaunchPlanDataOnly(
|
|
|
443
446
|
binding.model !== undefined ||
|
|
444
447
|
binding.effort !== undefined ||
|
|
445
448
|
binding.fastMode !== undefined ||
|
|
446
|
-
binding.subagentModel !== undefined
|
|
449
|
+
binding.subagentModel !== undefined ||
|
|
450
|
+
binding.subagentEffort !== undefined
|
|
447
451
|
) {
|
|
448
452
|
let checkId;
|
|
449
453
|
do {
|
|
@@ -559,7 +563,8 @@ export async function normalizeSelectedLaunchPlanDataOnly(
|
|
|
559
563
|
? {}
|
|
560
564
|
: { fastMode: agent.fastMode }
|
|
561
565
|
: { fastMode: binding.fastMode }),
|
|
562
|
-
...
|
|
566
|
+
...effectiveOptionalTuning(binding, agent, "subagentModel"),
|
|
567
|
+
...effectiveOptionalTuning(binding, agent, "subagentEffort"),
|
|
563
568
|
},
|
|
564
569
|
];
|
|
565
570
|
}),
|
|
@@ -689,9 +694,9 @@ export async function normalizeLaunchPlan(
|
|
|
689
694
|
const configuredAgents = new Map();
|
|
690
695
|
for (const playerId of allPlayerIds) {
|
|
691
696
|
assertPlayerId(playerId, `players.${playerId}`);
|
|
692
|
-
const agent =
|
|
693
|
-
"id",
|
|
694
|
-
|
|
697
|
+
const agent = resolveDelegationDefault(
|
|
698
|
+
resolveAgent(playersCfg[playerId], `players.${playerId}`, ["id"]),
|
|
699
|
+
);
|
|
695
700
|
if (
|
|
696
701
|
typeof agent.adapter !== "string" ||
|
|
697
702
|
agent.adapter.trim().length === 0
|
|
@@ -710,7 +715,9 @@ export async function normalizeLaunchPlan(
|
|
|
710
715
|
throw new Error("playbooks keys must be canonical trimmed nonblank ids");
|
|
711
716
|
}
|
|
712
717
|
|
|
713
|
-
let captain =
|
|
718
|
+
let captain = resolveDelegationDefault(
|
|
719
|
+
resolveAgent(top.captain, "captain", ["from", "options"]),
|
|
720
|
+
);
|
|
714
721
|
if (
|
|
715
722
|
typeof captain.adapter !== "string" ||
|
|
716
723
|
captain.adapter.trim().length === 0
|
|
@@ -790,7 +797,8 @@ export async function normalizeLaunchPlan(
|
|
|
790
797
|
binding.model !== undefined ||
|
|
791
798
|
binding.effort !== undefined ||
|
|
792
799
|
binding.fastMode !== undefined ||
|
|
793
|
-
binding.subagentModel !== undefined
|
|
800
|
+
binding.subagentModel !== undefined ||
|
|
801
|
+
binding.subagentEffort !== undefined
|
|
794
802
|
) {
|
|
795
803
|
let checkId;
|
|
796
804
|
do {
|
|
@@ -1002,7 +1010,8 @@ export async function normalizeLaunchPlan(
|
|
|
1002
1010
|
? {}
|
|
1003
1011
|
: { fastMode: agent.fastMode }
|
|
1004
1012
|
: { fastMode: binding.fastMode }),
|
|
1005
|
-
...
|
|
1013
|
+
...effectiveOptionalTuning(binding, agent, "subagentModel"),
|
|
1014
|
+
...effectiveOptionalTuning(binding, agent, "subagentEffort"),
|
|
1006
1015
|
},
|
|
1007
1016
|
];
|
|
1008
1017
|
}),
|
|
@@ -1488,7 +1497,8 @@ function migrateUserConfigIfRetired(userConfigPath, onNotice) {
|
|
|
1488
1497
|
throw new Error(
|
|
1489
1498
|
`cannot migrate the retired profiles config at ${userConfigPath}: ` +
|
|
1490
1499
|
`${errorMessage(error)} — edit it by hand: each agent takes its own ` +
|
|
1491
|
-
"adapter, model, effort, fast mode, subagent model,
|
|
1500
|
+
"adapter, model, effort, fast mode, subagent model, subagent effort, " +
|
|
1501
|
+
"and permissions",
|
|
1492
1502
|
);
|
|
1493
1503
|
}
|
|
1494
1504
|
if (migrated === undefined) return;
|
|
@@ -1624,7 +1634,8 @@ function assertNoRetiredProfiles(top, configPath) {
|
|
|
1624
1634
|
throw new Error(
|
|
1625
1635
|
`top-level "profiles" was removed${where}: write each agent's settings ` +
|
|
1626
1636
|
"inline under captain and each top-level players.<player-id> " +
|
|
1627
|
-
"(adapter, model, effort, fast mode, subagent model,
|
|
1637
|
+
"(adapter, model, effort, fast mode, subagent model, subagent effort, " +
|
|
1638
|
+
"permissions)",
|
|
1628
1639
|
);
|
|
1629
1640
|
}
|
|
1630
1641
|
const legacyPath = findLegacyPlayersPath(top);
|
|
@@ -1640,7 +1651,7 @@ function assertNoRetiredProfiles(top, configPath) {
|
|
|
1640
1651
|
throw new Error(
|
|
1641
1652
|
`${path}.profile was removed${where}: write the agent's settings ` +
|
|
1642
1653
|
"inline in that block (adapter, model, effort, fast mode, " +
|
|
1643
|
-
"subagent model, permissions)",
|
|
1654
|
+
"subagent model, subagent effort, permissions)",
|
|
1644
1655
|
);
|
|
1645
1656
|
}
|
|
1646
1657
|
}
|
|
@@ -2040,13 +2051,20 @@ function resolveRoleBinding(value, path) {
|
|
|
2040
2051
|
const block = requireObject(value, path);
|
|
2041
2052
|
const unknown = Object.keys(block).filter(
|
|
2042
2053
|
(key) =>
|
|
2043
|
-
![
|
|
2054
|
+
![
|
|
2055
|
+
"player",
|
|
2056
|
+
"model",
|
|
2057
|
+
"effort",
|
|
2058
|
+
"fastMode",
|
|
2059
|
+
"subagentModel",
|
|
2060
|
+
"subagentEffort",
|
|
2061
|
+
].includes(key),
|
|
2044
2062
|
);
|
|
2045
2063
|
if (unknown.length > 0) {
|
|
2046
2064
|
throw new Error(`${path} has unknown ${formatKeyList(unknown)}`);
|
|
2047
2065
|
}
|
|
2048
2066
|
assertPlayerId(block.player, `${path}.player`);
|
|
2049
|
-
for (const field of ["model", "effort", "subagentModel"]) {
|
|
2067
|
+
for (const field of ["model", "effort", "subagentModel", "subagentEffort"]) {
|
|
2050
2068
|
if (
|
|
2051
2069
|
block[field] !== undefined &&
|
|
2052
2070
|
block[field] !== false &&
|
|
@@ -2071,19 +2089,41 @@ function resolveRoleBinding(value, path) {
|
|
|
2071
2089
|
...(block.subagentModel === undefined
|
|
2072
2090
|
? {}
|
|
2073
2091
|
: { subagentModel: block.subagentModel }),
|
|
2092
|
+
...(block.subagentEffort === undefined
|
|
2093
|
+
? {}
|
|
2094
|
+
: { subagentEffort: block.subagentEffort }),
|
|
2074
2095
|
};
|
|
2075
2096
|
}
|
|
2076
2097
|
|
|
2077
|
-
// A binding's subagent model overrides the player's;
|
|
2078
|
-
// provider default, which a complete call expresses by
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
if (binding
|
|
2082
|
-
|
|
2098
|
+
// A binding's subagent model or subagent effort overrides the player's;
|
|
2099
|
+
// `false` selects the provider default, which a complete call expresses by
|
|
2100
|
+
// omission (for the effort: the agent chooses per task).
|
|
2101
|
+
function effectiveOptionalTuning(binding, agent, field) {
|
|
2102
|
+
if (binding[field] === false) return {};
|
|
2103
|
+
if (binding[field] !== undefined) return { [field]: binding[field] };
|
|
2104
|
+
return agent[field] === undefined ? {} : { [field]: agent[field] };
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
// DR-076: an agent whose adapter serves a subagent model delegates by
|
|
2108
|
+
// default. Resolved on the launcher's detached copy of a Captain or player
|
|
2109
|
+
// block — never the user's file — before cligent normalization, so cligent
|
|
2110
|
+
// validates a subagent effort against the resolved model, and both front
|
|
2111
|
+
// ends and the headless run share the result. A block's `false` opts out:
|
|
2112
|
+
// nothing is sent and no delegation directive is composed.
|
|
2113
|
+
function resolveDelegationDefault(agent) {
|
|
2114
|
+
if (!isObject(agent)) return agent;
|
|
2115
|
+
if (agent.subagentModel === false) {
|
|
2116
|
+
const { subagentModel: _off, ...rest } = agent;
|
|
2117
|
+
return rest;
|
|
2083
2118
|
}
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2119
|
+
if (
|
|
2120
|
+
agent.subagentModel === undefined &&
|
|
2121
|
+
typeof agent.adapter === "string" &&
|
|
2122
|
+
isSubagentModelSupported(agent.adapter)
|
|
2123
|
+
) {
|
|
2124
|
+
return { ...agent, subagentModel: "inherit" };
|
|
2125
|
+
}
|
|
2126
|
+
return agent;
|
|
2087
2127
|
}
|
|
2088
2128
|
|
|
2089
2129
|
function applyTuningOverrides(agent, binding) {
|
|
@@ -2096,9 +2136,9 @@ function applyTuningOverrides(agent, binding) {
|
|
|
2096
2136
|
else effective.effort = binding.effort;
|
|
2097
2137
|
}
|
|
2098
2138
|
if (binding.fastMode !== undefined) effective.fastMode = binding.fastMode;
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
effective
|
|
2139
|
+
for (const field of ["subagentModel", "subagentEffort"]) {
|
|
2140
|
+
if (binding[field] === false) delete effective[field];
|
|
2141
|
+
else if (binding[field] !== undefined) effective[field] = binding[field];
|
|
2102
2142
|
}
|
|
2103
2143
|
return effective;
|
|
2104
2144
|
}
|
|
@@ -2109,6 +2149,7 @@ function sessionAgentFromHostAgent(agent, path) {
|
|
|
2109
2149
|
}
|
|
2110
2150
|
return {
|
|
2111
2151
|
adapter: agent.adapter,
|
|
2152
|
+
...(agent.browser === undefined ? {} : { browser: fastModeSelection(agent.browser, `${path}.browser`) }),
|
|
2112
2153
|
model: tuningSelection(agent.model, `${path}.model`),
|
|
2113
2154
|
effort: tuningSelection(agent.effort, `${path}.effort`),
|
|
2114
2155
|
...(agent.fastMode === undefined
|
|
@@ -2117,11 +2158,19 @@ function sessionAgentFromHostAgent(agent, path) {
|
|
|
2117
2158
|
...(agent.subagentModel === undefined
|
|
2118
2159
|
? {}
|
|
2119
2160
|
: {
|
|
2120
|
-
subagentModel:
|
|
2161
|
+
subagentModel: nonblankSelection(
|
|
2121
2162
|
agent.subagentModel,
|
|
2122
2163
|
`${path}.subagentModel`,
|
|
2123
2164
|
),
|
|
2124
2165
|
}),
|
|
2166
|
+
...(agent.subagentEffort === undefined
|
|
2167
|
+
? {}
|
|
2168
|
+
: {
|
|
2169
|
+
subagentEffort: nonblankSelection(
|
|
2170
|
+
agent.subagentEffort,
|
|
2171
|
+
`${path}.subagentEffort`,
|
|
2172
|
+
),
|
|
2173
|
+
}),
|
|
2125
2174
|
...(agent.instruction === undefined
|
|
2126
2175
|
? {}
|
|
2127
2176
|
: { instruction: agent.instruction }),
|
|
@@ -2138,6 +2187,7 @@ export function projectHostAgent(agent, path = "agent") {
|
|
|
2138
2187
|
const normalized = cloneJson(agent, path);
|
|
2139
2188
|
return {
|
|
2140
2189
|
adapter: normalized.adapter,
|
|
2190
|
+
...(normalized.browser === undefined ? {} : { browser: fastModeSelection(normalized.browser, `${path}.browser`) }),
|
|
2141
2191
|
...(normalized.model?.kind === "value"
|
|
2142
2192
|
? { model: normalized.model.value }
|
|
2143
2193
|
: {}),
|
|
@@ -2150,6 +2200,9 @@ export function projectHostAgent(agent, path = "agent") {
|
|
|
2150
2200
|
...(normalized.subagentModel === undefined
|
|
2151
2201
|
? {}
|
|
2152
2202
|
: { subagentModel: normalized.subagentModel }),
|
|
2203
|
+
...(normalized.subagentEffort === undefined
|
|
2204
|
+
? {}
|
|
2205
|
+
: { subagentEffort: normalized.subagentEffort }),
|
|
2153
2206
|
...(normalized.instruction === undefined
|
|
2154
2207
|
? {}
|
|
2155
2208
|
: { instruction: normalized.instruction }),
|
|
@@ -2166,7 +2219,7 @@ function fastModeSelection(value, path) {
|
|
|
2166
2219
|
return value;
|
|
2167
2220
|
}
|
|
2168
2221
|
|
|
2169
|
-
function
|
|
2222
|
+
function nonblankSelection(value, path) {
|
|
2170
2223
|
if (typeof value !== "string" || value.trim().length === 0) {
|
|
2171
2224
|
throw new Error(`${path} must be a nonblank string`);
|
|
2172
2225
|
}
|
|
@@ -967,9 +967,11 @@ function helpText({
|
|
|
967
967
|
" adapter shorthand (claude, codex) or an inline agent block",
|
|
968
968
|
" - bind every playbooks.<id>.roles.<role> explicitly to a player id;",
|
|
969
969
|
" a scalar names the id, while { player, model?, effort?, fastMode?,",
|
|
970
|
-
" subagentModel? } may retune one role;",
|
|
971
|
-
" false selects provider-default model/effort/subagentModel
|
|
972
|
-
" while fastMode false is a literal disabled request",
|
|
970
|
+
" subagentModel?, subagentEffort? } may retune one role;",
|
|
971
|
+
" false selects provider-default model/effort/subagentModel/",
|
|
972
|
+
" subagentEffort, while fastMode false is a literal disabled request",
|
|
973
|
+
" - a Claude agent delegates by default: subagentModel resolves to",
|
|
974
|
+
" inherit (its own model) unless the block sets false",
|
|
973
975
|
" - reusing one id deliberately shares that provider conversation;",
|
|
974
976
|
" distinct ids stay isolated even when their agent blocks are equal",
|
|
975
977
|
" - the launcher injects captain.from and retains referenced player ids",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
3
|
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
|
+
import { validateAssetRef } from '../session-assets.js';
|
|
5
6
|
import { posix, win32 } from 'node:path';
|
|
6
7
|
import { isDeepStrictEqual } from 'node:util';
|
|
7
8
|
import {
|
|
@@ -16,7 +17,7 @@ export const EMPTY_REPLAY_SHA256 = createHash('sha256').digest('hex');
|
|
|
16
17
|
export const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
17
18
|
const COMMON = ['schemaVersion', 'kind', 'sessionId', 'cwd', 'createdAt', 'updatedAt', 'state', 'replay', 'contextSeq'];
|
|
18
19
|
const RECOVERY = ['structuralProjection', 'lastAppliedExecutionProjection', 'snapshot', 'effectLedger', 'unresolvedEffects'];
|
|
19
|
-
const OPTIONAL = ['retainedGenerations', 'settledAbandonment'];
|
|
20
|
+
const OPTIONAL = ['retainedGenerations', 'settledAbandonment', 'assets'];
|
|
20
21
|
const HEX = /^[0-9a-f]{64}$/;
|
|
21
22
|
const clone = (value) => structuredClone(value);
|
|
22
23
|
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
@@ -44,11 +45,17 @@ export function validateReplayCheckpoint(value) {
|
|
|
44
45
|
export function validateSessionManifest(value) {
|
|
45
46
|
if (!object(value) || value.schemaVersion !== 7) throw new Error(`unsupported session manifest schema ${value?.schemaVersion}`);
|
|
46
47
|
const history = value.state === 'history-only';
|
|
47
|
-
exact(value, [...COMMON, ...(history ? ['reason'] : RECOVERY), ...(value.state === 'uncertain' ? ['uncertain'] : [])], history ? [] : OPTIONAL);
|
|
48
|
+
exact(value, [...COMMON, ...(history ? ['reason'] : RECOVERY), ...(value.state === 'uncertain' ? ['uncertain'] : [])], history ? ['assets'] : OPTIONAL);
|
|
48
49
|
if (value.kind !== 'captain-session' || !SESSION_ID_PATTERN.test(value.sessionId) || !isRecordedAbsolutePath(value.cwd)) throw new Error('invalid session manifest identity');
|
|
49
50
|
iso(value.createdAt); iso(value.updatedAt);
|
|
50
51
|
if (Date.parse(value.updatedAt) < Date.parse(value.createdAt)) throw new Error('session update precedes creation');
|
|
51
52
|
validateReplayCheckpoint(value.replay);
|
|
53
|
+
if (value.assets !== undefined) {
|
|
54
|
+
exact(value.assets, ['version', 'entries']);
|
|
55
|
+
if (value.assets.version !== 1 || !Array.isArray(value.assets.entries)) throw new Error('unsupported session asset manifest');
|
|
56
|
+
const ids = new Set();
|
|
57
|
+
for (const item of value.assets.entries) { const reference = validateAssetRef(item); if (ids.has(reference.assetId)) throw new Error('duplicate session asset reference'); ids.add(reference.assetId); }
|
|
58
|
+
}
|
|
52
59
|
if (!(history && value.contextSeq === null) && (!Number.isSafeInteger(value.contextSeq) || value.contextSeq <= 0 || value.contextSeq > value.replay.seq)) throw new Error('invalid session context reference');
|
|
53
60
|
if (history) {
|
|
54
61
|
if (!nonempty(value.reason)) throw new Error('history-only session needs a reason');
|
|
@@ -60,7 +67,7 @@ export function validateSessionManifest(value) {
|
|
|
60
67
|
return clone(value);
|
|
61
68
|
}
|
|
62
69
|
function recoveryFromManifestUnchecked(value) {
|
|
63
|
-
const { replay, contextSeq, ...recovery } = value;
|
|
70
|
+
const { replay, contextSeq, assets, ...recovery } = value;
|
|
64
71
|
return { ...recovery, schemaVersion: 6 };
|
|
65
72
|
}
|
|
66
73
|
export function recoveryFromManifest(value) {
|
|
@@ -68,9 +75,9 @@ export function recoveryFromManifest(value) {
|
|
|
68
75
|
if (manifest.state === 'history-only') throw new Error(manifest.reason);
|
|
69
76
|
return validateCaptainSessionRecord(recoveryFromManifestUnchecked(manifest));
|
|
70
77
|
}
|
|
71
|
-
export function manifestFromRecovery(value, replay, contextSeq) {
|
|
78
|
+
export function manifestFromRecovery(value, replay, contextSeq, assets) {
|
|
72
79
|
const recovery = projectRecovery(validateCaptainSessionRecord(value));
|
|
73
|
-
return validateSessionManifest({ ...recovery, schemaVersion: 7, replay, contextSeq });
|
|
80
|
+
return validateSessionManifest({ ...recovery, schemaVersion: 7, replay, contextSeq, ...(assets === undefined ? {} : { assets }) });
|
|
74
81
|
}
|
|
75
82
|
export function projectRecovery(value) {
|
|
76
83
|
const source = clone(value);
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// presenter; it does not construct a registry runtime or PlaybookPorts itself.
|
|
8
8
|
|
|
9
9
|
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { workerReportExcerpt, workerRecoveryReportBlock } from "../worker-evidence.js";
|
|
11
|
+
import { createSessionMediaProjector } from "./session-media.js";
|
|
10
12
|
import { attachSessionHints, validateSessionContext } from "./portable-codec.js";
|
|
11
13
|
import { homedir } from "node:os";
|
|
12
14
|
import { resolve } from "node:path";
|
|
@@ -995,6 +997,13 @@ export function restoreInterruptedProgress(record, ledger) {
|
|
|
995
997
|
: `Captain preparation for ${name}: ${step.result.summary}`;
|
|
996
998
|
return `${name} ${step.kind} call: ${step.result === undefined ? 'started; no result was saved' : step.result?.status === undefined ? 'result saved' : `returned ${step.result.status}`}.`;
|
|
997
999
|
});
|
|
1000
|
+
const workerReports = changedBoundaries.flatMap((boundary) => {
|
|
1001
|
+
const completedStep = steps.find((step) => step.kind === 'player' &&
|
|
1002
|
+
step.workerEvidence?.boundaryId === boundary.boundaryId);
|
|
1003
|
+
if (!completedStep) return [];
|
|
1004
|
+
const report = workerReportExcerpt(boundary, completedStep.workerEvidence.playerId, completedStep.result.guard);
|
|
1005
|
+
return report === undefined ? [] : [report];
|
|
1006
|
+
});
|
|
998
1007
|
const completedRoots = steps.filter((step) => step.kind === 'completion' && step.result.retention !== 'keep').map((step) => ({ kind: 'clear', rootPlaybookId: step.playbookId }));
|
|
999
1008
|
const report = {
|
|
1000
1009
|
retentionUpdates: completedRoots,
|
|
@@ -1008,7 +1017,7 @@ export function restoreInterruptedProgress(record, ledger) {
|
|
|
1008
1017
|
? completion && !saved.frames?.length
|
|
1009
1018
|
? 'The completed run is restored; no work was repeated.'
|
|
1010
1019
|
: 'The run stopped. Its saved position is restored; no work was repeated. Choose how to continue. Before repeating unfinished work, confirm that the earlier worker has stopped and check any outside actions it may have taken.'
|
|
1011
|
-
: 'The exact stopping point was not saved. Files and repository evidence are preserved; no work was repeated. Check the saved work and stop any earlier worker before starting another attempt.', ...facts].join('\n\n'),
|
|
1020
|
+
: 'The exact stopping point was not saved. Files and repository evidence are preserved; no work was repeated. Check the saved work and stop any earlier worker before starting another attempt.', ...facts, ...(workerReports.length ? [workerRecoveryReportBlock(workerReports)] : [])].join('\n\n'),
|
|
1012
1021
|
};
|
|
1013
1022
|
if (noWork) return { snapshot: base, report };
|
|
1014
1023
|
if (saved) {
|
|
@@ -1068,6 +1077,7 @@ export async function createCaptainSessionHost({
|
|
|
1068
1077
|
signal,
|
|
1069
1078
|
graphs = [],
|
|
1070
1079
|
initialVisible = [],
|
|
1080
|
+
approvalHandler,
|
|
1071
1081
|
}) {
|
|
1072
1082
|
if (restoreSnapshot !== undefined) await sessionLease.assertContinuable({ cwd, executionProjection: config });
|
|
1073
1083
|
const hostCapabilities = await createRepositoryEffectCapabilities({
|
|
@@ -1115,23 +1125,30 @@ export async function createCaptainSessionHost({
|
|
|
1115
1125
|
if (sourceSnapshot !== undefined && !interrupted && typeof sessionLease.consumeHints === "function") {
|
|
1116
1126
|
sourceSnapshot = attachSessionHints(sourceSnapshot, await sessionLease.consumeHints());
|
|
1117
1127
|
}
|
|
1128
|
+
let presentationAttachments = [];
|
|
1129
|
+
const mediaProjector = createSessionMediaProjector({ lease: sessionLease, onEvidence: (asset, origin) => shell.recordMediaEvidence(asset, origin.runtimeSessionId) });
|
|
1118
1130
|
const bufferedRecords = [];
|
|
1119
1131
|
let presentationReady = false;
|
|
1120
1132
|
const presentationTurnOffset = restoreSnapshot?.sequences.turn ?? 0;
|
|
1133
|
+
const approvals = createHostApprovalScope(approvalHandler, presentationTurnOffset);
|
|
1121
1134
|
const forwardRecord = async (record) => {
|
|
1135
|
+
if (record.type === 'turn_started' && presentationAttachments.length) record = { ...record, turn: { ...record.turn, attachments: presentationAttachments } };
|
|
1122
1136
|
const projected = presentationTurnOffset > 0 && typeof record.turnId === "number"
|
|
1123
1137
|
? { ...record, turnId: record.turnId + presentationTurnOffset, ...(record.type === "turn_started" ? { turn: { ...record.turn, id: record.turn.id + presentationTurnOffset } } : {}) }
|
|
1124
1138
|
: record;
|
|
1125
|
-
for (const observer of observers ?? []) await observer.onRecord?.(
|
|
1139
|
+
for (const value of await mediaProjector.project(projected)) for (const observer of observers ?? []) await observer.onRecord?.(value);
|
|
1126
1140
|
};
|
|
1127
1141
|
const bufferedObservers = [{ async onRecord(record) {
|
|
1128
1142
|
if (!presentationReady) bufferedRecords.push(record);
|
|
1129
1143
|
else await forwardRecord(record);
|
|
1130
1144
|
} }];
|
|
1131
1145
|
const shell = createPlaybookCaptainShell(captainOptionsFromConfig(config), {
|
|
1146
|
+
onTurnAttachments: (references) => { presentationAttachments = references; },
|
|
1147
|
+
beginPreparationMedia: (runtimeSessionId) => mediaProjector.beginPreparation(runtimeSessionId),
|
|
1132
1148
|
abortPreparation: (reason) => host?.abortActiveTurn(typeof reason === 'string' ? reason : 'Captain preparation exceeded its 150-second limit'),
|
|
1133
1149
|
loadModule,
|
|
1134
1150
|
hostCapabilities,
|
|
1151
|
+
...(typeof sessionLease.resolveAttachments === 'function' ? { resolveAttachments: (references, signal) => sessionLease.resolveAttachments(references, { signal }) } : {}),
|
|
1135
1152
|
...(typeof sessionLease.recordProgress === 'function' ? {
|
|
1136
1153
|
recordProgress: (change) => sessionLease.recordProgress(change),
|
|
1137
1154
|
} : {}),
|
|
@@ -1166,6 +1183,7 @@ export async function createCaptainSessionHost({
|
|
|
1166
1183
|
})),
|
|
1167
1184
|
cwd,
|
|
1168
1185
|
observers: bufferedObservers,
|
|
1186
|
+
...(approvals.handler ? { approvalHandler: approvals.handler } : {}),
|
|
1169
1187
|
...(signal ? { signal } : {}),
|
|
1170
1188
|
...(adapterImports ? { adapterImports } : {}),
|
|
1171
1189
|
});
|
|
@@ -1193,9 +1211,13 @@ export async function createCaptainSessionHost({
|
|
|
1193
1211
|
}
|
|
1194
1212
|
presentationReady = true;
|
|
1195
1213
|
for (const record of bufferedRecords) await forwardRecord(record);
|
|
1196
|
-
if (interruptedReport)
|
|
1197
|
-
|
|
1214
|
+
if (interruptedReport) {
|
|
1215
|
+
shell.setTurnAttachments(interrupted.uncertain.attachments ?? []);
|
|
1216
|
+
shell.selectInterruptedReport(interrupted.uncertain.input, interruptedReport);
|
|
1217
|
+
}
|
|
1218
|
+
return { shell, host, snapshot, reconcileRepositoryEffects, cancelPendingApprovals: approvals.close, getInterruptedSettlement: (attemptId) => attemptId !== undefined && interruptedSettlement?.attemptId === attemptId ? interruptedSettlement.record : undefined };
|
|
1198
1219
|
} catch (error) {
|
|
1220
|
+
approvals.close();
|
|
1199
1221
|
let cleanupError;
|
|
1200
1222
|
try {
|
|
1201
1223
|
if (host !== undefined) await host.dispose();
|
|
@@ -1213,6 +1235,48 @@ export async function createCaptainSessionHost({
|
|
|
1213
1235
|
}
|
|
1214
1236
|
}
|
|
1215
1237
|
|
|
1238
|
+
// Callback liveness belongs to the host, independently of durable turn state.
|
|
1239
|
+
// Closing this scope denies waits without cancelling ordinary graceful work.
|
|
1240
|
+
function createHostApprovalScope(handler, turnOffset) {
|
|
1241
|
+
const lifetime = new AbortController();
|
|
1242
|
+
return {
|
|
1243
|
+
close: () => lifetime.abort('session host closing'),
|
|
1244
|
+
handler: typeof handler !== 'function' ? undefined : (envelope, { signal }) => {
|
|
1245
|
+
if (lifetime.signal.aborted || signal.aborted) return Promise.resolve('deny');
|
|
1246
|
+
const controller = new AbortController();
|
|
1247
|
+
return new Promise((resolve, reject) => {
|
|
1248
|
+
let settled = false;
|
|
1249
|
+
const finish = (decision) => {
|
|
1250
|
+
if (settled) return;
|
|
1251
|
+
settled = true;
|
|
1252
|
+
signal.removeEventListener('abort', cancel);
|
|
1253
|
+
lifetime.signal.removeEventListener('abort', cancel);
|
|
1254
|
+
if (!envelope.request.choices.includes(decision)) controller.abort('Invalid approval decision');
|
|
1255
|
+
resolve(decision);
|
|
1256
|
+
};
|
|
1257
|
+
const fail = (error) => {
|
|
1258
|
+
if (settled) return;
|
|
1259
|
+
settled = true;
|
|
1260
|
+
signal.removeEventListener('abort', cancel);
|
|
1261
|
+
lifetime.signal.removeEventListener('abort', cancel);
|
|
1262
|
+
controller.abort(error);
|
|
1263
|
+
reject(error);
|
|
1264
|
+
};
|
|
1265
|
+
const cancel = () => {
|
|
1266
|
+
controller.abort(signal.aborted ? signal.reason : lifetime.signal.reason);
|
|
1267
|
+
finish('deny');
|
|
1268
|
+
};
|
|
1269
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
1270
|
+
lifetime.signal.addEventListener('abort', cancel, { once: true });
|
|
1271
|
+
Promise.resolve().then(() => {
|
|
1272
|
+
if (settled) return 'deny';
|
|
1273
|
+
return handler({ ...envelope, turnId: envelope.turnId + turnOffset }, { signal: controller.signal });
|
|
1274
|
+
}).then(finish, fail);
|
|
1275
|
+
});
|
|
1276
|
+
},
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1216
1280
|
// PBCLI-55: both presentations install one authoritative retention map at the
|
|
1217
1281
|
// same post-shell, pre-turn boundary. Fresh-session guarded initialization
|
|
1218
1282
|
// publishes either the intentional empty boundary or the transferred map.
|
|
@@ -1367,11 +1431,14 @@ export async function validateFrozenExecutionConfig(
|
|
|
1367
1431
|
return config;
|
|
1368
1432
|
}
|
|
1369
1433
|
|
|
1370
|
-
// Fast mode and the subagent
|
|
1371
|
-
// frozen record re-validates each present value
|
|
1434
|
+
// Fast mode, the subagent model and the subagent effort are adapter-scoped
|
|
1435
|
+
// tuning (DR-075, DR-076): a frozen record re-validates each present value
|
|
1436
|
+
// against its adapter. Cligent serves the subagent effort exactly where it
|
|
1437
|
+
// serves a subagent model, so one capability assertion covers both.
|
|
1372
1438
|
const FROZEN_ADAPTER_SCOPED_TUNING = [
|
|
1373
1439
|
["fastMode", assertFastModeSupported],
|
|
1374
1440
|
["subagentModel", assertSubagentModelSupported],
|
|
1441
|
+
["subagentEffort", assertSubagentModelSupported],
|
|
1375
1442
|
];
|
|
1376
1443
|
|
|
1377
1444
|
function assertFrozenTuningSupported(config) {
|
|
@@ -1887,7 +1954,7 @@ function runHelpText(userConfigPath) {
|
|
|
1887
1954
|
"working directory and reports when it uses the global newest fallback.",
|
|
1888
1955
|
"An ordinary continued run restores that stored structure and working",
|
|
1889
1956
|
"directory, then reads current config and overlays for model, effort,",
|
|
1890
|
-
"fast mode, and subagent
|
|
1957
|
+
"fast mode, subagent model, and subagent effort.",
|
|
1891
1958
|
"Uncertain retry only restores and reports; later work uses current settings.",
|
|
1892
1959
|
"",
|
|
1893
1960
|
"Options:",
|