@sublang/playbook 13.3.0 → 14.1.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/cli.md +14 -14
- package/docs/configuration.md +10 -2
- package/docs/embedding.md +20 -1
- package/package.json +6 -1
- package/reference/sdlc/branch.playbook/branch.fsm.d.ts +1 -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 +198 -17
- package/reference/sdlc/code.playbook/bin/run.js +4 -0
- package/reference/sdlc/code.playbook/bin/session-store.js +2 -2
- package/reference/sdlc/code.playbook/code.fsm.d.ts +1 -1
- package/reference/sdlc/code.playbook/host-capabilities.d.ts +6 -0
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +3 -1
- package/reference/sdlc/code.playbook/playbook-captain.js +374 -20
- package/reference/sdlc/code.playbook/playbook-captain.ts +478 -38
- package/reference/sdlc/code.playbook/session-host.d.ts +6 -3
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +1 -1
- package/reference/sdlc/decide.playbook/decide.playbook.js +25 -1
- package/reference/sdlc/decide.playbook/decide.playbook.ts +35 -2
- package/reference/sdlc/dev.md +15 -8
- package/reference/sdlc/dev.playbook/dev.fsm.d.ts +1 -1
- package/reference/sdlc/dev.playbook/dev.fsm.js +18 -12
- package/reference/sdlc/dev.playbook/dev.fsm.ts +18 -13
- package/reference/sdlc/dev.playbook/dev.gears.md +19 -12
- package/reference/sdlc/dev.playbook/dev.playbook.d.ts +3 -10
- package/reference/sdlc/dev.playbook/dev.playbook.js +6 -6
- package/reference/sdlc/dev.playbook/dev.playbook.ts +8 -9
- package/reference/sdlc/dev.playbook/dev.registry.js +1 -1
- package/reference/sdlc/dev.playbook/dev.registry.ts +1 -1
- package/reference/sdlc/pr.md +17 -4
- package/reference/sdlc/pr.playbook/pr.fsm.d.ts +18 -4
- 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/reference/sdlc/review.playbook/review.fsm.d.ts +1 -1
- package/slc/experiments/fsm-scaffold-guidance.md +31 -0
- package/slc/gears2fsm.md +145 -58
- package/slc/link.md +190 -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/runtime.d.ts +70 -0
- package/src/runtime.js +232 -1
- package/src/runtime.ts +358 -1
- package/src/xstate-playbook-runtime.d.ts +9 -10
- package/src/xstate-playbook-runtime.js +407 -21
- package/src/xstate-playbook-runtime.ts +514 -20
- package/src/xstate-runtime.d.ts +9 -1
- package/src/xstate-runtime.js +299 -19
- package/src/xstate-runtime.ts +362 -21
package/README.md
CHANGED
|
@@ -32,9 +32,10 @@ Run `playbook` for an interactive tmux UI powered by [cligent](https://github.co
|
|
|
32
32
|
Out of the box, Playbook includes **CODE** for implementation, **REVIEW** for commit-based review and fixes, **DECIDE** for independently proposed and reviewed specification decisions, **DEV** for repository-aware planning of a development request, **BRANCH** for checking out a new branch for a GitHub issue, and **PR** for publishing, checking, and merging that branch through a pull request.
|
|
33
33
|
CODE and DECIDE call REVIEW as a nested playbook; DEV calls CODE, or DECIDE and then CODE, and wraps them in BRANCH and PR when the request names a GitHub issue or asks for a pull request.
|
|
34
34
|
|
|
35
|
-
The shared starter config
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
The shared starter config selects one adapter for the Captain and all players
|
|
36
|
+
from locally visible credentials: Claude first, then Codex. If neither is
|
|
37
|
+
configured, it seeds Claude and prints a notice. CODE, REVIEW, DECIDE, BRANCH, and PR bind
|
|
38
|
+
their local roles explicitly to `dev.coder` and `dev.reviewer`, so nested and later
|
|
38
39
|
engagements share a conversation only where their bindings name the same
|
|
39
40
|
player ID; DEV's Analyst binds a distinct `dev.analyst` player.
|
|
40
41
|
|
|
@@ -49,8 +50,7 @@ If an SDK is missing or older than cligent supports, Playbook prints the pinned
|
|
|
49
50
|
Prerequisites:
|
|
50
51
|
|
|
51
52
|
- Node.js >= 20.6.0
|
|
52
|
-
-
|
|
53
|
-
- Authenticated [Codex CLI](https://github.com/openai/codex) or `OPENAI_API_KEY`
|
|
53
|
+
- Credentials for the configured adapter: authenticated [Claude Code](https://docs.anthropic.com/en/docs/claude-code/overview) or `ANTHROPIC_API_KEY` for Claude; authenticated [Codex CLI](https://github.com/openai/codex) or `OPENAI_API_KEY` for Codex
|
|
54
54
|
- [GitHub CLI](https://cli.github.com/) (`gh`) authenticated for the repository's GitHub remote, for `/branch`, `/pr`, and `/dev` requests that name an issue or ask for a pull request
|
|
55
55
|
|
|
56
56
|
Interactive `playbook` additionally needs tmux 3.3 or newer and [`glow`](https://github.com/charmbracelet/glow#installation) on `PATH`; headless `playbook run` does not.
|
package/docs/cli.md
CHANGED
|
@@ -124,20 +124,20 @@ untouched
|
|
|
124
124
|
The current CODE, REVIEW, DECIDE, DEV, BRANCH, and PR workflows take their
|
|
125
125
|
deterministic initial event from the selecting Boss turn. CODE and DECIDE then
|
|
126
126
|
call REVIEW as a nested playbook, while DEV — the repository-aware planner
|
|
127
|
-
behind `/dev` —
|
|
128
|
-
DECIDE and then CODE, as nested playbooks. When the request names a
|
|
129
|
-
issue or asks for pull-request delivery, DEV reads the issue and its
|
|
130
|
-
while planning, calls BRANCH before that path and PR after CODE
|
|
131
|
-
ends with a merged pull request and a closed issue — or with a
|
|
132
|
-
that leaves the pull request open for a later `/pr`. Both are
|
|
133
|
-
own: `/branch <issue or request>` creates and checks out
|
|
134
|
-
request slug) at the current commit, changing nothing
|
|
135
|
-
checked-out branch, opens or reuses its pull request
|
|
136
|
-
branch, waits for the checks, fixes red checks through one
|
|
137
|
-
merges with a merge commit, and fast-forwards the local
|
|
138
|
-
repository without checks merges on CODE's nested review
|
|
139
|
-
and issue-aware DEV planning require the GitHub CLI `gh`,
|
|
140
|
-
the repository's GitHub remote
|
|
127
|
+
behind `/dev` — chooses the development path for a request and itself calls
|
|
128
|
+
CODE, or DECIDE and then CODE, as nested playbooks. When the request names a
|
|
129
|
+
GitHub issue or asks for pull-request delivery, DEV reads the issue and its
|
|
130
|
+
comments while planning, calls BRANCH before that path and PR after CODE
|
|
131
|
+
succeeds, and ends with a merged pull request and a closed issue — or with a
|
|
132
|
+
named failure that leaves the pull request open for a later `/pr`. Both are
|
|
133
|
+
usable on their own: `/branch <issue or request>` creates and checks out
|
|
134
|
+
`issue-N-slug` (or a request slug) at the current commit, changing nothing
|
|
135
|
+
else; `/pr` pushes the checked-out branch, opens or reuses its pull request
|
|
136
|
+
against the default branch, waits for the checks, fixes red checks through one
|
|
137
|
+
nested CODE call, merges with a merge commit, and fast-forwards the local
|
|
138
|
+
default branch. A repository without checks merges on CODE's nested review
|
|
139
|
+
alone. BRANCH, PR, and issue-aware DEV planning require the GitHub CLI `gh`,
|
|
140
|
+
authenticated for the repository's GitHub remote
|
|
141
141
|
([DR-050](https://github.com/sublang-ai/playbook/blob/main/specs/decisions/050-pull-request-delivery.md)).
|
|
142
142
|
Local role names do not imply continuity: each frame
|
|
143
143
|
uses the exact stable player IDs configured under its `roles` map. Equal IDs
|
package/docs/configuration.md
CHANGED
|
@@ -55,8 +55,16 @@ key is that playbook's option slice. Every manifest role must be present
|
|
|
55
55
|
exactly once. The launcher injects the rest — you do not write host wiring by
|
|
56
56
|
hand.
|
|
57
57
|
|
|
58
|
-
The
|
|
59
|
-
|
|
58
|
+
The launcher seeds one adapter for the Captain and all stable players from
|
|
59
|
+
locally visible credentials: Claude first, then Codex. It uses Claude Opus 5
|
|
60
|
+
for Claude or GPT-5.6 Sol for Codex, with Captain and Coder at `high` effort
|
|
61
|
+
and Reviewer and Analyst at `xhigh`. If neither adapter is configured, it
|
|
62
|
+
seeds Claude and prints a notice. Credentials do not prove SDK availability
|
|
63
|
+
or remaining quota; launch still checks the configured adapter. Existing
|
|
64
|
+
configs are left unchanged.
|
|
65
|
+
|
|
66
|
+
The Claude default is shown below. A Codex seed also adds
|
|
67
|
+
`permissions.writablePaths: ['.git']` to each agent:
|
|
60
68
|
|
|
61
69
|
```yaml
|
|
62
70
|
captain:
|
package/docs/embedding.md
CHANGED
|
@@ -433,7 +433,7 @@ defines the files, versions and compatibility rules.
|
|
|
433
433
|
|
|
434
434
|
## Reading the published spec contracts
|
|
435
435
|
|
|
436
|
-
The
|
|
436
|
+
The compiler definitions and supporting assets ship in the package and are exposed
|
|
437
437
|
as a public, semver-stable surface under `@sublang/playbook/slc/*`.
|
|
438
438
|
Resolve and read one with `import.meta.resolve` plus `fs`:
|
|
439
439
|
|
|
@@ -450,3 +450,22 @@ The four specs are [`slc/text2gears.md`](../slc/text2gears.md),
|
|
|
450
450
|
[`slc/link.md`](../slc/link.md) — the FSM-to-runtime contract that
|
|
451
451
|
`@sublang/playbook/runtime` projects into TypeScript — and
|
|
452
452
|
[`slc/optimize.md`](../slc/optimize.md).
|
|
453
|
+
|
|
454
|
+
Five supporting assets use the same resolution mechanism:
|
|
455
|
+
|
|
456
|
+
| Asset | Purpose |
|
|
457
|
+
| --- | --- |
|
|
458
|
+
| [`slc/workflow-contracts.json`](../slc/workflow-contracts.json) | Public output schemas and default literal target bindings for the builtin REVIEW, DECIDE, CODE, BRANCH, and PR workflows; compilers and embedders can read them without importing workflow implementations. |
|
|
459
|
+
| [`slc/materialize-link.mjs`](../slc/materialize-link.mjs) | Optional CLI that emits a thin linked module from a supported FSM and JSON descriptor; see the profiles and invocation in `slc/link.md`. |
|
|
460
|
+
| [`slc/scaffold-fsm.mjs`](../slc/scaffold-fsm.mjs) | Optional CLI that initializes an incomplete typed FSM from supported GEARS source. |
|
|
461
|
+
| [`slc/experiments/fsm-scaffold-guidance.md`](../slc/experiments/fsm-scaffold-guidance.md) | Opt-in compiler guidance for the initializer, outside ordinary phase discovery. |
|
|
462
|
+
| [`slc/slc.pin-inputs.json`](../slc/slc.pin-inputs.json) | SLC semantic-input closures for `gears2fsm` and `link`, including the workflow catalog and, for `link`, the materializer. Other phases use SLC's inline-input discovery. |
|
|
463
|
+
|
|
464
|
+
For example, resolve `@sublang/playbook/slc/workflow-contracts.json`, read it
|
|
465
|
+
with `readFile`, and parse the result as JSON to inspect the published workflow
|
|
466
|
+
interfaces. Execute the `.mjs` helpers with Node and their documented CLI
|
|
467
|
+
arguments: their supported public interfaces are their resolvable paths and CLI
|
|
468
|
+
contracts. Their JavaScript module exports are internal implementation details
|
|
469
|
+
and carry no SemVer compatibility guarantee.
|
|
470
|
+
The initializer requires further semantic compilation and verification
|
|
471
|
+
before its output is runnable.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sublang/playbook",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "14.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Composable XState v5 playbook runtime with compiled Captain, CODE, REVIEW, DECIDE, DEV, BRANCH, and PR workflows driven by GEARS specs.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -38,6 +38,11 @@
|
|
|
38
38
|
"src/xstate-playbook-runtime.js",
|
|
39
39
|
"src/xstate-playbook-runtime.d.ts",
|
|
40
40
|
"slc/link.md",
|
|
41
|
+
"slc/materialize-link.mjs",
|
|
42
|
+
"slc/scaffold-fsm.mjs",
|
|
43
|
+
"slc/experiments/fsm-scaffold-guidance.md",
|
|
44
|
+
"slc/slc.pin-inputs.json",
|
|
45
|
+
"slc/workflow-contracts.json",
|
|
41
46
|
"slc/gears2fsm.md",
|
|
42
47
|
"slc/text2gears.md",
|
|
43
48
|
"slc/optimize.md",
|
|
@@ -131,7 +131,7 @@ export declare const branchMachine: import("xstate").StateMachine<BranchContext,
|
|
|
131
131
|
} | {
|
|
132
132
|
type: "resumesCreateBranch";
|
|
133
133
|
params: unknown;
|
|
134
|
-
}, never, "failed" | "
|
|
134
|
+
}, never, "failed" | "ready" | "awaitBossReply" | "refused" | "createBranch" | "branched", string, Readonly<Record<string, never>>, {
|
|
135
135
|
readonly status: "branched";
|
|
136
136
|
/** Exact name of the new branch now checked out. */
|
|
137
137
|
readonly branch: string;
|
|
@@ -180,7 +180,7 @@ export function resolveLaunchSessionsDir({
|
|
|
180
180
|
if (sessionsDir !== undefined) return sessionsDir;
|
|
181
181
|
|
|
182
182
|
if (preparePrimary) {
|
|
183
|
-
seedUserConfigIfMissing(userConfigPath, templatePath, onNotice);
|
|
183
|
+
seedUserConfigIfMissing(userConfigPath, templatePath, onNotice, env, homeDir);
|
|
184
184
|
migrateUserConfigIfRetired(userConfigPath, onNotice);
|
|
185
185
|
}
|
|
186
186
|
|
|
@@ -248,8 +248,10 @@ export async function loadLaunchPlan({
|
|
|
248
248
|
templatePath = DEFAULT_TEMPLATE_PATH,
|
|
249
249
|
onNotice = () => {},
|
|
250
250
|
selectedMembers,
|
|
251
|
+
env = process.env,
|
|
252
|
+
homeDir = env.HOME ?? homedir(),
|
|
251
253
|
}) {
|
|
252
|
-
seedUserConfigIfMissing(userConfigPath, templatePath, onNotice);
|
|
254
|
+
seedUserConfigIfMissing(userConfigPath, templatePath, onNotice, env, homeDir);
|
|
253
255
|
// PBCLI-22/46: a reopen must not rewrite, validate, or otherwise inspect
|
|
254
256
|
// config members outside the stored projection.
|
|
255
257
|
if (selectedMembers === undefined) {
|
|
@@ -288,10 +290,12 @@ export async function loadSelectedLaunchPlanDataOnly({
|
|
|
288
290
|
structuralProjection,
|
|
289
291
|
templatePath = DEFAULT_TEMPLATE_PATH,
|
|
290
292
|
onNotice = () => {},
|
|
293
|
+
env = process.env,
|
|
294
|
+
homeDir = env.HOME ?? homedir(),
|
|
291
295
|
}) {
|
|
292
296
|
const stored = validateStoredStructuralProjection(structuralProjection);
|
|
293
297
|
const selectedMembers = selectedMembersFromStoredStructure(stored);
|
|
294
|
-
seedUserConfigIfMissing(userConfigPath, templatePath, onNotice);
|
|
298
|
+
seedUserConfigIfMissing(userConfigPath, templatePath, onNotice, env, homeDir);
|
|
295
299
|
|
|
296
300
|
let top = parseYaml(readFileSync(userConfigPath, "utf8")) ?? {};
|
|
297
301
|
if (overlayPaths.length > 0 && !isObject(top)) {
|
|
@@ -1335,11 +1339,90 @@ function assertLegacyRelocationLocatorsSafe(
|
|
|
1335
1339
|
);
|
|
1336
1340
|
}
|
|
1337
1341
|
|
|
1338
|
-
|
|
1342
|
+
// PBCLI-11 / DR-053: the adapters a seeded lineup may take, highest
|
|
1343
|
+
// precedence first. A fixed order — not whichever the probe answers
|
|
1344
|
+
// first — is what makes one machine seed one file twice.
|
|
1345
|
+
const SEED_LINEUP = Object.freeze([
|
|
1346
|
+
Object.freeze({ adapter: "claude", model: "claude-opus-5" }),
|
|
1347
|
+
Object.freeze({ adapter: "codex", model: "gpt-5.6-sol" }),
|
|
1348
|
+
]);
|
|
1349
|
+
|
|
1350
|
+
/**
|
|
1351
|
+
* PBCLI-11: the lineup a seed would write here, and whether anything
|
|
1352
|
+
* actually probed ready.
|
|
1353
|
+
*
|
|
1354
|
+
* Only the credential check participates. The SDK probe constructs an
|
|
1355
|
+
* adapter and awaits `isAvailable()`, which reads whatever happens to
|
|
1356
|
+
* be installed and spawns a subprocess for CLI-backed adapters: a seed
|
|
1357
|
+
* consulting it would differ between a developer's machine and a clean
|
|
1358
|
+
* one, and would make creating a file asynchronous.
|
|
1359
|
+
*/
|
|
1360
|
+
export function selectSeedLineup(env = process.env, home = homedir()) {
|
|
1361
|
+
const ready = SEED_LINEUP.filter(
|
|
1362
|
+
(entry) =>
|
|
1363
|
+
checkReadiness([entry.adapter], env, home).failingAdapters.length === 0,
|
|
1364
|
+
);
|
|
1365
|
+
return { lineup: ready[0] ?? SEED_LINEUP[0], anyReady: ready.length > 0 };
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
/**
|
|
1369
|
+
* The template is the documented default and the only source of the
|
|
1370
|
+
* starter's structure and comments, so the default choice is still the
|
|
1371
|
+
* byte copy it always was. A different adapter rewrites the agent
|
|
1372
|
+
* scalars in place, which the Document API carries comments through.
|
|
1373
|
+
*/
|
|
1374
|
+
function seedTextForLineup(templatePath, lineup) {
|
|
1375
|
+
const template = readFileSync(templatePath, "utf8");
|
|
1376
|
+
if (lineup.adapter === SEED_LINEUP[0].adapter) return template;
|
|
1377
|
+
const doc = parseYamlDocument(template);
|
|
1378
|
+
const players = doc.getIn(["players"]);
|
|
1379
|
+
const agentPaths = [
|
|
1380
|
+
["captain"],
|
|
1381
|
+
...[...(players?.items ?? [])].map((pair) => [
|
|
1382
|
+
"players",
|
|
1383
|
+
String(pair.key),
|
|
1384
|
+
]),
|
|
1385
|
+
];
|
|
1386
|
+
for (const path of agentPaths) {
|
|
1387
|
+
doc.setIn([...path, "adapter"], lineup.adapter);
|
|
1388
|
+
doc.setIn([...path, "model"], lineup.model);
|
|
1389
|
+
// A codex agent writes git metadata under its sandbox; claude's
|
|
1390
|
+
// auto mode needs no grant, so the key exists for one adapter only.
|
|
1391
|
+
if (lineup.adapter === "codex") {
|
|
1392
|
+
doc.setIn([...path, "permissions", "writablePaths"], [".git"]);
|
|
1393
|
+
} else {
|
|
1394
|
+
doc.deleteIn([...path, "permissions", "writablePaths"]);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
return String(doc);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
function seedUserConfigIfMissing(
|
|
1401
|
+
userConfigPath,
|
|
1402
|
+
templatePath,
|
|
1403
|
+
onNotice,
|
|
1404
|
+
env = process.env,
|
|
1405
|
+
homeDir = env.HOME ?? homedir(),
|
|
1406
|
+
) {
|
|
1339
1407
|
if (existsSync(userConfigPath)) return;
|
|
1340
1408
|
mkdirSync(dirname(userConfigPath), { recursive: true });
|
|
1341
|
-
|
|
1409
|
+
const { lineup, anyReady } = selectSeedLineup(env, homeDir);
|
|
1410
|
+
if (lineup.adapter === SEED_LINEUP[0].adapter && anyReady) {
|
|
1411
|
+
// Unchanged default path: one exclusive create of the template bytes.
|
|
1412
|
+
copyFileSync(templatePath, userConfigPath, constants.COPYFILE_EXCL);
|
|
1413
|
+
} else {
|
|
1414
|
+
// Still one exclusive create, so two seeding calls cannot disagree.
|
|
1415
|
+
writeFileSync(userConfigPath, seedTextForLineup(templatePath, lineup), {
|
|
1416
|
+
encoding: "utf8",
|
|
1417
|
+
flag: "wx",
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1342
1420
|
onNotice(`playbook: created config at ${userConfigPath}\n`);
|
|
1421
|
+
if (!anyReady) {
|
|
1422
|
+
onNotice(
|
|
1423
|
+
`playbook: no adapter probed ready; seeded ${lineup.adapter}\n`,
|
|
1424
|
+
);
|
|
1425
|
+
}
|
|
1343
1426
|
}
|
|
1344
1427
|
|
|
1345
1428
|
// DR-021 §3: migrate once, keeping the original before any rewrite.
|
|
@@ -346,10 +346,14 @@ export async function runPlaybookCli(options = {}) {
|
|
|
346
346
|
overlayPaths: withPaths,
|
|
347
347
|
structuralProjection: selectedRecord.structuralProjection,
|
|
348
348
|
onNotice: (line) => stderr.write(line),
|
|
349
|
+
env,
|
|
350
|
+
homeDir: home,
|
|
349
351
|
})
|
|
350
352
|
: await loadLaunchPlan({
|
|
351
353
|
userConfigPath,
|
|
352
354
|
overlayPaths: withPaths,
|
|
355
|
+
env,
|
|
356
|
+
homeDir: home,
|
|
353
357
|
loadModule,
|
|
354
358
|
prepareRegistryModule:
|
|
355
359
|
options.prepareRegistryModule ??
|
|
@@ -520,14 +520,169 @@ function projectionText(projection) {
|
|
|
520
520
|
return JSON.stringify(projection);
|
|
521
521
|
}
|
|
522
522
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
523
|
+
const MISSING_CONTENT_IDENTITY = Object.freeze({ kind: 'missing' });
|
|
524
|
+
|
|
525
|
+
function hasPath(projection, path) {
|
|
526
|
+
return Object.prototype.hasOwnProperty.call(projection, path);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// DR-062 §1: a rename, an unmerged path, a submodule, or a nested worktree is
|
|
530
|
+
// preserved when byte-equal and otherwise lost, because no content matching is
|
|
531
|
+
// attempted for them.
|
|
532
|
+
function nonComparableBaselineEntry(entry) {
|
|
533
|
+
return (
|
|
534
|
+
entry.kind === 'rename-or-copy' ||
|
|
535
|
+
entry.kind === 'unmerged' ||
|
|
536
|
+
(entry.submodule !== undefined && entry.submodule !== 'N...') ||
|
|
537
|
+
entry.worktree?.kind === 'directory'
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// The entry the baseline HEAD tree held for a path: an ordinary porcelain-v2
|
|
542
|
+
// record carries its HEAD mode and OID, `000000` means the path did not exist
|
|
543
|
+
// there, an untracked path never did, and an unborn baseline HEAD holds none.
|
|
544
|
+
function baselineHeadEntry(baseline, entry) {
|
|
545
|
+
if (baseline.head === NULL_GIT_OID) return undefined;
|
|
546
|
+
if (entry.kind !== 'ordinary') return undefined;
|
|
547
|
+
if (entry.headMode === undefined || entry.headMode === '000000') {
|
|
548
|
+
return undefined;
|
|
549
|
+
}
|
|
550
|
+
return { mode: entry.headMode, oid: entry.headOid };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function sameTreeEntry(left, right) {
|
|
554
|
+
if (left === undefined || right === undefined) return left === right;
|
|
555
|
+
return left.mode === right.mode && left.oid === right.oid;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// The baseline recorded nothing at the path: a deleted worktree entry, or a
|
|
559
|
+
// staged deletion whose index holds no object.
|
|
560
|
+
function baselineEntryIsDeletion(entry) {
|
|
561
|
+
return entry.worktree === undefined
|
|
562
|
+
? entry.indexMode === '000000'
|
|
563
|
+
: entry.worktree.kind === 'missing';
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// The entry one commit's tree holds for an exact path, or undefined when that
|
|
567
|
+
// tree holds none.
|
|
568
|
+
async function afterTreeEntry(worktree, head, path) {
|
|
569
|
+
const raw = await runGitText(worktree, [
|
|
570
|
+
'ls-tree',
|
|
571
|
+
'-z',
|
|
572
|
+
'--full-tree',
|
|
573
|
+
head,
|
|
574
|
+
'--',
|
|
575
|
+
path,
|
|
576
|
+
]);
|
|
577
|
+
for (const record of raw.split('\0')) {
|
|
578
|
+
if (record.length === 0) continue;
|
|
579
|
+
const tab = record.indexOf('\t');
|
|
580
|
+
const fields = tab < 0 ? [] : record.slice(0, tab).split(' ');
|
|
581
|
+
if (fields.length !== 3) {
|
|
582
|
+
throw new RepositoryObservationAmbiguousError(
|
|
583
|
+
'Git returned a malformed tree record',
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
if (record.slice(tab + 1) !== path) continue;
|
|
587
|
+
return { mode: fields[0], oid: fields[2] };
|
|
588
|
+
}
|
|
589
|
+
return undefined;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// A blob addressed exactly as `worktreePathIdentity` addresses the same bytes
|
|
593
|
+
// in the working tree, so a committed object and the Boss's own content are
|
|
594
|
+
// comparable by the projection's content addressing rather than by Git OID.
|
|
595
|
+
async function blobContentIdentity(worktree, mode, oid) {
|
|
596
|
+
const bytes = await runGit(worktree, ['cat-file', 'blob', oid]);
|
|
597
|
+
return {
|
|
598
|
+
kind: mode === '120000' ? 'symlink' : 'file',
|
|
599
|
+
mode,
|
|
600
|
+
content: `sha256:${sha256(bytes)}`,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function baselineContentIdentity(worktree, entry) {
|
|
605
|
+
return entry.worktree === undefined
|
|
606
|
+
? blobContentIdentity(worktree, entry.indexMode, entry.indexOid)
|
|
607
|
+
: entry.worktree;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function sameContentIdentity(left, right) {
|
|
611
|
+
return (
|
|
612
|
+
left.kind === right.kind &&
|
|
613
|
+
left.mode === right.mode &&
|
|
614
|
+
left.content === right.content
|
|
528
615
|
);
|
|
529
616
|
}
|
|
530
617
|
|
|
618
|
+
// DR-062 §1: the fate of one baseline projection entry at the end of a
|
|
619
|
+
// governed call — `preserved`, `altered-uncommitted`, `altered-committed`,
|
|
620
|
+
// `absorbed`, or `lost`.
|
|
621
|
+
async function baselineEntryFate(baseline, after, path, entry) {
|
|
622
|
+
if (hasPath(after.projection, path)) {
|
|
623
|
+
return JSON.stringify(after.projection[path]) === JSON.stringify(entry)
|
|
624
|
+
? 'preserved'
|
|
625
|
+
: 'altered-uncommitted';
|
|
626
|
+
}
|
|
627
|
+
// The entry left the working tree, so only a commit this call made can hold
|
|
628
|
+
// the Boss's content; a call that moved no HEAD made none.
|
|
629
|
+
if (baseline.head === after.head) return 'lost';
|
|
630
|
+
if (nonComparableBaselineEntry(entry)) return 'lost';
|
|
631
|
+
try {
|
|
632
|
+
const treeEntry = await afterTreeEntry(after.worktree, after.head, path);
|
|
633
|
+
if (sameTreeEntry(treeEntry, baselineHeadEntry(baseline, entry))) {
|
|
634
|
+
// The commit left the path exactly as the baseline HEAD held it, so it
|
|
635
|
+
// carried no change of the Boss's and his content is nowhere.
|
|
636
|
+
return 'lost';
|
|
637
|
+
}
|
|
638
|
+
if (baselineEntryIsDeletion(entry)) {
|
|
639
|
+
return treeEntry === undefined ? 'absorbed' : 'lost';
|
|
640
|
+
}
|
|
641
|
+
if (treeEntry?.mode === '160000') return 'lost';
|
|
642
|
+
const [baselineIdentity, treeIdentity] = await Promise.all([
|
|
643
|
+
baselineContentIdentity(after.worktree, entry),
|
|
644
|
+
treeEntry === undefined
|
|
645
|
+
? MISSING_CONTENT_IDENTITY
|
|
646
|
+
: blobContentIdentity(after.worktree, treeEntry.mode, treeEntry.oid),
|
|
647
|
+
]);
|
|
648
|
+
return sameContentIdentity(baselineIdentity, treeIdentity)
|
|
649
|
+
? 'absorbed'
|
|
650
|
+
: 'altered-committed';
|
|
651
|
+
} catch {
|
|
652
|
+
// Content the after HEAD cannot produce leaves the baseline entry
|
|
653
|
+
// unaccounted for, which fails closed exactly as a lost entry does.
|
|
654
|
+
return 'lost';
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async function baselineEntryFates(baseline, after) {
|
|
659
|
+
const fates = [];
|
|
660
|
+
for (const [path, entry] of Object.entries(baseline.projection)) {
|
|
661
|
+
fates.push([path, await baselineEntryFate(baseline, after, path, entry)]);
|
|
662
|
+
}
|
|
663
|
+
return fates;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// DR-062 §3: the accounting a receipt records, present exactly when one list
|
|
667
|
+
// is nonempty so that an all-preserved call keeps today's receipt shape.
|
|
668
|
+
function preExistingFromFates(fates) {
|
|
669
|
+
const absorbed = [];
|
|
670
|
+
const altered = [];
|
|
671
|
+
const lost = [];
|
|
672
|
+
for (const [path, fate] of fates) {
|
|
673
|
+
if (fate === 'absorbed') absorbed.push(path);
|
|
674
|
+
else if (fate === 'altered-uncommitted' || fate === 'altered-committed') {
|
|
675
|
+
altered.push(path);
|
|
676
|
+
} else if (fate === 'lost') lost.push(path);
|
|
677
|
+
}
|
|
678
|
+
if (absorbed.length + altered.length + lost.length === 0) return undefined;
|
|
679
|
+
return {
|
|
680
|
+
absorbed: absorbed.sort(),
|
|
681
|
+
altered: altered.sort(),
|
|
682
|
+
lost: lost.sort(),
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
531
686
|
async function rawRepositoryStatus(worktree, env) {
|
|
532
687
|
return runGit(
|
|
533
688
|
worktree,
|
|
@@ -659,10 +814,18 @@ export async function resolveCanonicalGitWorktree(cwd) {
|
|
|
659
814
|
}
|
|
660
815
|
|
|
661
816
|
async function sampleWorktree(worktree, env) {
|
|
662
|
-
const
|
|
663
|
-
|
|
817
|
+
const [headResult, visibilityResult, statusResult] = await Promise.allSettled([
|
|
818
|
+
resolveHead(worktree, env),
|
|
819
|
+
rawIndexVisibility(worktree, env),
|
|
820
|
+
rawRepositoryStatus(worktree, env),
|
|
821
|
+
]);
|
|
822
|
+
if (headResult.status === 'rejected') throw headResult.reason;
|
|
823
|
+
if (visibilityResult.status === 'rejected') throw visibilityResult.reason;
|
|
824
|
+
const head = headResult.value;
|
|
825
|
+
const indexVisibility = visibilityResult.value;
|
|
664
826
|
assertIndexVisibility(indexVisibility);
|
|
665
|
-
|
|
827
|
+
if (statusResult.status === 'rejected') throw statusResult.reason;
|
|
828
|
+
const status = statusResult.value;
|
|
666
829
|
const projection = await projectionFromStatus(worktree, status);
|
|
667
830
|
return { head, indexVisibility, status, projection };
|
|
668
831
|
}
|
|
@@ -780,12 +943,13 @@ async function descendantCount(worktree, baselineHead, afterHead) {
|
|
|
780
943
|
return count;
|
|
781
944
|
}
|
|
782
945
|
|
|
783
|
-
function receipt(classification, baseline, after, commitOid) {
|
|
946
|
+
function receipt(classification, baseline, after, commitOid, preExisting) {
|
|
784
947
|
return deepFreeze({
|
|
785
948
|
classification,
|
|
786
949
|
baseline,
|
|
787
950
|
...(after === undefined ? {} : { after }),
|
|
788
951
|
...(commitOid === undefined ? {} : { commitOid }),
|
|
952
|
+
...(preExisting === undefined ? {} : { preExisting }),
|
|
789
953
|
});
|
|
790
954
|
}
|
|
791
955
|
|
|
@@ -813,16 +977,19 @@ export async function classifyRepositoryReceipt(
|
|
|
813
977
|
if (allowed.every((value) => value === 'unchanged')) {
|
|
814
978
|
return receipt('concurrent-or-foreign-change', baseline, after);
|
|
815
979
|
}
|
|
980
|
+
// DR-062 §2: a same-HEAD delta admits altered pre-existing entries beside
|
|
981
|
+
// new ones; only an entry whose change is nowhere keeps it ambiguous.
|
|
816
982
|
if (sameHead) {
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
}
|
|
983
|
+
const fates = await baselineEntryFates(baseline, after);
|
|
984
|
+
const lost = fates.some(([, fate]) => fate === 'lost');
|
|
820
985
|
return receipt(
|
|
821
|
-
allowed.includes('one-descendant-commit')
|
|
986
|
+
!lost && allowed.includes('one-descendant-commit')
|
|
822
987
|
? 'worktree-only-change'
|
|
823
988
|
: 'observation-ambiguous',
|
|
824
989
|
baseline,
|
|
825
990
|
after,
|
|
991
|
+
undefined,
|
|
992
|
+
preExistingFromFates(fates),
|
|
826
993
|
);
|
|
827
994
|
}
|
|
828
995
|
// A HEAD that stopped naming a commit lost its history; every commit
|
|
@@ -839,10 +1006,24 @@ export async function classifyRepositoryReceipt(
|
|
|
839
1006
|
if (count !== 1) {
|
|
840
1007
|
return receipt('rewritten-or-non-descendant', baseline, after);
|
|
841
1008
|
}
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
1009
|
+
// DR-062 §2: the one commit proves itself when every baseline entry is
|
|
1010
|
+
// preserved, absorbed, or altered and carried by it, and the after
|
|
1011
|
+
// projection holds nothing else.
|
|
1012
|
+
const fates = await baselineEntryFates(baseline, after);
|
|
1013
|
+
const unattributable =
|
|
1014
|
+
fates.some(
|
|
1015
|
+
([, fate]) => fate === 'lost' || fate === 'altered-uncommitted',
|
|
1016
|
+
) ||
|
|
1017
|
+
Object.keys(after.projection).some(
|
|
1018
|
+
(path) => !hasPath(baseline.projection, path),
|
|
1019
|
+
);
|
|
1020
|
+
return receipt(
|
|
1021
|
+
unattributable ? 'observation-ambiguous' : 'one-descendant-commit',
|
|
1022
|
+
baseline,
|
|
1023
|
+
after,
|
|
1024
|
+
unattributable ? undefined : after.head,
|
|
1025
|
+
preExistingFromFates(fates),
|
|
1026
|
+
);
|
|
846
1027
|
}
|
|
847
1028
|
|
|
848
1029
|
export async function captureRepositoryReceipt(baseline, options = {}) {
|
|
@@ -359,10 +359,14 @@ export async function runPlaybookRun(options = {}) {
|
|
|
359
359
|
overlayPaths: args.withPaths,
|
|
360
360
|
structuralProjection: priorRecord.structuralProjection,
|
|
361
361
|
onNotice: (line) => configNotices.push(line),
|
|
362
|
+
env,
|
|
363
|
+
homeDir: home,
|
|
362
364
|
})
|
|
363
365
|
: await loadLaunchPlan({
|
|
364
366
|
userConfigPath,
|
|
365
367
|
overlayPaths: args.withPaths,
|
|
368
|
+
env,
|
|
369
|
+
homeDir: home,
|
|
366
370
|
loadModule,
|
|
367
371
|
prepareRegistryModule,
|
|
368
372
|
onNotice: (line) => configNotices.push(line),
|
|
@@ -3163,7 +3163,7 @@ async function createLease({
|
|
|
3163
3163
|
}
|
|
3164
3164
|
if (
|
|
3165
3165
|
snapshot.mode !== 'chat' ||
|
|
3166
|
-
snapshot.lastAction !== 'runtime' ||
|
|
3166
|
+
(snapshot.lastAction !== 'runtime' && snapshot.lastAction !== 'dismiss') ||
|
|
3167
3167
|
snapshot.lastSettlementStatus !== 'ok'
|
|
3168
3168
|
) {
|
|
3169
3169
|
throw new Error(
|
|
@@ -4221,7 +4221,7 @@ function validateCanonicalCaptainSessionRecord(
|
|
|
4221
4221
|
}
|
|
4222
4222
|
if (
|
|
4223
4223
|
snapshot.mode !== 'chat' ||
|
|
4224
|
-
snapshot.lastAction !== 'runtime' ||
|
|
4224
|
+
(snapshot.lastAction !== 'runtime' && snapshot.lastAction !== 'dismiss') ||
|
|
4225
4225
|
snapshot.lastSettlementStatus !== 'ok' ||
|
|
4226
4226
|
Object.hasOwn(record.retainedGenerations ?? {}, rootPlaybookId)
|
|
4227
4227
|
) {
|
|
@@ -212,7 +212,7 @@ export declare const codingMachine: import("xstate").StateMachine<CodingContext,
|
|
|
212
212
|
} | {
|
|
213
213
|
type: "resumesIrTask";
|
|
214
214
|
params: unknown;
|
|
215
|
-
}, never, "done" | "failed" | "
|
|
215
|
+
}, never, "done" | "failed" | "ready" | "awaitBossReply" | "runFirstPhase" | "reviewFirstCommit" | "runIrTask" | "reviewIrTask" | "reportedReviewFailure", string, CodingInput, {
|
|
216
216
|
readonly status: "complete";
|
|
217
217
|
/** Exact identity of the last CODE-owned commit. */
|
|
218
218
|
readonly lastCodeCommit: string;
|
|
@@ -32,11 +32,17 @@ export interface PlaybookRepositoryObservation extends RepositoryIdentity {
|
|
|
32
32
|
readonly projection: Readonly<Record<string, JsonValue>>;
|
|
33
33
|
readonly projectionDigest: string;
|
|
34
34
|
}
|
|
35
|
+
export interface PlaybookRepositoryPreExistingChanges {
|
|
36
|
+
readonly absorbed: readonly string[];
|
|
37
|
+
readonly altered: readonly string[];
|
|
38
|
+
readonly lost: readonly string[];
|
|
39
|
+
}
|
|
35
40
|
export interface PlaybookRepositoryReceipt {
|
|
36
41
|
readonly classification: RepositoryReceiptClassification;
|
|
37
42
|
readonly baseline: PlaybookRepositoryObservation;
|
|
38
43
|
readonly after?: PlaybookRepositoryObservation;
|
|
39
44
|
readonly commitOid?: string;
|
|
45
|
+
readonly preExisting?: PlaybookRepositoryPreExistingChanges;
|
|
40
46
|
}
|
|
41
47
|
export interface RepositoryReceiptOptions {
|
|
42
48
|
readonly allowedDispositions: readonly PlaybookRepositoryDisposition[];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Captain, type CaptainSession, type TuningSelection } from '@sublang/cligent/tmux-play';
|
|
2
2
|
import type { Effort, PermissionPolicy } from '@sublang/cligent';
|
|
3
|
-
import type { JsonValue, PlaybookEffectLedger, PlaybookEffectLedgerCommandBatch, PlaybookControlAction, PlaybookRuntime, PlaybookRuntimeSnapshot } from '@sublang/playbook/runtime';
|
|
3
|
+
import type { JsonValue, PlaybookEffectLedger, PlaybookEffectLedgerCommandBatch, PlaybookControlAction, PlaybookFailureCause, PlaybookRuntime, PlaybookRuntimeSnapshot } from '@sublang/playbook/runtime';
|
|
4
4
|
import { type CaptainControllerPort } from '../captain.playbook/captain.playbook.js';
|
|
5
5
|
import type { PlaybookSummaryPolicy } from './code.registry.js';
|
|
6
6
|
interface SessionAgent {
|
|
@@ -176,9 +176,11 @@ type PlaybookCaptainShellSnapshotValue = PlaybookCaptainShellSnapshotFields & ({
|
|
|
176
176
|
readonly frames: readonly PlaybookCaptainFrameSnapshot[];
|
|
177
177
|
readonly retainedEffectReconciliation?: PlaybookCaptainRetainedEffectReconciliation;
|
|
178
178
|
readonly pendingBossQuestions?: JsonValue;
|
|
179
|
+
/** DR-063 §2: the parked failure's cause travels with its error. */
|
|
179
180
|
readonly lastError?: {
|
|
180
181
|
readonly name: string;
|
|
181
182
|
readonly message: string;
|
|
183
|
+
readonly cause?: PlaybookFailureCause;
|
|
182
184
|
};
|
|
183
185
|
});
|
|
184
186
|
export type PlaybookCaptainShellSnapshot = DeepReadonly<PlaybookCaptainShellSnapshotValue>;
|