@sublang/playbook 14.0.0 → 15.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 +1 -1
- package/docs/cli.md +14 -14
- package/docs/configuration.md +15 -9
- package/package.json +1 -1
- package/reference/sdlc/branch.playbook/branch.fsm.d.ts +1 -1
- package/reference/sdlc/code.md +1 -1
- package/reference/sdlc/code.playbook/bin/launch-config.js +55 -16
- package/reference/sdlc/code.playbook/bin/playbook.js +14 -9
- package/reference/sdlc/code.playbook/bin/repository-effects.js +187 -14
- package/reference/sdlc/code.playbook/bin/run.js +12 -8
- package/reference/sdlc/code.playbook/code.fsm.d.ts +1 -1
- package/reference/sdlc/code.playbook/code.fsm.js +1 -1
- package/reference/sdlc/code.playbook/code.fsm.ts +1 -1
- package/reference/sdlc/code.playbook/code.gears.md +2 -2
- 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 +357 -16
- package/reference/sdlc/code.playbook/playbook-captain.ts +459 -34
- package/reference/sdlc/code.playbook/session-host.d.ts +6 -3
- package/reference/sdlc/decide.md +1 -1
- package/reference/sdlc/decide.playbook/decide.fsm.d.ts +1 -1
- package/reference/sdlc/decide.playbook/decide.fsm.js +1 -1
- package/reference/sdlc/decide.playbook/decide.fsm.ts +1 -1
- package/reference/sdlc/decide.playbook/decide.gears.md +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.js +1 -1
- package/reference/sdlc/dev.playbook/dev.playbook.ts +1 -2
- package/reference/sdlc/dev.playbook/dev.registry.js +1 -1
- package/reference/sdlc/dev.playbook/dev.registry.ts +1 -1
- package/reference/sdlc/pr.playbook/pr.fsm.d.ts +1 -1
- package/reference/sdlc/review.md +1 -1
- package/reference/sdlc/review.playbook/review.fsm.d.ts +1 -1
- package/reference/sdlc/review.playbook/review.fsm.js +1 -1
- package/reference/sdlc/review.playbook/review.fsm.ts +1 -1
- package/reference/sdlc/review.playbook/review.gears.md +1 -1
- package/slc/link.md +32 -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 +372 -20
- package/src/xstate-playbook-runtime.ts +460 -19
- 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
|
@@ -65,7 +65,7 @@ playbook
|
|
|
65
65
|
Type a task, enter `/code <task>` for implementation, or enter
|
|
66
66
|
`/decide <question>` for an independently proposed and reviewed decision.
|
|
67
67
|
|
|
68
|
-
On first launch, Playbook writes its config to `${SPEX_HOME:-$HOME/.spex}/
|
|
68
|
+
On first launch, Playbook writes its config to `${SPEX_HOME:-$HOME/.spex}/config/playbook.config.yaml`. A config left at either former location — the root's `playbook/playbook.config.yaml` or `${XDG_CONFIG_HOME:-$HOME/.config}/playbook/playbook.config.yaml` — is moved there once on the next launch, unless a relative primary locator would change targets; that case is rejected unchanged with absolute replacements.
|
|
69
69
|
|
|
70
70
|
The same config, compiled Captain, enabled playbooks, stable players, and
|
|
71
71
|
nested calls power headless turns. Both front ends create the same durable
|
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
|
@@ -4,24 +4,30 @@
|
|
|
4
4
|
# Configuring agents
|
|
5
5
|
|
|
6
6
|
Fresh launches and ordinary reopens read one config at
|
|
7
|
-
`${SPEX_HOME:-$HOME/.spex}/
|
|
7
|
+
`${SPEX_HOME:-$HOME/.spex}/config/playbook.config.yaml`. The
|
|
8
8
|
first launch seeds it from the bundled starter and prints the path;
|
|
9
9
|
later launches reuse it untouched.
|
|
10
10
|
|
|
11
|
-
On the first launching command after upgrading Playbook, it moves a config from
|
|
12
|
-
former
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
On the first launching command after upgrading Playbook, it moves a config from
|
|
12
|
+
either former location when the canonical path is absent — the root's
|
|
13
|
+
`playbook/playbook.config.yaml` first, then
|
|
14
|
+
`${XDG_CONFIG_HOME:-$HOME/.config}/playbook/playbook.config.yaml`. Exactly one
|
|
15
|
+
file moves; the one-time move preserves bytes and permissions, takes the former
|
|
16
|
+
file's directory with it when that leaves it empty, and leaves no compatibility
|
|
17
|
+
alias, so running an older Spex host afterward could seed a second file at the
|
|
18
|
+
path it still resolves.
|
|
16
19
|
|
|
17
|
-
The current guard rejects relocation when a
|
|
20
|
+
The current guard rejects relocation when a former relative `sessions` value
|
|
18
21
|
or relative filesystem `playbooks.<id>.from` would resolve differently below
|
|
19
22
|
the new directory. It leaves the former file unchanged and names every
|
|
20
23
|
target-preserving absolute replacement. Apply those replacements and retry;
|
|
21
|
-
Playbook does not rewrite the user-authored file.
|
|
24
|
+
Playbook does not rewrite the user-authored file. `config/` sits at the depth
|
|
25
|
+
the root's `playbook/` did, so a relative locator reaching outside the directory
|
|
26
|
+
keeps its target across that move; only one pointing into the directory is
|
|
27
|
+
refused as above.
|
|
22
28
|
|
|
23
29
|
```sh
|
|
24
|
-
$EDITOR "${SPEX_HOME:-$HOME/.spex}/
|
|
30
|
+
$EDITOR "${SPEX_HOME:-$HOME/.spex}/config/playbook.config.yaml"
|
|
25
31
|
```
|
|
26
32
|
|
|
27
33
|
## Anatomy
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sublang/playbook",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "15.0.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",
|
|
@@ -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;
|
package/reference/sdlc/code.md
CHANGED
|
@@ -66,7 +66,7 @@ Do not re-run tests or builds whose inputs have not changed since any previous r
|
|
|
66
66
|
Make the phase's minimal changes and then one new commit, following @specs/packages/git.md; never amend an existing commit.
|
|
67
67
|
Make the commit message explain concisely what changed and why, including relevant verification.
|
|
68
68
|
Identify every new commit you make.
|
|
69
|
-
Coder
|
|
69
|
+
Credit every AI that contributed to this commit: Coder <coder-llm>.
|
|
70
70
|
```
|
|
71
71
|
|
|
72
72
|
Every new-intent phase has two semantic outcomes: direct implementation and new IR; the new-IR outcome identifies the created IR.
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
mkdirSync,
|
|
16
16
|
mkdtempSync,
|
|
17
17
|
readFileSync,
|
|
18
|
+
rmdirSync,
|
|
18
19
|
rmSync,
|
|
19
20
|
writeFileSync,
|
|
20
21
|
} from "node:fs";
|
|
@@ -133,9 +134,11 @@ export function mergeSelectedConfigs(base, overlay, selectedMembers) {
|
|
|
133
134
|
);
|
|
134
135
|
}
|
|
135
136
|
|
|
136
|
-
// DR-043: the config lives under the shared Spex root, resolved exactly
|
|
137
|
-
// Spex's own shells resolve it so both hosts open one file.
|
|
138
|
-
//
|
|
137
|
+
// DR-043/DR-064: the config lives under the shared Spex root, resolved exactly
|
|
138
|
+
// as Spex's own shells resolve it so both hosts open one file. `config/` is the
|
|
139
|
+
// root's directory of human-authored configuration files, each named for the
|
|
140
|
+
// format it follows; the rest of the root is laid out the same way, by what a
|
|
141
|
+
// directory holds.
|
|
139
142
|
export function resolveSpexHome(env = process.env, home = homedir()) {
|
|
140
143
|
const explicit = env.SPEX_HOME;
|
|
141
144
|
if (typeof explicit === "string" && explicit.trim().length > 0) {
|
|
@@ -148,7 +151,7 @@ export function resolveSpexHome(env = process.env, home = homedir()) {
|
|
|
148
151
|
}
|
|
149
152
|
|
|
150
153
|
export function resolveUserConfigPath(env = process.env, home = homedir()) {
|
|
151
|
-
return join(resolveSpexHome(env, home), "
|
|
154
|
+
return join(resolveSpexHome(env, home), "config", "playbook.config.yaml");
|
|
152
155
|
}
|
|
153
156
|
|
|
154
157
|
// The pre-DR-043 location, kept only to relocate a config written there.
|
|
@@ -160,6 +163,18 @@ export function resolveLegacyUserConfigPath(
|
|
|
160
163
|
return join(configHome, "playbook", "playbook.config.yaml");
|
|
161
164
|
}
|
|
162
165
|
|
|
166
|
+
// DR-064: the locations a launch relocates from, the nearer first — the root's
|
|
167
|
+
// pre-DR-064 `playbook/` namespace, then the pre-DR-043 XDG path.
|
|
168
|
+
export function resolveFormerUserConfigPaths(
|
|
169
|
+
env = process.env,
|
|
170
|
+
home = homedir(),
|
|
171
|
+
) {
|
|
172
|
+
return [
|
|
173
|
+
join(resolveSpexHome(env, home), "playbook", "playbook.config.yaml"),
|
|
174
|
+
resolveLegacyUserConfigPath(env, home),
|
|
175
|
+
];
|
|
176
|
+
}
|
|
177
|
+
|
|
163
178
|
// PBCLI-46/78: session selection needs this one root locator before the
|
|
164
179
|
// complete launch plan can be selected and normalized. Read only that scalar
|
|
165
180
|
// from each layer so an ordinary reopen does not inspect unrelated current
|
|
@@ -1203,22 +1218,24 @@ export function deriveLaunchReadiness(
|
|
|
1203
1218
|
return { adapters, ...checkReadiness(adapters, env, home) };
|
|
1204
1219
|
}
|
|
1205
1220
|
|
|
1206
|
-
// DR-043: a user-authored config cannot be regenerated, so the one-time
|
|
1207
|
-
// to the canonical path is the deliberate exception to this project's
|
|
1221
|
+
// DR-043/DR-064: a user-authored config cannot be regenerated, so the one-time
|
|
1222
|
+
// move to the canonical path is the deliberate exception to this project's
|
|
1208
1223
|
// reject-don't-migrate posture. It runs before seeding, never clobbers a
|
|
1209
|
-
// canonical file, and is a no-op once the
|
|
1224
|
+
// canonical file, and is a no-op once the former file is gone. It answers
|
|
1225
|
+
// whether it published, so a caller walking the ordered former locations stops
|
|
1226
|
+
// at the one that moved.
|
|
1210
1227
|
export function relocateLegacyUserConfig(
|
|
1211
1228
|
userConfigPath,
|
|
1212
1229
|
legacyUserConfigPath,
|
|
1213
1230
|
onNotice,
|
|
1214
1231
|
) {
|
|
1215
|
-
if (legacyUserConfigPath === undefined) return;
|
|
1232
|
+
if (legacyUserConfigPath === undefined) return false;
|
|
1216
1233
|
// Any canonical filesystem entry wins, including a dangling symlink. The
|
|
1217
1234
|
// relocation is considered only when that exact pathname is absent; this
|
|
1218
1235
|
// keeps an obsolete or malformed legacy entry from blocking a valid config.
|
|
1219
1236
|
try {
|
|
1220
1237
|
lstatSync(userConfigPath);
|
|
1221
|
-
return;
|
|
1238
|
+
return false;
|
|
1222
1239
|
} catch (error) {
|
|
1223
1240
|
if (error?.code !== "ENOENT") throw error;
|
|
1224
1241
|
}
|
|
@@ -1226,7 +1243,7 @@ export function relocateLegacyUserConfig(
|
|
|
1226
1243
|
try {
|
|
1227
1244
|
source = lstatSync(legacyUserConfigPath);
|
|
1228
1245
|
} catch (error) {
|
|
1229
|
-
if (error?.code === "ENOENT") return;
|
|
1246
|
+
if (error?.code === "ENOENT") return false;
|
|
1230
1247
|
throw error;
|
|
1231
1248
|
}
|
|
1232
1249
|
if (!source.isFile()) {
|
|
@@ -1246,11 +1263,18 @@ export function relocateLegacyUserConfig(
|
|
|
1246
1263
|
const stagedPath = join(stagingDir, "playbook.config.yaml");
|
|
1247
1264
|
let published = false;
|
|
1248
1265
|
try {
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1266
|
+
try {
|
|
1267
|
+
copyFileSync(
|
|
1268
|
+
legacyUserConfigPath,
|
|
1269
|
+
stagedPath,
|
|
1270
|
+
constants.COPYFILE_EXCL,
|
|
1271
|
+
);
|
|
1272
|
+
} catch (error) {
|
|
1273
|
+
// The former file went between inspection and staging: there is
|
|
1274
|
+
// nothing left to move, and nothing to report.
|
|
1275
|
+
if (error?.code === "ENOENT") return false;
|
|
1276
|
+
throw error;
|
|
1277
|
+
}
|
|
1254
1278
|
chmodSync(stagedPath, source.mode & 0o7777);
|
|
1255
1279
|
assertLegacyRelocationLocatorsSafe(
|
|
1256
1280
|
readFileSync(stagedPath, "utf8"),
|
|
@@ -1268,12 +1292,27 @@ export function relocateLegacyUserConfig(
|
|
|
1268
1292
|
} finally {
|
|
1269
1293
|
rmSync(stagingDir, { recursive: true, force: true });
|
|
1270
1294
|
}
|
|
1271
|
-
if (!published) return;
|
|
1295
|
+
if (!published) return false;
|
|
1272
1296
|
|
|
1273
1297
|
rmSync(legacyUserConfigPath, { force: true });
|
|
1298
|
+
// The former file's directory went with it when it held nothing else. A
|
|
1299
|
+
// plain rmdir is the whole removal: it refuses a directory with any other
|
|
1300
|
+
// entry, so nothing the user put beside the config is ever swept away.
|
|
1301
|
+
try {
|
|
1302
|
+
rmdirSync(dirname(legacyUserConfigPath));
|
|
1303
|
+
} catch (error) {
|
|
1304
|
+
if (
|
|
1305
|
+
error?.code !== "ENOTEMPTY" &&
|
|
1306
|
+
error?.code !== "EEXIST" &&
|
|
1307
|
+
error?.code !== "ENOENT"
|
|
1308
|
+
) {
|
|
1309
|
+
throw error;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1274
1312
|
onNotice(
|
|
1275
1313
|
`playbook: moved config from ${legacyUserConfigPath} to ${userConfigPath}\n`,
|
|
1276
1314
|
);
|
|
1315
|
+
return true;
|
|
1277
1316
|
}
|
|
1278
1317
|
|
|
1279
1318
|
function assertLegacyRelocationLocatorsSafe(
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
projectTmuxConfig,
|
|
25
25
|
resolveLaunchSessionsDir,
|
|
26
26
|
relocateLegacyUserConfig,
|
|
27
|
-
|
|
27
|
+
resolveFormerUserConfigPaths,
|
|
28
28
|
resolveUserConfigPath,
|
|
29
29
|
checkReadiness,
|
|
30
30
|
} from "./launch-config.js";
|
|
@@ -233,16 +233,21 @@ export async function runPlaybookCli(options = {}) {
|
|
|
233
233
|
return { code: COMPOSITION_FAILURE_EXIT_CODE };
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
-
// DR-043: only a validated managed launch may relocate
|
|
237
|
-
// Help and raw --config return above without changing
|
|
238
|
-
// the delegated `run` front end applies the same boundary
|
|
236
|
+
// DR-043/DR-064: only a validated managed launch may relocate a config left
|
|
237
|
+
// at a former location. Help and raw --config return above without changing
|
|
238
|
+
// any config path; the delegated `run` front end applies the same boundary
|
|
239
|
+
// independently. The nearer former location goes first, and the one that
|
|
240
|
+
// moves ends the walk.
|
|
239
241
|
if (options.userConfigPath === undefined) {
|
|
240
242
|
try {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
243
|
+
for (const formerPath of resolveFormerUserConfigPaths(env, home)) {
|
|
244
|
+
const moved = relocateLegacyUserConfig(
|
|
245
|
+
userConfigPath,
|
|
246
|
+
formerPath,
|
|
247
|
+
(line) => stderr.write(line),
|
|
248
|
+
);
|
|
249
|
+
if (moved) break;
|
|
250
|
+
}
|
|
246
251
|
} catch (error) {
|
|
247
252
|
stderr.write(`playbook: ${errorMessage(error)}\n`);
|
|
248
253
|
return { code: COMPOSITION_FAILURE_EXIT_CODE };
|
|
@@ -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'
|
|
528
538
|
);
|
|
529
539
|
}
|
|
530
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
|
|
615
|
+
);
|
|
616
|
+
}
|
|
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,
|
|
@@ -788,12 +943,13 @@ async function descendantCount(worktree, baselineHead, afterHead) {
|
|
|
788
943
|
return count;
|
|
789
944
|
}
|
|
790
945
|
|
|
791
|
-
function receipt(classification, baseline, after, commitOid) {
|
|
946
|
+
function receipt(classification, baseline, after, commitOid, preExisting) {
|
|
792
947
|
return deepFreeze({
|
|
793
948
|
classification,
|
|
794
949
|
baseline,
|
|
795
950
|
...(after === undefined ? {} : { after }),
|
|
796
951
|
...(commitOid === undefined ? {} : { commitOid }),
|
|
952
|
+
...(preExisting === undefined ? {} : { preExisting }),
|
|
797
953
|
});
|
|
798
954
|
}
|
|
799
955
|
|
|
@@ -821,16 +977,19 @@ export async function classifyRepositoryReceipt(
|
|
|
821
977
|
if (allowed.every((value) => value === 'unchanged')) {
|
|
822
978
|
return receipt('concurrent-or-foreign-change', baseline, after);
|
|
823
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.
|
|
824
982
|
if (sameHead) {
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
}
|
|
983
|
+
const fates = await baselineEntryFates(baseline, after);
|
|
984
|
+
const lost = fates.some(([, fate]) => fate === 'lost');
|
|
828
985
|
return receipt(
|
|
829
|
-
allowed.includes('one-descendant-commit')
|
|
986
|
+
!lost && allowed.includes('one-descendant-commit')
|
|
830
987
|
? 'worktree-only-change'
|
|
831
988
|
: 'observation-ambiguous',
|
|
832
989
|
baseline,
|
|
833
990
|
after,
|
|
991
|
+
undefined,
|
|
992
|
+
preExistingFromFates(fates),
|
|
834
993
|
);
|
|
835
994
|
}
|
|
836
995
|
// A HEAD that stopped naming a commit lost its history; every commit
|
|
@@ -847,10 +1006,24 @@ export async function classifyRepositoryReceipt(
|
|
|
847
1006
|
if (count !== 1) {
|
|
848
1007
|
return receipt('rewritten-or-non-descendant', baseline, after);
|
|
849
1008
|
}
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
+
);
|
|
854
1027
|
}
|
|
855
1028
|
|
|
856
1029
|
export async function captureRepositoryReceipt(baseline, options = {}) {
|
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
projectHostAgent,
|
|
36
36
|
resolveLaunchSessionsDir,
|
|
37
37
|
relocateLegacyUserConfig,
|
|
38
|
-
|
|
38
|
+
resolveFormerUserConfigPaths,
|
|
39
39
|
resolveUserConfigPath,
|
|
40
40
|
snapshotRegistryEntry,
|
|
41
41
|
} from "./launch-config.js";
|
|
@@ -127,15 +127,19 @@ export async function runPlaybookRun(options = {}) {
|
|
|
127
127
|
|
|
128
128
|
const userConfigPath =
|
|
129
129
|
options.userConfigPath ?? resolveUserConfigPath(env, home);
|
|
130
|
-
// DR-043: move a
|
|
131
|
-
// read, seed, or plan work observes its absence.
|
|
130
|
+
// DR-043/DR-064: move a config left at a former location to the canonical
|
|
131
|
+
// path before any read, seed, or plan work observes its absence. The nearer
|
|
132
|
+
// former location goes first, and the one that moves ends the walk.
|
|
132
133
|
if (options.userConfigPath === undefined) {
|
|
133
134
|
try {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
135
|
+
for (const formerPath of resolveFormerUserConfigPaths(env, home)) {
|
|
136
|
+
const moved = relocateLegacyUserConfig(
|
|
137
|
+
userConfigPath,
|
|
138
|
+
formerPath,
|
|
139
|
+
(line) => stderr.write(line),
|
|
140
|
+
);
|
|
141
|
+
if (moved) break;
|
|
142
|
+
}
|
|
139
143
|
} catch (error) {
|
|
140
144
|
await writeStream(stderr, `playbook run: ${message(error)}\n`);
|
|
141
145
|
return { code: EXIT.argument };
|
|
@@ -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;
|
|
@@ -9,7 +9,7 @@ const APPENDED_PHASE_PROMPT = [
|
|
|
9
9
|
"Make the phase's minimal changes and then one new commit, following @specs/packages/git.md; never amend an existing commit.",
|
|
10
10
|
'Make the commit message explain concisely what changed and why, including relevant verification.',
|
|
11
11
|
'Identify every new commit you make.',
|
|
12
|
-
'Coder
|
|
12
|
+
'Credit every AI that contributed to this commit: Coder <coder-llm>.',
|
|
13
13
|
];
|
|
14
14
|
const FIRST_PHASE_PROMPT = [
|
|
15
15
|
'> Original request: <caller-input>',
|
|
@@ -152,7 +152,7 @@ const APPENDED_PHASE_PROMPT = [
|
|
|
152
152
|
"Make the phase's minimal changes and then one new commit, following @specs/packages/git.md; never amend an existing commit.",
|
|
153
153
|
'Make the commit message explain concisely what changed and why, including relevant verification.',
|
|
154
154
|
'Identify every new commit you make.',
|
|
155
|
-
'Coder
|
|
155
|
+
'Credit every AI that contributed to this commit: Coder <coder-llm>.',
|
|
156
156
|
];
|
|
157
157
|
|
|
158
158
|
const FIRST_PHASE_PROMPT = [
|
|
@@ -36,7 +36,7 @@ When the first coding phase begins, Captain shall prompt Coder:
|
|
|
36
36
|
> Make the phase's minimal changes and then one new commit, following @specs/packages/git.md; never amend an existing commit.
|
|
37
37
|
> Make the commit message explain concisely what changed and why, including relevant verification.
|
|
38
38
|
> Identify every new commit you make.
|
|
39
|
-
> Coder
|
|
39
|
+
> Credit every AI that contributed to this commit: Coder <coder-llm>.
|
|
40
40
|
|
|
41
41
|
Results:
|
|
42
42
|
- `directCommit`: Coder completed and committed the direct implementation phase. Output shall include `coderOutput: <verbatim final text>` and `latestCommit: <commit identity>`.
|
|
@@ -77,7 +77,7 @@ When a later IR-task phase begins, Captain shall prompt Coder:
|
|
|
77
77
|
> Make the phase's minimal changes and then one new commit, following @specs/packages/git.md; never amend an existing commit.
|
|
78
78
|
> Make the commit message explain concisely what changed and why, including relevant verification.
|
|
79
79
|
> Identify every new commit you make.
|
|
80
|
-
> Coder
|
|
80
|
+
> Credit every AI that contributed to this commit: Coder <coder-llm>.
|
|
81
81
|
|
|
82
82
|
Results:
|
|
83
83
|
- `moreTasks`: Coder completed and committed exactly the IR's next unfinished task and at least one task remains. Output shall include `coderOutput: <verbatim final text>`, `latestCommit: <commit identity>`, `irNumber` identifying the continued IR, and `irTask` naming the implemented task.
|
|
@@ -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[];
|