@pmelab/gtd 62.1.0 → 64.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/.claude-plugin/plugin.json +1 -1
- package/README.md +8 -3
- package/dist/chunks/{flows-DENt0uWV.mjs → flows-B7ZRCe-B.mjs} +1 -1
- package/dist/chunks/{rolldown-runtime-8H4AJuhK.mjs → rolldown-runtime-D7D4PA-g.mjs} +0 -1
- package/dist/chunks/{steering-DF_BMadH.mjs → steering-DkA51qDa.mjs} +1 -1
- package/dist/chunks/{testkit-CPzWnl3P.mjs → testkit-zGjNeQNL.mjs} +1144 -345
- package/dist/flows.mjs +1 -1
- package/dist/gtd.bundle.mjs +4023 -1326
- package/dist/gtd.core.mjs +120 -119
- package/dist/steering.mjs +1 -1
- package/dist/testkit/gherkin.mjs +2 -2
- package/dist/testkit.mjs +2 -2
- package/dist/workflow-kit.mjs +2 -2
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +5 -8
- package/src/cli/Cli.ts +10 -28
- package/src/cli/Output.ts +2 -1
- package/src/cli/Program.test.ts +55 -53
- package/src/cli/Program.ts +36 -45
- package/src/cli/ToolCommands.ts +3 -4
- package/src/edge/Edge.test.ts +38 -12
- package/src/edge/Edge.ts +98 -13
- package/src/edge/index.ts +1 -0
- package/src/emit/SteeringMode.test.ts +4 -2
- package/src/emit/SteeringMode.ts +10 -9
- package/src/gherkin/messages.test.ts +1 -1
- package/src/gherkin/module-config.feature +1 -1
- package/src/gherkin/testkit.feature +2 -2
- package/src/install/Version.ts +14 -13
- package/src/platform/Host.ts +75 -22
- package/src/platform/NodePinFile.ts +44 -0
- package/src/platform/NodeRuntime.ts +19 -0
- package/src/platform/NodeSync.ts +64 -0
- package/src/platform/PinFile.ts +0 -50
- package/src/platform/Sha.ts +121 -0
- package/src/platform/SyncIo.ts +35 -0
- package/src/platform/Workspace.test.ts +8 -3
- package/src/platform/Workspace.ts +82 -123
- package/src/platform/WorktreeState.test.ts +8 -4
- package/src/platform/WorktreeState.ts +21 -19
- package/src/platform/index.ts +18 -5
- package/src/replay/Format.test.ts +32 -10
- package/src/replay/Format.ts +5 -2
- package/src/replay/Replay.test.ts +110 -3
- package/src/replay/Replay.ts +120 -28
- package/src/replay/Trailers.test.ts +10 -10
- package/src/replay/Trailers.ts +32 -1
- package/src/replay/index.ts +2 -0
- package/src/step/planStart.test.ts +2 -2
- package/src/step/planStep.test.ts +4 -4
- package/src/testkit/TestRepo.test.ts +1 -1
- package/src/testkit/TestRepo.ts +4 -18
- package/src/twins/Host.test.ts +151 -0
- package/src/twins/Layers.test.ts +2 -2
- package/src/twins/Layers.ts +44 -18
- package/src/twins/Path.test.ts +52 -0
- package/src/twins/SyncIo.test.ts +153 -0
- package/src/twins/SyncIo.ts +159 -0
- package/src/twins/cliIo.ts +8 -1
- package/src/twins/index.ts +5 -0
- package/src/workflow/discovery.test.ts +6 -5
- package/src/workflow/discovery.ts +43 -32
- package/src/workflow/fingerprint.test.ts +23 -3
- package/src/workflow/fingerprint.ts +9 -11
- package/src/workflow/load.test.ts +16 -6
- package/src/workflow/load.ts +107 -69
- package/src/workflow/pin.test.ts +3 -2
- package/src/workflow/pin.ts +17 -11
|
@@ -1154,6 +1154,8 @@ describe("human({ approve })", () => {
|
|
|
1154
1154
|
expect(outcome).toEqual({
|
|
1155
1155
|
kind: "divergence",
|
|
1156
1156
|
message: `gtd: commit ${hashOf(1).slice(0, 7)} completes approval gate "gate#1" but records no Gtd-Approved for it — the workflow changed under this process; run \`gtd abandon\` to start over`,
|
|
1157
|
+
at: hashOf(0),
|
|
1158
|
+
reached: "gate",
|
|
1157
1159
|
})
|
|
1158
1160
|
})
|
|
1159
1161
|
|
|
@@ -1224,7 +1226,7 @@ describe("pin commits", () => {
|
|
|
1224
1226
|
const pinned = () =>
|
|
1225
1227
|
new History()
|
|
1226
1228
|
.land("human", "idle", 1, "build", { "NOTE.md": "n" })
|
|
1227
|
-
.raw("gtd(check): pin → build\n\nGtd-Pin: add", { [PIN]: "gtdpin 1 gtd 1\n" })
|
|
1229
|
+
.raw("gtd(check): gtd:pin → build\n\nGtd-Pin: add", { [PIN]: "gtdpin 1 gtd 1\n" })
|
|
1228
1230
|
|
|
1229
1231
|
it("are never a turn: the next step answers from the commit after the pin", async () => {
|
|
1230
1232
|
const flow = async () => {
|
|
@@ -1266,8 +1268,113 @@ describe("pin commits", () => {
|
|
|
1266
1268
|
await agent("build", "b")
|
|
1267
1269
|
}
|
|
1268
1270
|
const h = pinned()
|
|
1269
|
-
.land("agent", "build", 1, "unpin", { "a.ts": "a" })
|
|
1270
|
-
.raw("gtd(check): unpin → idle\n\nGtd-Pin: remove", { [PIN]: null })
|
|
1271
|
+
.land("agent", "build", 1, "gtd:unpin", { "a.ts": "a" })
|
|
1272
|
+
.raw("gtd(check): gtd:unpin → idle\n\nGtd-Pin: remove", { [PIN]: null })
|
|
1271
1273
|
expect((await replayOf(flow, h)).kind).toBe("ended")
|
|
1272
1274
|
})
|
|
1273
1275
|
})
|
|
1276
|
+
|
|
1277
|
+
describe("replay: a repin's anchor", () => {
|
|
1278
|
+
const anchorTo = (to: string, at: number): string =>
|
|
1279
|
+
formatCommitMessage({ actor: "check", from: "gtd:pin", to, pin: "add", anchor: hashOf(at) })
|
|
1280
|
+
|
|
1281
|
+
// The old flow went idle → build → old; this one goes idle → build → check.
|
|
1282
|
+
const anchoredHistory = (): History =>
|
|
1283
|
+
new History()
|
|
1284
|
+
.land("human", "idle", 1, "build", { "a.txt": "a" })
|
|
1285
|
+
.land("agent", "build", 1, "old", { "b.txt": "b" })
|
|
1286
|
+
.raw(anchorTo("build", 1))
|
|
1287
|
+
|
|
1288
|
+
it("records where replay diverged and the step it reached there", async () => {
|
|
1289
|
+
const h = new History().land("human", "idle", 1, "fix", { "TODO.md": "go" })
|
|
1290
|
+
const outcome = await replayOf(fixLoop, h)
|
|
1291
|
+
expect(outcome).toMatchObject({ kind: "divergence", at: hashOf(1), reached: "check" })
|
|
1292
|
+
})
|
|
1293
|
+
|
|
1294
|
+
it("rests at the anchored step, redone on the anchor's tree", async () => {
|
|
1295
|
+
let afterBuild: readonly string[] = []
|
|
1296
|
+
const flow: Flow = async () => {
|
|
1297
|
+
await human("idle")
|
|
1298
|
+
await agent("build", "p")
|
|
1299
|
+
afterBuild = changes().map((c) => c.path)
|
|
1300
|
+
await agent("check", "p")
|
|
1301
|
+
}
|
|
1302
|
+
expect(restName(await replayOf(flow, anchoredHistory()))).toBe("build#1")
|
|
1303
|
+
const outcome = await replayOf(flow, anchoredHistory(), { pending: { "c.txt": "c" } })
|
|
1304
|
+
expect(restName(outcome)).toBe("check#1")
|
|
1305
|
+
expect(afterBuild).toEqual(["c.txt"])
|
|
1306
|
+
})
|
|
1307
|
+
|
|
1308
|
+
it("fails changesSince() on a commit it set aside", async () => {
|
|
1309
|
+
const flow: Flow = async () => {
|
|
1310
|
+
await human("idle")
|
|
1311
|
+
changesSince(hashOf(2))
|
|
1312
|
+
await agent("build", "p")
|
|
1313
|
+
}
|
|
1314
|
+
const outcome = await replayOf(flow, anchoredHistory())
|
|
1315
|
+
expect(outcome.kind === "failed" && outcome.message).toContain("a repin set 0000000 aside")
|
|
1316
|
+
})
|
|
1317
|
+
|
|
1318
|
+
it("diverges again when the flow reaches another step than the anchor names", async () => {
|
|
1319
|
+
const flow: Flow = async () => {
|
|
1320
|
+
await human("idle")
|
|
1321
|
+
await agent("make", "p")
|
|
1322
|
+
}
|
|
1323
|
+
expect(await replayOf(flow, anchoredHistory())).toMatchObject({
|
|
1324
|
+
kind: "divergence",
|
|
1325
|
+
at: hashOf(3),
|
|
1326
|
+
reached: "make",
|
|
1327
|
+
})
|
|
1328
|
+
})
|
|
1329
|
+
|
|
1330
|
+
it("lets the flow code leading to the step read HEAD's tree", async () => {
|
|
1331
|
+
const flow: Flow = async () => {
|
|
1332
|
+
await human("idle")
|
|
1333
|
+
await agent("build", `seen: ${read("b.txt") ?? "nothing"}`)
|
|
1334
|
+
}
|
|
1335
|
+
const outcome = await replayOf(flow, anchoredHistory())
|
|
1336
|
+
expect(outcome.kind === "rest" && outcome.rest.request).toMatchObject({ prompt: "seen: b" })
|
|
1337
|
+
})
|
|
1338
|
+
|
|
1339
|
+
it("diverges when the flow ends where it names a step", async () => {
|
|
1340
|
+
const outcome = await replayOf(async () => void (await human("idle")), anchoredHistory())
|
|
1341
|
+
expect(outcome).toMatchObject({ kind: "divergence", at: hashOf(3), reached: undefined })
|
|
1342
|
+
})
|
|
1343
|
+
|
|
1344
|
+
it("closes the process when it names the unpin and the flow ends", async () => {
|
|
1345
|
+
const h = new History()
|
|
1346
|
+
.land("human", "idle", 1, "build", { "a.txt": "a" })
|
|
1347
|
+
.land("agent", "build", 1, "old")
|
|
1348
|
+
.raw(anchorTo("gtd:unpin", 1))
|
|
1349
|
+
const outcome = await replayOf(async () => void (await human("idle")), h)
|
|
1350
|
+
expect(outcome.kind).toBe("ended")
|
|
1351
|
+
})
|
|
1352
|
+
|
|
1353
|
+
it("closes the process when a format 3 anchor names the unpin by its old name", async () => {
|
|
1354
|
+
const h = new History()
|
|
1355
|
+
.land("human", "idle", 1, "build", { "a.txt": "a" })
|
|
1356
|
+
.land("agent", "build", 1, "old")
|
|
1357
|
+
.raw(`gtd(check): pin → unpin\n\nGtd-Pin: add\nGtd-Anchor: ${hashOf(1)}\nGtd-Format: 3`)
|
|
1358
|
+
const outcome = await replayOf(async () => void (await human("idle")), h)
|
|
1359
|
+
expect(outcome.kind).toBe("ended")
|
|
1360
|
+
})
|
|
1361
|
+
|
|
1362
|
+
it("reads a format 4 anchor naming unpin as a step", async () => {
|
|
1363
|
+
const outcome = await replayOf(
|
|
1364
|
+
async () => void (await human("idle")),
|
|
1365
|
+
new History()
|
|
1366
|
+
.land("human", "idle", 1, "build", { "a.txt": "a" })
|
|
1367
|
+
.land("agent", "build", 1, "old")
|
|
1368
|
+
.raw(anchorTo("unpin", 1)),
|
|
1369
|
+
)
|
|
1370
|
+
expect(outcome).toMatchObject({ kind: "divergence", reached: undefined })
|
|
1371
|
+
})
|
|
1372
|
+
|
|
1373
|
+
it("fails when it names no commit replay reads", async () => {
|
|
1374
|
+
const h = anchoredHistory().raw(anchorTo("build", 2))
|
|
1375
|
+
const outcome = await replayOf(async () => void (await human("idle")), h)
|
|
1376
|
+
expect(outcome.kind === "failed" && outcome.message).toContain(
|
|
1377
|
+
"which is no commit replay reads",
|
|
1378
|
+
)
|
|
1379
|
+
})
|
|
1380
|
+
})
|
package/src/replay/Replay.ts
CHANGED
|
@@ -29,10 +29,14 @@ import {
|
|
|
29
29
|
type CommitMessage,
|
|
30
30
|
type JudgeVerdict,
|
|
31
31
|
type StepId,
|
|
32
|
-
|
|
32
|
+
ENGINE_PREFIX,
|
|
33
|
+
pinStateIn,
|
|
33
34
|
} from "./Trailers.js"
|
|
34
35
|
|
|
35
|
-
const
|
|
36
|
+
const namesUnpin = (commit: ParsedCommit): boolean => {
|
|
37
|
+
const unpin = pinStateIn(commit.parsed.format, "remove")
|
|
38
|
+
return unpin !== undefined && commit.parsed.parsed?.to === unpin
|
|
39
|
+
}
|
|
36
40
|
|
|
37
41
|
export interface EpisodeCommit {
|
|
38
42
|
readonly hash: string
|
|
@@ -127,7 +131,14 @@ export type ReplayOutcome =
|
|
|
127
131
|
readonly landed: ReachedStep | undefined
|
|
128
132
|
readonly marks: readonly Mark[]
|
|
129
133
|
}
|
|
130
|
-
| {
|
|
134
|
+
| {
|
|
135
|
+
readonly kind: "divergence"
|
|
136
|
+
readonly message: string
|
|
137
|
+
/** The commit replay last stood on — where a repin anchors. */
|
|
138
|
+
readonly at: string
|
|
139
|
+
/** The step replay reached there; `undefined` when the flow ended instead. */
|
|
140
|
+
readonly reached: string | undefined
|
|
141
|
+
}
|
|
131
142
|
| { readonly kind: "refused"; readonly message: string }
|
|
132
143
|
| { readonly kind: "failed"; readonly message: string }
|
|
133
144
|
|
|
@@ -261,6 +272,38 @@ interface ParsedCommit extends EpisodeCommit {
|
|
|
261
272
|
readonly parsed: CommitMessage
|
|
262
273
|
}
|
|
263
274
|
|
|
275
|
+
const anchorOf = (parsed: CommitMessage): string | undefined =>
|
|
276
|
+
parsed.pin === "add" ? parsed.anchor : undefined
|
|
277
|
+
|
|
278
|
+
const isAnchor = (commit: ParsedCommit): boolean => anchorOf(commit.parsed) !== undefined
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The commits every anchor sets aside — those between the commit it names and
|
|
282
|
+
* itself — or a fault for an anchor naming no commit still read.
|
|
283
|
+
*/
|
|
284
|
+
export const setAsideBy = (
|
|
285
|
+
base: string,
|
|
286
|
+
all: readonly { readonly hash: string; readonly parsed: CommitMessage }[],
|
|
287
|
+
): { readonly setAside: ReadonlySet<string> } | { readonly fault: string } => {
|
|
288
|
+
const setAside = new Set<string>()
|
|
289
|
+
if (all.every((c) => anchorOf(c.parsed) === undefined)) return { setAside }
|
|
290
|
+
let kept: string[] = []
|
|
291
|
+
for (const commit of all) {
|
|
292
|
+
const anchor = anchorOf(commit.parsed)
|
|
293
|
+
if (anchor !== undefined) {
|
|
294
|
+
const at = anchor === base ? -1 : kept.indexOf(anchor)
|
|
295
|
+
if (anchor !== base && at === -1) {
|
|
296
|
+
return {
|
|
297
|
+
fault: `gtd: commit ${short(commit.hash)} anchors the process at ${short(anchor)}, which is no commit replay reads in this episode`,
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
for (const hash of kept.splice(at + 1)) setAside.add(hash)
|
|
301
|
+
}
|
|
302
|
+
kept.push(commit.hash)
|
|
303
|
+
}
|
|
304
|
+
return { setAside }
|
|
305
|
+
}
|
|
306
|
+
|
|
264
307
|
/**
|
|
265
308
|
* Replay one episode: run its entry flow, answering each step from the
|
|
266
309
|
* episode's commits in order, until a step has no commit left — the rest.
|
|
@@ -323,10 +366,17 @@ const withoutPin = (input: ReplayInput): ReplayInput => {
|
|
|
323
366
|
|
|
324
367
|
export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
|
|
325
368
|
const input = withoutPin(raw)
|
|
326
|
-
const
|
|
369
|
+
const all: readonly ParsedCommit[] = input.episode.commits.map((c) => ({
|
|
327
370
|
...c,
|
|
328
371
|
parsed: parseCommitMessage(c.message),
|
|
329
372
|
}))
|
|
373
|
+
const resolved = setAsideBy(input.episode.base.hash, all)
|
|
374
|
+
if ("fault" in resolved) return { kind: "failed", message: resolved.fault }
|
|
375
|
+
const setAside = resolved.setAside
|
|
376
|
+
const commits = all.filter((c) => !setAside.has(c.hash))
|
|
377
|
+
const pinned = (raw.episode.commits.at(-1)?.tree ?? raw.episode.base.tree)
|
|
378
|
+
.paths()
|
|
379
|
+
.includes(PIN_PATH)
|
|
330
380
|
|
|
331
381
|
// Every replay re-derives base, commits and head() from whatever history
|
|
332
382
|
// exists right now, so a hash a flow reads this same run (head()/start())
|
|
@@ -343,12 +393,27 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
343
393
|
let pendingUsed = false
|
|
344
394
|
let position: Position = input.episode.base
|
|
345
395
|
// Never stands on a pin commit: `head()` names the turn, so a flow
|
|
346
|
-
// reverting it reverts the turn, not the pin.
|
|
396
|
+
// reverting it reverts the turn, not the pin. An anchor is the exception:
|
|
397
|
+
// replay stands on it for HEAD's tree, so the flow code leading to the step
|
|
398
|
+
// it names, and that step's actor, see the set-aside work in place.
|
|
399
|
+
// `changes()` keeps the previous position: the set-aside range counts as
|
|
400
|
+
// part of the last step replay read.
|
|
347
401
|
const skipPins = (): void => {
|
|
348
|
-
while (cursor < commits.length && commits[cursor]!.parsed.pin !== undefined)
|
|
402
|
+
while (cursor < commits.length && commits[cursor]!.parsed.pin !== undefined) {
|
|
403
|
+
const commit = commits[cursor]!
|
|
404
|
+
cursor++
|
|
405
|
+
if (!isAnchor(commit)) continue
|
|
406
|
+
position = commit
|
|
407
|
+
const to = commit.parsed.parsed?.to
|
|
408
|
+
expectedNext =
|
|
409
|
+
to === undefined || namesUnpin(commit) ? undefined : { name: to, hash: commit.hash }
|
|
410
|
+
anchorPending = expectedNext !== undefined
|
|
411
|
+
}
|
|
349
412
|
}
|
|
350
413
|
let previousPosition: Position = input.episode.base
|
|
351
414
|
let expectedNext: { readonly name: string; readonly hash: string } | undefined
|
|
415
|
+
// Whether `expectedNext` is an anchor's, which a flow that ends contradicts.
|
|
416
|
+
let anchorPending = false
|
|
352
417
|
const occurrences = new Map<string, number>()
|
|
353
418
|
const personas = new Map<string, Identity>()
|
|
354
419
|
const scopes: ScopeOptions[] = []
|
|
@@ -547,18 +612,28 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
547
612
|
: `gtd: "${step.name}" runs with a different model, system prompt, skills or file access than an earlier agent step in its conversation in memory scope "${step.memoryScope || "root"}" — a conversation has one identity`
|
|
548
613
|
}
|
|
549
614
|
|
|
550
|
-
const DIVERGED =
|
|
615
|
+
const DIVERGED = pinned
|
|
616
|
+
? "the workflow changed under this process; run `gtd repin` to continue under it from there, or `gtd abandon` to start over"
|
|
617
|
+
: "the workflow changed under this process; run `gtd abandon` to start over"
|
|
618
|
+
|
|
619
|
+
const diverged = (message: string, reached: ReachedStep | undefined): ReplayOutcome => ({
|
|
620
|
+
kind: "divergence",
|
|
621
|
+
message: `${message} — ${DIVERGED}`,
|
|
622
|
+
at: position.hash,
|
|
623
|
+
reached: reached?.name,
|
|
624
|
+
})
|
|
551
625
|
|
|
552
626
|
// Checks a reached step against what the previous commit's subject promised
|
|
553
627
|
// and against the persona of its scope — the reasons replay cannot go on.
|
|
554
628
|
const blockerAt = (step: ReachedStep): ReplayOutcome | undefined => {
|
|
555
629
|
const expected = expectedNext
|
|
556
630
|
expectedNext = undefined
|
|
631
|
+
anchorPending = false
|
|
557
632
|
if (expected !== undefined && expected.name !== step.name) {
|
|
558
|
-
return
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
633
|
+
return diverged(
|
|
634
|
+
`gtd: commit ${short(expected.hash)} names "${expected.name}" as the next step, but replay reached "${step.name}"`,
|
|
635
|
+
step,
|
|
636
|
+
)
|
|
562
637
|
}
|
|
563
638
|
const error =
|
|
564
639
|
unknownOptions(step) ?? approveConflict(step) ?? modeWithoutFile(step) ?? checkIdentity(step)
|
|
@@ -569,10 +644,10 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
569
644
|
if (step.request.kind !== "human" || step.request.options.approve !== true) return undefined
|
|
570
645
|
const approved = commit.parsed.approved
|
|
571
646
|
if (approved !== undefined && formatStepId(approved.step) === expected) return undefined
|
|
572
|
-
return
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
647
|
+
return diverged(
|
|
648
|
+
`gtd: commit ${short(commit.hash)} completes approval gate "${expected}" but records no Gtd-Approved for it`,
|
|
649
|
+
step,
|
|
650
|
+
)
|
|
576
651
|
}
|
|
577
652
|
|
|
578
653
|
const consumeCommit = (step: ReachedStep, commit: ParsedCommit): unknown => {
|
|
@@ -581,23 +656,29 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
581
656
|
if (recorded === undefined || formatStepId(recorded) !== expected) {
|
|
582
657
|
const what =
|
|
583
658
|
recorded === undefined ? "records no step" : `records step "${formatStepId(recorded)}"`
|
|
584
|
-
return finish(
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
659
|
+
return finish(
|
|
660
|
+
diverged(
|
|
661
|
+
`gtd: commit ${short(commit.hash)} ${what}, but replay expected "${expected}"`,
|
|
662
|
+
step,
|
|
663
|
+
),
|
|
664
|
+
)
|
|
588
665
|
}
|
|
589
666
|
const unapproved = missingApproval(step, commit, expected)
|
|
590
667
|
if (unapproved !== undefined) return finish(unapproved)
|
|
591
668
|
cursor++
|
|
592
669
|
advance(commit)
|
|
670
|
+
expectNextOf(commit)
|
|
593
671
|
skipPins()
|
|
594
672
|
noteKept(step, commit)
|
|
595
|
-
const to = commit.parsed.parsed?.to
|
|
596
|
-
// A turn that ends a pinned process names the unpin, which is no step.
|
|
597
|
-
if (to !== undefined && to !== UNPIN_STATE) expectedNext = { name: to, hash: commit.hash }
|
|
598
673
|
return replyFor(step.request, commit.parsed.judge)
|
|
599
674
|
}
|
|
600
675
|
|
|
676
|
+
// A turn that ends a pinned process names the unpin, which is no step.
|
|
677
|
+
const expectNextOf = (commit: ParsedCommit): void => {
|
|
678
|
+
const to = commit.parsed.parsed?.to
|
|
679
|
+
if (to !== undefined && !namesUnpin(commit)) expectedNext = { name: to, hash: commit.hash }
|
|
680
|
+
}
|
|
681
|
+
|
|
601
682
|
const noteKept = (step: ReachedStep, commit: ParsedCommit): void => {
|
|
602
683
|
if (commit.parsed.kept === undefined) return
|
|
603
684
|
if (commit.kept === undefined) keptTips.delete(step.name)
|
|
@@ -642,8 +723,8 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
642
723
|
|
|
643
724
|
// gtd.config.ts is loaded without a type check.
|
|
644
725
|
const requestFault = (request: Exclude<StepRequest, { kind: "restart" }>): string | undefined => {
|
|
645
|
-
if (
|
|
646
|
-
return `gtd: step "${scoped(request.name)}": the
|
|
726
|
+
if (scoped(request.name).startsWith(ENGINE_PREFIX)) {
|
|
727
|
+
return `gtd: step "${scoped(request.name)}": the "${ENGINE_PREFIX}" prefix is reserved for gtd's own beats — rename the step`
|
|
647
728
|
}
|
|
648
729
|
if (request.kind === "run" && typeof request.body !== "string") {
|
|
649
730
|
return `gtd: step "${scoped(request.name)}": a run() body is a shell script string — decide in flow code, then render the script (see checkScript and friends)`
|
|
@@ -679,16 +760,27 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
679
760
|
|
|
680
761
|
const endOfFlow = (via: "return" | "restart"): ReplayOutcome => {
|
|
681
762
|
skipPins()
|
|
763
|
+
if (anchorPending && cursor === commits.length) {
|
|
764
|
+
return diverged(
|
|
765
|
+
`gtd: the flow ended, but commit ${short(expectedNext!.hash)} continues the process at "${expectedNext!.name}"`,
|
|
766
|
+
undefined,
|
|
767
|
+
)
|
|
768
|
+
}
|
|
682
769
|
return cursor < commits.length
|
|
683
|
-
?
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
770
|
+
? diverged(
|
|
771
|
+
`gtd: the flow ended, but commit ${short(commits[cursor]!.hash)} continues the episode`,
|
|
772
|
+
undefined,
|
|
773
|
+
)
|
|
687
774
|
: { kind: "ended", via, trace, landed, marks }
|
|
688
775
|
}
|
|
689
776
|
|
|
690
777
|
const changesSince = (hash: string): readonly Change[] => {
|
|
691
778
|
const tree = treeAt(hash)
|
|
779
|
+
if (setAside.has(hash)) {
|
|
780
|
+
throw new Error(
|
|
781
|
+
`gtd: changesSince(${hash}): a repin set ${short(hash)} aside — pass a hash this run read from head() or start()`,
|
|
782
|
+
)
|
|
783
|
+
}
|
|
692
784
|
if (tree === undefined) {
|
|
693
785
|
throw new Error(
|
|
694
786
|
`gtd: changesSince(${hash}): ${hash} is not the episode base or one of its commits — pass a hash this run read from head() or start(), not one captured earlier, read from state, or from another branch`,
|
|
@@ -46,7 +46,7 @@ describe("the commit-message codec", () => {
|
|
|
46
46
|
reviewBase: "abc",
|
|
47
47
|
})
|
|
48
48
|
expect(message).toBe(
|
|
49
|
-
"gtd(human): fix-precheck\n\nGtd-Workflow: fix\nGtd-Review-Base: abc\nGtd-Format:
|
|
49
|
+
"gtd(human): fix-precheck\n\nGtd-Workflow: fix\nGtd-Review-Base: abc\nGtd-Format: 4",
|
|
50
50
|
)
|
|
51
51
|
expect(parseCommitMessage(message).workflow).toBe("fix")
|
|
52
52
|
expect(parseCommitMessage("gtd(human): fix-precheck").workflow).toBeUndefined()
|
|
@@ -56,7 +56,7 @@ describe("the commit-message codec", () => {
|
|
|
56
56
|
const one = formatCommitMessage({ actor: "human", to: "x", vars: { b: "2", a: "1", c: "" } })
|
|
57
57
|
const two = formatCommitMessage({ actor: "human", to: "x", vars: { c: "", a: "1", b: "2" } })
|
|
58
58
|
expect(one).toBe(two)
|
|
59
|
-
expect(one).toBe("gtd(human): x\n\nGtd-Var: a=1\nGtd-Var: b=2\nGtd-Var: c=\nGtd-Format:
|
|
59
|
+
expect(one).toBe("gtd(human): x\n\nGtd-Var: a=1\nGtd-Var: b=2\nGtd-Var: c=\nGtd-Format: 4")
|
|
60
60
|
expect(parseCommitMessage(one).vars).toEqual({ a: "1", b: "2", c: "" })
|
|
61
61
|
})
|
|
62
62
|
|
|
@@ -81,8 +81,8 @@ describe("the commit-message codec", () => {
|
|
|
81
81
|
it("reads a Gtd-Version carrying control bytes as unknown, and keeps spaces and parens", () => {
|
|
82
82
|
const withVersion = (value: string) =>
|
|
83
83
|
formatCommitMessage({ actor: "a", to: "b" }).replace(
|
|
84
|
-
"Gtd-Format:
|
|
85
|
-
`Gtd-Version: ${value}\nGtd-Format:
|
|
84
|
+
"Gtd-Format: 4",
|
|
85
|
+
`Gtd-Version: ${value}\nGtd-Format: 4`,
|
|
86
86
|
)
|
|
87
87
|
expect(parseCommitMessage(withVersion("1.0\u001b]0;x\u0007")).version).toBeUndefined()
|
|
88
88
|
expect(parseCommitMessage(withVersion("1.0\u009b")).version).toBeUndefined()
|
|
@@ -100,18 +100,18 @@ describe("the commit-message codec", () => {
|
|
|
100
100
|
|
|
101
101
|
it("writes the current Gtd-Format when branch and version are written", () => {
|
|
102
102
|
const message = formatCommitMessage({ actor: "a", to: "b", branch: "m", version: "1" })
|
|
103
|
-
expect(message.endsWith("Gtd-Format:
|
|
103
|
+
expect(message.endsWith("Gtd-Format: 4")).toBe(true)
|
|
104
104
|
})
|
|
105
105
|
|
|
106
106
|
it("writes Gtd-Format last on every message, even one with no other trailer", () => {
|
|
107
107
|
const message = formatCommitMessage({ actor: "agent", to: "building" })
|
|
108
|
-
expect(message).toBe("gtd(agent): building\n\nGtd-Format:
|
|
109
|
-
expect(parseCommitMessage(message).format).toBe(
|
|
108
|
+
expect(message).toBe("gtd(agent): building\n\nGtd-Format: 4")
|
|
109
|
+
expect(parseCommitMessage(message).format).toBe(4)
|
|
110
110
|
})
|
|
111
111
|
|
|
112
112
|
it("reads a message without Gtd-Format as format 1, and an explicit one as written", () => {
|
|
113
113
|
expect(parseCommitMessage("gtd(agent): a → b\n\nGtd-Step: a#1").format).toBe(1)
|
|
114
|
-
expect(parseCommitMessage("gtd(agent): a → b\n\nGtd-Format:
|
|
114
|
+
expect(parseCommitMessage("gtd(agent): a → b\n\nGtd-Format: 4").format).toBe(4)
|
|
115
115
|
expect(parseCommitMessage("gtd(agent): a → b\n\nGtd-Format: two").format).toBe(1)
|
|
116
116
|
})
|
|
117
117
|
|
|
@@ -149,7 +149,7 @@ describe("Gtd-Kept", () => {
|
|
|
149
149
|
it("reads undefined when absent, and leaves Gtd-Format at the current format", () => {
|
|
150
150
|
const parsed = parseCommitMessage(formatCommitMessage({ actor: "agent", to: "b" }))
|
|
151
151
|
expect(parsed.kept).toBeUndefined()
|
|
152
|
-
expect(parsed.format).toBe(
|
|
152
|
+
expect(parsed.format).toBe(4)
|
|
153
153
|
})
|
|
154
154
|
})
|
|
155
155
|
|
|
@@ -289,7 +289,7 @@ describe("Gtd-Mark", () => {
|
|
|
289
289
|
const at = lines.findIndex((l) => l.startsWith("Gtd-Mark:"))
|
|
290
290
|
expect(lines[at - 1]).toMatch(/^Gtd-Judge:/)
|
|
291
291
|
expect(lines[at + 1]).toMatch(/^Gtd-Approved:/)
|
|
292
|
-
expect(lines).toContain("Gtd-Format:
|
|
292
|
+
expect(lines).toContain("Gtd-Format: 4")
|
|
293
293
|
})
|
|
294
294
|
|
|
295
295
|
it("writes nothing without marks", () => {
|
package/src/replay/Trailers.ts
CHANGED
|
@@ -70,8 +70,30 @@ export interface Approval {
|
|
|
70
70
|
|
|
71
71
|
export type PinChange = PinMark
|
|
72
72
|
|
|
73
|
+
/** Step names a workflow may not take: gtd's own beats live under it. */
|
|
74
|
+
export const ENGINE_PREFIX = "gtd:"
|
|
75
|
+
|
|
73
76
|
/** What a pin and an unpin beat rest at, and their commits name — neither is a step of the workflow. */
|
|
74
|
-
export const PIN_STATES: Readonly<Record<PinChange, string>> = {
|
|
77
|
+
export const PIN_STATES: Readonly<Record<PinChange, string>> = {
|
|
78
|
+
add: `${ENGINE_PREFIX}pin`,
|
|
79
|
+
remove: `${ENGINE_PREFIX}unpin`,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The names formats 2 and 3 gave the pin beats, before `gtd:` reserved them. */
|
|
83
|
+
export const LEGACY_PIN_STATES: Readonly<Record<PinChange, string>> = {
|
|
84
|
+
add: "pin",
|
|
85
|
+
remove: "unpin",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const SCOPED_PIN_FORMAT = 4
|
|
89
|
+
|
|
90
|
+
/** The name a commit in `format` gives a pin beat; format 1 has none. */
|
|
91
|
+
export const pinStateIn = (format: number, change: PinChange): string | undefined =>
|
|
92
|
+
format >= SCOPED_PIN_FORMAT
|
|
93
|
+
? PIN_STATES[change]
|
|
94
|
+
: format >= 2
|
|
95
|
+
? LEGACY_PIN_STATES[change]
|
|
96
|
+
: undefined
|
|
75
97
|
|
|
76
98
|
export interface CommitMessage {
|
|
77
99
|
readonly subject: string
|
|
@@ -94,6 +116,8 @@ export interface CommitMessage {
|
|
|
94
116
|
readonly approved: Approval | undefined
|
|
95
117
|
/** `Gtd-Pin`: a commit that adds or removes the pin — never a turn. */
|
|
96
118
|
readonly pin: PinChange | undefined
|
|
119
|
+
/** `Gtd-Anchor`, on a repin's commit: replay sets aside every commit after this one; a non-hash value is skipped. */
|
|
120
|
+
readonly anchor: string | undefined
|
|
97
121
|
readonly format: number
|
|
98
122
|
}
|
|
99
123
|
|
|
@@ -153,6 +177,7 @@ interface Trailers {
|
|
|
153
177
|
kept: string | undefined
|
|
154
178
|
approved: Approval | undefined
|
|
155
179
|
pin: PinChange | undefined
|
|
180
|
+
anchor: string | undefined
|
|
156
181
|
readonly judge: JudgeVerdict[]
|
|
157
182
|
readonly marks: Mark[]
|
|
158
183
|
readonly vars: Record<string, string>
|
|
@@ -200,6 +225,9 @@ const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) =
|
|
|
200
225
|
"Gtd-Pin": (value, into) => {
|
|
201
226
|
if (value === "add" || value === "remove") into.pin ??= value
|
|
202
227
|
},
|
|
228
|
+
"Gtd-Anchor": (value, into) => {
|
|
229
|
+
if (HASH_RE.test(value)) into.anchor ??= value
|
|
230
|
+
},
|
|
203
231
|
"Gtd-Format": (value, into) => {
|
|
204
232
|
if (FORMAT_RE.test(value)) into.format ??= Number(value)
|
|
205
233
|
},
|
|
@@ -221,6 +249,7 @@ export const parseCommitMessage = (message: string): CommitMessage => {
|
|
|
221
249
|
kept: undefined,
|
|
222
250
|
approved: undefined,
|
|
223
251
|
pin: undefined,
|
|
252
|
+
anchor: undefined,
|
|
224
253
|
judge: [],
|
|
225
254
|
marks: [],
|
|
226
255
|
vars: {},
|
|
@@ -250,6 +279,7 @@ export interface CommitSpec {
|
|
|
250
279
|
/** Written after `Gtd-Mark`, before `Gtd-Branch`. */
|
|
251
280
|
readonly approved?: Approval
|
|
252
281
|
readonly pin?: PinChange
|
|
282
|
+
readonly anchor?: string
|
|
253
283
|
readonly branch?: string
|
|
254
284
|
readonly version?: string
|
|
255
285
|
}
|
|
@@ -275,6 +305,7 @@ export const formatCommitMessage = (spec: CommitSpec): string => {
|
|
|
275
305
|
...(spec.marks ?? []).map((m) => `Gtd-Mark: ${m.name}=${m.value}`),
|
|
276
306
|
...trailer("Approved", spec.approved && encodeApproval(spec.approved)),
|
|
277
307
|
...trailer("Pin", spec.pin),
|
|
308
|
+
...trailer("Anchor", spec.anchor),
|
|
278
309
|
...trailer("Branch", spec.branch),
|
|
279
310
|
...trailer("Version", spec.version),
|
|
280
311
|
`Gtd-Format: ${HISTORY_FORMAT}`,
|
package/src/replay/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export {
|
|
2
2
|
replay,
|
|
3
3
|
scopeRunStart,
|
|
4
|
+
setAsideBy,
|
|
4
5
|
type EpisodeCommit,
|
|
5
6
|
type ReachedStep,
|
|
6
7
|
type ReplayOutcome,
|
|
@@ -16,6 +17,7 @@ export {
|
|
|
16
17
|
type CommitSpec,
|
|
17
18
|
type JudgeVerdict,
|
|
18
19
|
type PinChange,
|
|
20
|
+
LEGACY_PIN_STATES,
|
|
19
21
|
PIN_STATES,
|
|
20
22
|
} from "./Trailers.js"
|
|
21
23
|
export { accessShapeFault } from "./Access.js"
|
|
@@ -176,7 +176,7 @@ describe("planStart", () => {
|
|
|
176
176
|
kind: "gitWrite",
|
|
177
177
|
write: {
|
|
178
178
|
kind: "commitAll",
|
|
179
|
-
message: "gtd(human): work\n\nGtd-Workflow: working\nGtd-Var: base=\nGtd-Format:
|
|
179
|
+
message: "gtd(human): work\n\nGtd-Workflow: working\nGtd-Var: base=\nGtd-Format: 4",
|
|
180
180
|
},
|
|
181
181
|
},
|
|
182
182
|
{ kind: "outcome", outcome: { kind: "commit", subject: "gtd(human): work" } },
|
|
@@ -195,7 +195,7 @@ describe("planStart", () => {
|
|
|
195
195
|
if (write?.kind !== "gitWrite" || write.write.kind !== "commitAll")
|
|
196
196
|
throw new Error("expected a gitWrite step")
|
|
197
197
|
expect(write.write.message).toBe(
|
|
198
|
-
"gtd(human): work\n\nGtd-Workflow: working\nGtd-Var: base=main\nGtd-Format:
|
|
198
|
+
"gtd(human): work\n\nGtd-Workflow: working\nGtd-Var: base=main\nGtd-Format: 4",
|
|
199
199
|
)
|
|
200
200
|
const outcome = plan.steps.find((s) => s.kind === "outcome")
|
|
201
201
|
expect(outcome).toEqual({
|
|
@@ -39,7 +39,7 @@ describe("planStep — commit", () => {
|
|
|
39
39
|
expect(outcome.steps).toEqual([
|
|
40
40
|
{
|
|
41
41
|
kind: "gitWrite",
|
|
42
|
-
write: { kind: "commitAll", message: "gtd(agent): await-answers\n\nGtd-Format:
|
|
42
|
+
write: { kind: "commitAll", message: "gtd(agent): await-answers\n\nGtd-Format: 4" },
|
|
43
43
|
},
|
|
44
44
|
{ kind: "outcome", outcome: { kind: "commit", subject: "gtd(agent): await-answers" } },
|
|
45
45
|
])
|
|
@@ -59,7 +59,7 @@ describe("planStep — commit", () => {
|
|
|
59
59
|
kind: "gitWrite",
|
|
60
60
|
write: {
|
|
61
61
|
kind: "commitAll",
|
|
62
|
-
message: "gtd(human): building → done\n\nGtd-Step: building#1\nGtd-Format:
|
|
62
|
+
message: "gtd(human): building → done\n\nGtd-Step: building#1\nGtd-Format: 4",
|
|
63
63
|
},
|
|
64
64
|
})
|
|
65
65
|
expect(outcome.steps.at(-1)).toEqual({
|
|
@@ -99,7 +99,7 @@ describe("planStep — commit", () => {
|
|
|
99
99
|
expect(trailers[1]).toBe("Gtd-Cost: 1.5 m")
|
|
100
100
|
expect(trailers[2]).toBe('Gtd-Judge: {"id":"q1","answer":true,"p":0.9}')
|
|
101
101
|
expect(trailers[3]).toBe('Gtd-Judge: {"id":"q2","answer":"x","p":0.4}')
|
|
102
|
-
expect(trailers[4]).toBe("Gtd-Format:
|
|
102
|
+
expect(trailers[4]).toBe("Gtd-Format: 4")
|
|
103
103
|
expect(trailers).toHaveLength(5)
|
|
104
104
|
})
|
|
105
105
|
})
|
|
@@ -182,7 +182,7 @@ describe("planStep — keepTurn", () => {
|
|
|
182
182
|
write: {
|
|
183
183
|
kind: "keepTurn",
|
|
184
184
|
ref: kept,
|
|
185
|
-
message: "gtd(agent): building → checking\n\nGtd-Step: building#1\nGtd-Format:
|
|
185
|
+
message: "gtd(agent): building → checking\n\nGtd-Step: building#1\nGtd-Format: 4",
|
|
186
186
|
},
|
|
187
187
|
})
|
|
188
188
|
})
|
|
@@ -61,7 +61,7 @@ describe("testRepo", () => {
|
|
|
61
61
|
expect(repo.subjects()).toEqual([
|
|
62
62
|
"chore: initial commit",
|
|
63
63
|
"gtd(agent): plan → build",
|
|
64
|
-
"gtd(check): pin → build",
|
|
64
|
+
"gtd(check): gtd:pin → build",
|
|
65
65
|
"gtd(agent): build → check",
|
|
66
66
|
"gtd(check): check → sign-off",
|
|
67
67
|
])
|
package/src/testkit/TestRepo.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
|
-
import { PassThrough } from "node:stream"
|
|
3
2
|
import { runCli } from "../cli/index.js"
|
|
4
3
|
import { EXIT_OK, type BeatDocument } from "../wire/index.js"
|
|
5
4
|
import {
|
|
@@ -450,8 +449,8 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
|
450
449
|
|
|
451
450
|
// The modules are registered only while gtd runs, so nothing outlives the
|
|
452
451
|
// call that loads them and no test can reach another's module.
|
|
453
|
-
const
|
|
454
|
-
const { io, result } = makeCapturingCliIo(repo, seenEnv())
|
|
452
|
+
const gtdWithStdin = async (stdin: string, ...args: string[]): Promise<CapturedCliResult> => {
|
|
453
|
+
const { io, result } = makeCapturingCliIo(repo, seenEnv(), {}, stdin)
|
|
455
454
|
if (typeof workflow !== "string") registry().set(id, workflow)
|
|
456
455
|
for (const [specifier, module] of modules) registry().set(moduleKey(specifier), module)
|
|
457
456
|
try {
|
|
@@ -462,19 +461,7 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
|
462
461
|
}
|
|
463
462
|
return result()
|
|
464
463
|
}
|
|
465
|
-
|
|
466
|
-
// `gtd judge answer` reads its verdict off stdin, so it is swapped for the call.
|
|
467
|
-
const withStdin = async <T>(stdin: string, fn: () => Promise<T>): Promise<T> => {
|
|
468
|
-
const saved = Object.getOwnPropertyDescriptor(process, "stdin")
|
|
469
|
-
const fake = new PassThrough()
|
|
470
|
-
Object.defineProperty(process, "stdin", { value: fake, configurable: true })
|
|
471
|
-
fake.end(stdin)
|
|
472
|
-
try {
|
|
473
|
-
return await fn()
|
|
474
|
-
} finally {
|
|
475
|
-
if (saved) Object.defineProperty(process, "stdin", saved)
|
|
476
|
-
}
|
|
477
|
-
}
|
|
464
|
+
const gtd = (...args: string[]): Promise<CapturedCliResult> => gtdWithStdin("", ...args)
|
|
478
465
|
|
|
479
466
|
const apply = (what: string, script: string): void => {
|
|
480
467
|
if (script.length === 0) return
|
|
@@ -488,8 +475,7 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
|
|
|
488
475
|
stdin?: string,
|
|
489
476
|
refused: (error: Error) => Error = (error) => error,
|
|
490
477
|
): Promise<CapturedCliResult> => {
|
|
491
|
-
const call = (...extra: string[]) =>
|
|
492
|
-
stdin === undefined ? gtd(...args, ...extra) : withStdin(stdin, () => gtd(...args, ...extra))
|
|
478
|
+
const call = (...extra: string[]) => gtdWithStdin(stdin ?? "", ...args, ...extra)
|
|
493
479
|
const reported = await call()
|
|
494
480
|
if (reported.exitCode !== EXIT_OK) throw refused(failed(`gtd ${args.join(" ")}`, reported))
|
|
495
481
|
const script = await call("--json=script")
|