@pmelab/gtd 60.1.0 → 62.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 +16 -0
- package/dist/chunks/{testkit-BhTey1Do.mjs → testkit-XQeYCNMn.mjs} +1670 -1470
- package/dist/chunks/{wire-DZ8R1RU9.mjs → wire-CPNSm_9N.mjs} +27 -12
- package/dist/gtd.bundle.mjs +991 -627
- package/dist/gtd.core.mjs +117 -117
- package/dist/testkit/gherkin.mjs +1 -1
- package/dist/testkit.mjs +1 -1
- package/dist/wire.mjs +2 -2
- package/package.json +1 -1
- package/src/cli/Cli.ts +6 -1
- package/src/cli/History.test.ts +10 -1
- package/src/cli/Launch.test.ts +52 -0
- package/src/cli/Launch.ts +143 -0
- package/src/cli/Metrics.test.ts +10 -1
- package/src/cli/Program.test.ts +30 -20
- package/src/cli/Program.ts +4 -2
- package/src/cli/Strays.ts +3 -2
- package/src/cli/index.ts +1 -0
- package/src/edge/Edge.test.ts +79 -26
- package/src/edge/Edge.ts +178 -99
- package/src/emit/GitScript.ts +21 -3
- package/src/emit/index.ts +3 -0
- package/src/gherkin/messages.test.ts +1 -0
- package/src/gherkin/module-config.feature +1 -0
- package/src/gherkin/more-steps.feature +1 -0
- package/src/gherkin/testkit.feature +2 -1
- package/src/install/Install.ts +9 -2
- package/src/platform/PinFile.ts +61 -0
- package/src/platform/index.ts +1 -0
- package/src/replay/Format.test.ts +35 -8
- package/src/replay/Format.ts +7 -4
- package/src/replay/Replay.test.ts +54 -0
- package/src/replay/Replay.ts +61 -17
- package/src/replay/Trailers.test.ts +11 -48
- package/src/replay/Trailers.ts +36 -42
- package/src/replay/Tree.ts +11 -0
- package/src/replay/index.ts +3 -0
- package/src/step/LandStep.ts +1 -0
- package/src/step/RepoSnapshot.ts +2 -0
- package/src/step/planStart.test.ts +2 -3
- package/src/step/planStart.ts +1 -5
- package/src/step/planStep.test.ts +4 -4
- package/src/step/planStep.ts +10 -8
- package/src/testkit/TestRepo.test.ts +4 -1
- package/src/testkit/TestRepo.ts +3 -1
- package/src/twins/EmittedScriptRecognizer.ts +32 -1
- package/src/twins/GitTiers.ts +23 -0
- package/src/twins/InMemRepo.ts +14 -0
- package/src/twins/PinBeats.ts +33 -0
- package/src/twins/PinFile.test.ts +107 -0
- package/src/twins/PinFile.ts +12 -0
- package/src/twins/index.ts +2 -0
- package/src/wire/BeatDocument.ts +26 -5
- package/src/wire/Demand.ts +16 -6
- package/src/wire/constants.ts +3 -0
- package/src/wire/types.ts +4 -0
- package/src/workflow/fingerprint.ts +2 -2
- package/src/workflow/index.ts +2 -2
- package/src/workflow/load.ts +14 -1
- package/src/workflow/pin.ts +15 -7
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process"
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
3
|
+
import { registerHooks } from "node:module"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { pathToFileURL } from "node:url"
|
|
6
|
+
|
|
7
|
+
/** A process's pin as the launcher found it: the file's bytes and where its engine runs from. */
|
|
8
|
+
export interface FoundPin {
|
|
9
|
+
readonly path: string
|
|
10
|
+
readonly bytes: Uint8Array
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where the launcher looks for a pin: the worktree's copy, else HEAD's — so
|
|
15
|
+
* the landing after an unpin beat deleted the file still runs on the pin.
|
|
16
|
+
*/
|
|
17
|
+
export type PinFile = () => FoundPin | undefined
|
|
18
|
+
|
|
19
|
+
// `cwd`, as the engine's own root is: gtd runs only at the worktree root, and
|
|
20
|
+
// a relocated `$GIT_DIR` leaves no `.git` to walk up to. A file that cannot be
|
|
21
|
+
// read counts as no pin, so the launcher refuses it rather than crashing.
|
|
22
|
+
export const livePinFile =
|
|
23
|
+
(cwd: string, path: string): PinFile =>
|
|
24
|
+
() => {
|
|
25
|
+
const file = join(cwd, path)
|
|
26
|
+
if (existsSync(file)) {
|
|
27
|
+
try {
|
|
28
|
+
return { path: file, bytes: readFileSync(file) }
|
|
29
|
+
} catch {
|
|
30
|
+
return { path: file, bytes: new Uint8Array() }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const bytes = execFileSync("git", ["show", `HEAD:${path}`], {
|
|
35
|
+
cwd,
|
|
36
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
37
|
+
})
|
|
38
|
+
return { path: file, bytes }
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Runs a pin's module as this process's main program. Node refuses an unknown
|
|
46
|
+
* extension, so a load hook serves the unpacked source under the pin's own
|
|
47
|
+
* URL — `import.meta.url` stays a file in the repository.
|
|
48
|
+
*/
|
|
49
|
+
export const runPinModule = async (path: string, source: string): Promise<void> => {
|
|
50
|
+
const url = pathToFileURL(path).href
|
|
51
|
+
// A pin read from HEAD has no file behind its URL, which Node's own resolve refuses.
|
|
52
|
+
registerHooks({
|
|
53
|
+
resolve: (specifier, context, nextResolve) =>
|
|
54
|
+
specifier === url ? { url, shortCircuit: true } : nextResolve(specifier, context),
|
|
55
|
+
load: (requested, context, nextLoad) =>
|
|
56
|
+
requested === url
|
|
57
|
+
? { format: "module", source, shortCircuit: true }
|
|
58
|
+
: nextLoad(requested, context),
|
|
59
|
+
})
|
|
60
|
+
await import(url)
|
|
61
|
+
}
|
package/src/platform/index.ts
CHANGED
|
@@ -2,25 +2,31 @@ import { describe, expect, it } from "vitest"
|
|
|
2
2
|
import { formatCommitMessage, formatFault, parseCommitMessage } from "./index.js"
|
|
3
3
|
|
|
4
4
|
describe("the history format", () => {
|
|
5
|
-
it("accepts a history
|
|
6
|
-
expect(formatFault([{ hash: "a".repeat(40), format:
|
|
5
|
+
it("accepts a history in the current format and in the one before it", () => {
|
|
6
|
+
expect(formatFault([{ hash: "a".repeat(40), format: 2 }])).toBeUndefined()
|
|
7
|
+
expect(
|
|
8
|
+
formatFault([
|
|
9
|
+
{ hash: "a".repeat(40), format: 1 },
|
|
10
|
+
{ hash: "b".repeat(40), format: 2 },
|
|
11
|
+
]),
|
|
12
|
+
).toBeUndefined()
|
|
7
13
|
expect(formatFault([])).toBeUndefined()
|
|
8
14
|
})
|
|
9
15
|
|
|
10
16
|
it("names the first foreign commit, the found and readable formats, and what to do", () => {
|
|
11
17
|
const fault = formatFault([
|
|
12
18
|
{ hash: "a".repeat(40), format: 1 },
|
|
13
|
-
{ hash: "b".repeat(40), format:
|
|
14
|
-
{ hash: "c".repeat(40), format:
|
|
19
|
+
{ hash: "b".repeat(40), format: 3 },
|
|
20
|
+
{ hash: "c".repeat(40), format: 4 },
|
|
15
21
|
])
|
|
16
22
|
expect(fault).toBe(
|
|
17
|
-
"gtd: commit bbbbbbb is written in history format
|
|
23
|
+
"gtd: commit bbbbbbb is written in history format 3, but this gtd reads formats 1 to 2 — run a gtd release that reads format 3 to continue this process",
|
|
18
24
|
)
|
|
19
25
|
})
|
|
20
26
|
})
|
|
21
27
|
|
|
22
28
|
describe("Gtd-Approved and the history format", () => {
|
|
23
|
-
it("
|
|
29
|
+
it("writes the current format on a message carrying it", () => {
|
|
24
30
|
const message = formatCommitMessage({
|
|
25
31
|
actor: "human",
|
|
26
32
|
to: "b",
|
|
@@ -29,7 +35,28 @@ describe("Gtd-Approved and the history format", () => {
|
|
|
29
35
|
expect(message).toContain(
|
|
30
36
|
'Gtd-Approved: {"step":"seam#1","name":"Test","email":"test@test.com"}',
|
|
31
37
|
)
|
|
32
|
-
expect(message).toContain("Gtd-Format:
|
|
33
|
-
expect(parseCommitMessage(message).format).toBe(
|
|
38
|
+
expect(message).toContain("Gtd-Format: 2")
|
|
39
|
+
expect(parseCommitMessage(message).format).toBe(2)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe("Gtd-Pin", () => {
|
|
44
|
+
it("round-trips add and remove, after Gtd-Mark and before Gtd-Branch", () => {
|
|
45
|
+
const message = formatCommitMessage({
|
|
46
|
+
actor: "check",
|
|
47
|
+
from: "unpin",
|
|
48
|
+
to: "idle",
|
|
49
|
+
pin: "remove",
|
|
50
|
+
branch: "main",
|
|
51
|
+
})
|
|
52
|
+
expect(message).toBe(
|
|
53
|
+
"gtd(check): unpin → idle\n\nGtd-Pin: remove\nGtd-Branch: main\nGtd-Format: 2",
|
|
54
|
+
)
|
|
55
|
+
expect(parseCommitMessage(message).pin).toBe("remove")
|
|
56
|
+
expect(parseCommitMessage("gtd(check): pin → a\n\nGtd-Pin: add").pin).toBe("add")
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("reads an unknown value as no pin", () => {
|
|
60
|
+
expect(parseCommitMessage("gtd(check): pin → a\n\nGtd-Pin: frozen").pin).toBeUndefined()
|
|
34
61
|
})
|
|
35
62
|
})
|
package/src/replay/Format.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
// Bump only when what replay reads back out of git changes incompatibly — the
|
|
2
2
|
// package version must not stand in for it, since most majors leave history
|
|
3
|
-
// untouched.
|
|
4
|
-
export const HISTORY_FORMAT =
|
|
3
|
+
// untouched. Format 2 adds pin commits, which format 1 would replay as turns.
|
|
4
|
+
export const HISTORY_FORMAT = 2
|
|
5
|
+
const OLDEST_READABLE_FORMAT = 1
|
|
5
6
|
|
|
6
7
|
const short = (hash: string): string => hash.slice(0, 7)
|
|
7
8
|
|
|
8
9
|
export const formatFault = (
|
|
9
10
|
commits: readonly { readonly hash: string; readonly format: number }[],
|
|
10
11
|
): string | undefined => {
|
|
11
|
-
const foreign = commits.find(
|
|
12
|
+
const foreign = commits.find(
|
|
13
|
+
(c) => c.format < OLDEST_READABLE_FORMAT || c.format > HISTORY_FORMAT,
|
|
14
|
+
)
|
|
12
15
|
if (foreign === undefined) return undefined
|
|
13
|
-
return `gtd: commit ${short(foreign.hash)} is written in history format ${foreign.format}, but this gtd reads
|
|
16
|
+
return `gtd: commit ${short(foreign.hash)} is written in history format ${foreign.format}, but this gtd reads formats ${OLDEST_READABLE_FORMAT} to ${HISTORY_FORMAT} — run a gtd release that reads format ${foreign.format} to continue this process`
|
|
14
17
|
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
changes,
|
|
7
7
|
changesSince,
|
|
8
8
|
codeThreads,
|
|
9
|
+
head,
|
|
9
10
|
human,
|
|
10
11
|
judge,
|
|
11
12
|
kept,
|
|
@@ -1217,3 +1218,56 @@ describe("human({ approve })", () => {
|
|
|
1217
1218
|
expect(outcome.kind).toBe("rest")
|
|
1218
1219
|
})
|
|
1219
1220
|
})
|
|
1221
|
+
|
|
1222
|
+
describe("pin commits", () => {
|
|
1223
|
+
const PIN = ".gtd/process.gtdpin"
|
|
1224
|
+
const pinned = () =>
|
|
1225
|
+
new History()
|
|
1226
|
+
.land("human", "idle", 1, "build", { "NOTE.md": "n" })
|
|
1227
|
+
.raw("gtd(check): pin → build\n\nGtd-Pin: add", { [PIN]: "gtdpin 1 gtd 1\n" })
|
|
1228
|
+
|
|
1229
|
+
it("are never a turn: the next step answers from the commit after the pin", async () => {
|
|
1230
|
+
const flow = async () => {
|
|
1231
|
+
await human("idle")
|
|
1232
|
+
await agent("build", "b")
|
|
1233
|
+
await agent("check", "c")
|
|
1234
|
+
}
|
|
1235
|
+
const h = pinned().land("agent", "build", 1, "check", { "a.ts": "a" })
|
|
1236
|
+
expect(restName(await replayOf(flow, h))).toBe("check#1")
|
|
1237
|
+
})
|
|
1238
|
+
|
|
1239
|
+
it("hide the pin from every tree flow code reads, so a turn's changes leave it out", async () => {
|
|
1240
|
+
let seen: { readonly changed: readonly string[]; readonly pin: string | undefined } | undefined
|
|
1241
|
+
const flow = async () => {
|
|
1242
|
+
await human("idle")
|
|
1243
|
+
await agent("build", "b")
|
|
1244
|
+
seen = { changed: changes().map((c) => c.path), pin: read(PIN) }
|
|
1245
|
+
await agent("check", "c")
|
|
1246
|
+
}
|
|
1247
|
+
const h = pinned().land("agent", "build", 1, "check", { "a.ts": "a" })
|
|
1248
|
+
await replayOf(flow, h)
|
|
1249
|
+
expect(seen).toEqual({ changed: ["a.ts"], pin: undefined })
|
|
1250
|
+
})
|
|
1251
|
+
|
|
1252
|
+
it("are never what head() names: a flow reverting the start reverts the turn", async () => {
|
|
1253
|
+
let named: string | undefined
|
|
1254
|
+
const flow = async () => {
|
|
1255
|
+
await human("idle")
|
|
1256
|
+
named = head()
|
|
1257
|
+
await agent("build", "b")
|
|
1258
|
+
}
|
|
1259
|
+
await replayOf(flow, pinned())
|
|
1260
|
+
expect(named).toBe(hashOf(1))
|
|
1261
|
+
})
|
|
1262
|
+
|
|
1263
|
+
it("trailing an ended flow are no divergence", async () => {
|
|
1264
|
+
const flow = async () => {
|
|
1265
|
+
await human("idle")
|
|
1266
|
+
await agent("build", "b")
|
|
1267
|
+
}
|
|
1268
|
+
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
|
+
expect((await replayOf(flow, h)).kind).toBe("ended")
|
|
1272
|
+
})
|
|
1273
|
+
})
|
package/src/replay/Replay.ts
CHANGED
|
@@ -20,17 +20,20 @@ import {
|
|
|
20
20
|
threads,
|
|
21
21
|
} from "../steering/index.js"
|
|
22
22
|
import { accessShapeFault, foldAccess, sameAccess } from "./Access.js"
|
|
23
|
-
import { globMatches } from "../wire/index.js"
|
|
23
|
+
import { globMatches, PIN_PATH } from "../wire/index.js"
|
|
24
24
|
import { refNameFault } from "./RefName.js"
|
|
25
|
-
import { diffTrees, isEmptyDiff, type TreeView } from "./Tree.js"
|
|
25
|
+
import { diffTrees, isEmptyDiff, withoutPath, type TreeView } from "./Tree.js"
|
|
26
26
|
import {
|
|
27
27
|
formatStepId,
|
|
28
28
|
parseCommitMessage,
|
|
29
29
|
type CommitMessage,
|
|
30
30
|
type JudgeVerdict,
|
|
31
31
|
type StepId,
|
|
32
|
+
PIN_STATES,
|
|
32
33
|
} from "./Trailers.js"
|
|
33
34
|
|
|
35
|
+
const UNPIN_STATE = PIN_STATES.remove
|
|
36
|
+
|
|
34
37
|
export interface EpisodeCommit {
|
|
35
38
|
readonly hash: string
|
|
36
39
|
readonly message: string
|
|
@@ -297,7 +300,29 @@ const modeWithoutFile = (step: ReachedStep): string | undefined => {
|
|
|
297
300
|
return `gtd: step "${step.name}": mode requires file in ${step.kind}() options — name the steering file the mode checks`
|
|
298
301
|
}
|
|
299
302
|
|
|
300
|
-
|
|
303
|
+
// The pin is the engine's, not the process's: flow code never sees it, so a
|
|
304
|
+
// pin commit is an empty diff replay can step over.
|
|
305
|
+
const withoutPin = (input: ReplayInput): ReplayInput => {
|
|
306
|
+
const hide = (tree: TreeView): TreeView => withoutPath(tree, PIN_PATH)
|
|
307
|
+
return {
|
|
308
|
+
...input,
|
|
309
|
+
episode: {
|
|
310
|
+
base: { ...input.episode.base, tree: hide(input.episode.base.tree) },
|
|
311
|
+
commits: input.episode.commits.map((c) => ({
|
|
312
|
+
...c,
|
|
313
|
+
tree: hide(c.tree),
|
|
314
|
+
...(c.kept !== undefined ? { kept: { ...c.kept, tree: hide(c.kept.tree) } } : {}),
|
|
315
|
+
})),
|
|
316
|
+
},
|
|
317
|
+
...(input.startTree !== undefined ? { startTree: hide(input.startTree) } : {}),
|
|
318
|
+
...(input.pending !== undefined
|
|
319
|
+
? { pending: { ...input.pending, tree: hide(input.pending.tree) } }
|
|
320
|
+
: {}),
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
|
|
325
|
+
const input = withoutPin(raw)
|
|
301
326
|
const commits: readonly ParsedCommit[] = input.episode.commits.map((c) => ({
|
|
302
327
|
...c,
|
|
303
328
|
parsed: parseCommitMessage(c.message),
|
|
@@ -317,6 +342,11 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
317
342
|
let cursor = 0
|
|
318
343
|
let pendingUsed = false
|
|
319
344
|
let position: Position = input.episode.base
|
|
345
|
+
// Never stands on a pin commit: `head()` names the turn, so a flow
|
|
346
|
+
// reverting it reverts the turn, not the pin.
|
|
347
|
+
const skipPins = (): void => {
|
|
348
|
+
while (cursor < commits.length && commits[cursor]!.parsed.pin !== undefined) cursor++
|
|
349
|
+
}
|
|
320
350
|
let previousPosition: Position = input.episode.base
|
|
321
351
|
let expectedNext: { readonly name: string; readonly hash: string } | undefined
|
|
322
352
|
const occurrences = new Map<string, number>()
|
|
@@ -560,20 +590,25 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
560
590
|
if (unapproved !== undefined) return finish(unapproved)
|
|
561
591
|
cursor++
|
|
562
592
|
advance(commit)
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
else
|
|
566
|
-
keptTips.set(step.name, {
|
|
567
|
-
hash: commit.kept.hash,
|
|
568
|
-
before: commit.tree,
|
|
569
|
-
after: commit.kept.tree,
|
|
570
|
-
})
|
|
571
|
-
}
|
|
593
|
+
skipPins()
|
|
594
|
+
noteKept(step, commit)
|
|
572
595
|
const to = commit.parsed.parsed?.to
|
|
573
|
-
|
|
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 }
|
|
574
598
|
return replyFor(step.request, commit.parsed.judge)
|
|
575
599
|
}
|
|
576
600
|
|
|
601
|
+
const noteKept = (step: ReachedStep, commit: ParsedCommit): void => {
|
|
602
|
+
if (commit.parsed.kept === undefined) return
|
|
603
|
+
if (commit.kept === undefined) keptTips.delete(step.name)
|
|
604
|
+
else
|
|
605
|
+
keptTips.set(step.name, {
|
|
606
|
+
hash: commit.kept.hash,
|
|
607
|
+
before: commit.tree,
|
|
608
|
+
after: commit.kept.tree,
|
|
609
|
+
})
|
|
610
|
+
}
|
|
611
|
+
|
|
577
612
|
const landPending = (step: ReachedStep, pending: NonNullable<typeof input.pending>): unknown => {
|
|
578
613
|
pendingUsed = true
|
|
579
614
|
landed = step
|
|
@@ -587,7 +622,10 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
587
622
|
}
|
|
588
623
|
|
|
589
624
|
const answer = (step: ReachedStep): unknown => {
|
|
590
|
-
while (cursor < commits.length && isAttemptAt(commits[cursor]!, step.name))
|
|
625
|
+
while (cursor < commits.length && isAttemptAt(commits[cursor]!, step.name)) {
|
|
626
|
+
cursor++
|
|
627
|
+
skipPins()
|
|
628
|
+
}
|
|
591
629
|
const commit = commits[cursor]
|
|
592
630
|
if (commit !== undefined) return consumeCommit(step, commit)
|
|
593
631
|
if (input.pending === undefined || pendingUsed) {
|
|
@@ -604,6 +642,9 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
604
642
|
|
|
605
643
|
// gtd.config.ts is loaded without a type check.
|
|
606
644
|
const requestFault = (request: Exclude<StepRequest, { kind: "restart" }>): string | undefined => {
|
|
645
|
+
if (Object.values(PIN_STATES).includes(scoped(request.name))) {
|
|
646
|
+
return `gtd: step "${scoped(request.name)}": the name is reserved for the pin and unpin beats — rename the step`
|
|
647
|
+
}
|
|
607
648
|
if (request.kind === "run" && typeof request.body !== "string") {
|
|
608
649
|
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)`
|
|
609
650
|
}
|
|
@@ -636,13 +677,15 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
636
677
|
return blocker === undefined ? answer(step) : finish(blocker)
|
|
637
678
|
}
|
|
638
679
|
|
|
639
|
-
const endOfFlow = (via: "return" | "restart"): ReplayOutcome =>
|
|
640
|
-
|
|
680
|
+
const endOfFlow = (via: "return" | "restart"): ReplayOutcome => {
|
|
681
|
+
skipPins()
|
|
682
|
+
return cursor < commits.length
|
|
641
683
|
? {
|
|
642
684
|
kind: "divergence",
|
|
643
685
|
message: `gtd: the flow ended, but commit ${short(commits[cursor]!.hash)} continues the episode — ${DIVERGED}`,
|
|
644
686
|
}
|
|
645
687
|
: { kind: "ended", via, trace, landed, marks }
|
|
688
|
+
}
|
|
646
689
|
|
|
647
690
|
const changesSince = (hash: string): readonly Change[] => {
|
|
648
691
|
const tree = treeAt(hash)
|
|
@@ -714,13 +757,14 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
714
757
|
// At the rest, trailing attempts sit above the last step commit: the head a
|
|
715
758
|
// prompt names is the commit the process actually stands on.
|
|
716
759
|
head: () => {
|
|
717
|
-
const rest = commits.slice(cursor)
|
|
760
|
+
const rest = commits.slice(cursor).filter((c) => c.parsed.pin === undefined)
|
|
718
761
|
return rest.length > 0 && rest.every((c) => c.parsed.step === undefined)
|
|
719
762
|
? rest[rest.length - 1]!.hash
|
|
720
763
|
: position.hash
|
|
721
764
|
},
|
|
722
765
|
}
|
|
723
766
|
|
|
767
|
+
skipPins()
|
|
724
768
|
installContext(context)
|
|
725
769
|
try {
|
|
726
770
|
// A pre-upgrade flow destructuring `{ entry }` reads `undefined` (an
|
|
@@ -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: 2",
|
|
50
50
|
)
|
|
51
51
|
expect(parseCommitMessage(message).workflow).toBe("fix")
|
|
52
52
|
expect(parseCommitMessage("gtd(human): fix-precheck").workflow).toBeUndefined()
|
|
@@ -56,47 +56,10 @@ 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: 2")
|
|
60
60
|
expect(parseCommitMessage(one).vars).toEqual({ a: "1", b: "2", c: "" })
|
|
61
61
|
})
|
|
62
62
|
|
|
63
|
-
it("writes Gtd-Config trailers sorted by path, after Gtd-Var, and reads paths with spaces back", () => {
|
|
64
|
-
const message = formatCommitMessage({
|
|
65
|
-
actor: "human",
|
|
66
|
-
to: "x",
|
|
67
|
-
vars: { a: "1" },
|
|
68
|
-
config: { "lib/my flow.ts": "0123456789abcdef", "gtd.config.ts": "deadbeefdeadbeef" },
|
|
69
|
-
})
|
|
70
|
-
expect(message).toBe(
|
|
71
|
-
"gtd(human): x\n\nGtd-Var: a=1\nGtd-Config: deadbeefdeadbeef gtd.config.ts\nGtd-Config: 0123456789abcdef lib/my flow.ts\nGtd-Format: 1",
|
|
72
|
-
)
|
|
73
|
-
expect(parseCommitMessage(message).config).toEqual({
|
|
74
|
-
"gtd.config.ts": "deadbeefdeadbeef",
|
|
75
|
-
"lib/my flow.ts": "0123456789abcdef",
|
|
76
|
-
})
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
it("round-trips a Gtd-Config path that git or the trailer syntax would alter", () => {
|
|
80
|
-
const config = {
|
|
81
|
-
" lead.ts": "0123456789abcdef",
|
|
82
|
-
"trail.ts ": "0123456789abcdef",
|
|
83
|
-
'"q.ts': "0123456789abcdef",
|
|
84
|
-
}
|
|
85
|
-
const message = formatCommitMessage({ actor: "human", to: "x", config })
|
|
86
|
-
expect(message).toContain('Gtd-Config: 0123456789abcdef " lead.ts"')
|
|
87
|
-
expect(parseCommitMessage(message).config).toEqual(config)
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
it("ignores a Gtd-Config trailer without a 16-digit hex hash, without a path, or with a control character in its path", () => {
|
|
91
|
-
const read = (value: string) =>
|
|
92
|
-
parseCommitMessage(`gtd(human): x\n\nGtd-Config: ${value}`).config
|
|
93
|
-
expect(read("zzzzzzzzzzzzzzzz gtd.config.ts")).toEqual({})
|
|
94
|
-
expect(read("deadbeef gtd.config.ts")).toEqual({})
|
|
95
|
-
expect(read("deadbeefdeadbeef")).toEqual({})
|
|
96
|
-
expect(read("deadbeefdeadbeef a\x1b]0;x\x07.ts")).toEqual({})
|
|
97
|
-
expect(read('deadbeefdeadbeef "a\\u001b.ts"')).toEqual({})
|
|
98
|
-
})
|
|
99
|
-
|
|
100
63
|
it("round-trips Gtd-Branch and Gtd-Version, after Gtd-Judge and before Gtd-Format", () => {
|
|
101
64
|
const message = formatCommitMessage({
|
|
102
65
|
actor: "agent",
|
|
@@ -118,8 +81,8 @@ describe("the commit-message codec", () => {
|
|
|
118
81
|
it("reads a Gtd-Version carrying control bytes as unknown, and keeps spaces and parens", () => {
|
|
119
82
|
const withVersion = (value: string) =>
|
|
120
83
|
formatCommitMessage({ actor: "a", to: "b" }).replace(
|
|
121
|
-
"Gtd-Format:
|
|
122
|
-
`Gtd-Version: ${value}\nGtd-Format:
|
|
84
|
+
"Gtd-Format: 2",
|
|
85
|
+
`Gtd-Version: ${value}\nGtd-Format: 2`,
|
|
123
86
|
)
|
|
124
87
|
expect(parseCommitMessage(withVersion("1.0\u001b]0;x\u0007")).version).toBeUndefined()
|
|
125
88
|
expect(parseCommitMessage(withVersion("1.0\u009b")).version).toBeUndefined()
|
|
@@ -135,15 +98,15 @@ describe("the commit-message codec", () => {
|
|
|
135
98
|
expect(parsed.version).toBeUndefined()
|
|
136
99
|
})
|
|
137
100
|
|
|
138
|
-
it("
|
|
101
|
+
it("writes the current Gtd-Format when branch and version are written", () => {
|
|
139
102
|
const message = formatCommitMessage({ actor: "a", to: "b", branch: "m", version: "1" })
|
|
140
|
-
expect(message.endsWith("Gtd-Format:
|
|
103
|
+
expect(message.endsWith("Gtd-Format: 2")).toBe(true)
|
|
141
104
|
})
|
|
142
105
|
|
|
143
106
|
it("writes Gtd-Format last on every message, even one with no other trailer", () => {
|
|
144
107
|
const message = formatCommitMessage({ actor: "agent", to: "building" })
|
|
145
|
-
expect(message).toBe("gtd(agent): building\n\nGtd-Format:
|
|
146
|
-
expect(parseCommitMessage(message).format).toBe(
|
|
108
|
+
expect(message).toBe("gtd(agent): building\n\nGtd-Format: 2")
|
|
109
|
+
expect(parseCommitMessage(message).format).toBe(2)
|
|
147
110
|
})
|
|
148
111
|
|
|
149
112
|
it("reads a message without Gtd-Format as format 1, and an explicit one as written", () => {
|
|
@@ -183,10 +146,10 @@ describe("Gtd-Kept", () => {
|
|
|
183
146
|
expect(parseCommitMessage(`gtd(agent): a → b\n\nGtd-Kept: ${hash}0`).kept).toBeUndefined()
|
|
184
147
|
})
|
|
185
148
|
|
|
186
|
-
it("reads undefined when absent, and leaves Gtd-Format at
|
|
149
|
+
it("reads undefined when absent, and leaves Gtd-Format at the current format", () => {
|
|
187
150
|
const parsed = parseCommitMessage(formatCommitMessage({ actor: "agent", to: "b" }))
|
|
188
151
|
expect(parsed.kept).toBeUndefined()
|
|
189
|
-
expect(parsed.format).toBe(
|
|
152
|
+
expect(parsed.format).toBe(2)
|
|
190
153
|
})
|
|
191
154
|
})
|
|
192
155
|
|
|
@@ -326,7 +289,7 @@ describe("Gtd-Mark", () => {
|
|
|
326
289
|
const at = lines.findIndex((l) => l.startsWith("Gtd-Mark:"))
|
|
327
290
|
expect(lines[at - 1]).toMatch(/^Gtd-Judge:/)
|
|
328
291
|
expect(lines[at + 1]).toMatch(/^Gtd-Approved:/)
|
|
329
|
-
expect(lines).toContain("Gtd-Format:
|
|
292
|
+
expect(lines).toContain("Gtd-Format: 2")
|
|
330
293
|
})
|
|
331
294
|
|
|
332
295
|
it("writes nothing without marks", () => {
|
package/src/replay/Trailers.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// command unusable.
|
|
5
5
|
|
|
6
6
|
import type { Mark } from "../flows/index.js"
|
|
7
|
+
import type { PinMark } from "../wire/index.js"
|
|
7
8
|
import { HISTORY_FORMAT } from "./Format.js"
|
|
8
9
|
|
|
9
10
|
const TRANSITION_SEP = " → "
|
|
@@ -67,6 +68,11 @@ export interface Approval {
|
|
|
67
68
|
readonly email: string
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
export type PinChange = PinMark
|
|
72
|
+
|
|
73
|
+
/** 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>> = { add: "pin", remove: "unpin" }
|
|
75
|
+
|
|
70
76
|
export interface CommitMessage {
|
|
71
77
|
readonly subject: string
|
|
72
78
|
readonly parsed: ParsedSubject | undefined
|
|
@@ -77,8 +83,6 @@ export interface CommitMessage {
|
|
|
77
83
|
/** `Gtd-Mark`, in order; a malformed or control-character line is skipped. */
|
|
78
84
|
readonly marks: readonly Mark[]
|
|
79
85
|
readonly vars: Readonly<Record<string, string>>
|
|
80
|
-
/** Written only on a process's first commit. */
|
|
81
|
-
readonly config: Readonly<Record<string, string>>
|
|
82
86
|
readonly reviewBase: string | undefined
|
|
83
87
|
readonly cost: readonly CostEntry[]
|
|
84
88
|
/** Written only on a process's finishing landing. */
|
|
@@ -88,6 +92,8 @@ export interface CommitMessage {
|
|
|
88
92
|
readonly kept: string | undefined
|
|
89
93
|
/** `Gtd-Approved`; a malformed or control-character value is skipped. */
|
|
90
94
|
readonly approved: Approval | undefined
|
|
95
|
+
/** `Gtd-Pin`: a commit that adds or removes the pin — never a turn. */
|
|
96
|
+
readonly pin: PinChange | undefined
|
|
91
97
|
readonly format: number
|
|
92
98
|
}
|
|
93
99
|
|
|
@@ -112,20 +118,10 @@ const parseJson = (raw: string): unknown => {
|
|
|
112
118
|
const TRAILER_RE = /^(Gtd-[A-Za-z-]+):[ \t]*(.*?)[ \t]*$/gm
|
|
113
119
|
const COST_RE = /^([0-9]+(?:\.[0-9]+)?)(?:[ \t]+(.+))?$/
|
|
114
120
|
const VAR_RE = /^([^=\s]+)=(.*)$/
|
|
115
|
-
|
|
116
|
-
//
|
|
117
|
-
// JSON string. A control character is refused on read: history written by
|
|
118
|
-
// someone else must never put escape sequences on a terminal.
|
|
121
|
+
// A control character is refused on read: history written by someone else
|
|
122
|
+
// must never put escape sequences on a terminal.
|
|
119
123
|
// eslint-disable-next-line no-control-regex -- the control range IS the point
|
|
120
124
|
const CONTROL_CHARACTER = /[\x00-\x1f\x7f-\x9f\u2028\u2029]/
|
|
121
|
-
const encodePath = (path: string): string =>
|
|
122
|
-
path.trim() !== path || path.startsWith('"') || CONTROL_CHARACTER.test(path)
|
|
123
|
-
? JSON.stringify(path)
|
|
124
|
-
: path
|
|
125
|
-
const decodePath = (raw: string): string | undefined => {
|
|
126
|
-
const path = raw.startsWith('"') ? parseJson(raw) : raw
|
|
127
|
-
return typeof path === "string" && path !== "" && !CONTROL_CHARACTER.test(path) ? path : undefined
|
|
128
|
-
}
|
|
129
125
|
const decodeApproval = (raw: string): Approval | undefined => {
|
|
130
126
|
const parsed = parseJson(raw)
|
|
131
127
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined
|
|
@@ -156,10 +152,10 @@ interface Trailers {
|
|
|
156
152
|
version: string | undefined
|
|
157
153
|
kept: string | undefined
|
|
158
154
|
approved: Approval | undefined
|
|
155
|
+
pin: PinChange | undefined
|
|
159
156
|
readonly judge: JudgeVerdict[]
|
|
160
157
|
readonly marks: Mark[]
|
|
161
158
|
readonly vars: Record<string, string>
|
|
162
|
-
readonly config: Record<string, string>
|
|
163
159
|
readonly cost: CostEntry[]
|
|
164
160
|
format: number | undefined
|
|
165
161
|
}
|
|
@@ -184,11 +180,6 @@ const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) =
|
|
|
184
180
|
const kv = VAR_RE.exec(value)
|
|
185
181
|
if (kv !== null) into.vars[kv[1]!] = kv[2]!
|
|
186
182
|
},
|
|
187
|
-
"Gtd-Config": (value, into) => {
|
|
188
|
-
const entry = CONFIG_RE.exec(value)
|
|
189
|
-
const path = entry === null ? undefined : decodePath(entry[2]!)
|
|
190
|
-
if (path !== undefined) into.config[path] = entry![1]!
|
|
191
|
-
},
|
|
192
183
|
"Gtd-Review-Base": (value, into) => {
|
|
193
184
|
into.reviewBase ??= value.split(/\s/)[0]
|
|
194
185
|
},
|
|
@@ -206,6 +197,9 @@ const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) =
|
|
|
206
197
|
"Gtd-Approved": (value, into) => {
|
|
207
198
|
into.approved ??= decodeApproval(value)
|
|
208
199
|
},
|
|
200
|
+
"Gtd-Pin": (value, into) => {
|
|
201
|
+
if (value === "add" || value === "remove") into.pin ??= value
|
|
202
|
+
},
|
|
209
203
|
"Gtd-Format": (value, into) => {
|
|
210
204
|
if (FORMAT_RE.test(value)) into.format ??= Number(value)
|
|
211
205
|
},
|
|
@@ -226,10 +220,10 @@ export const parseCommitMessage = (message: string): CommitMessage => {
|
|
|
226
220
|
version: undefined,
|
|
227
221
|
kept: undefined,
|
|
228
222
|
approved: undefined,
|
|
223
|
+
pin: undefined,
|
|
229
224
|
judge: [],
|
|
230
225
|
marks: [],
|
|
231
226
|
vars: {},
|
|
232
|
-
config: {},
|
|
233
227
|
cost: [],
|
|
234
228
|
format: undefined,
|
|
235
229
|
}
|
|
@@ -249,13 +243,13 @@ export interface CommitSpec {
|
|
|
249
243
|
readonly workflow?: string
|
|
250
244
|
readonly reviewBase?: string
|
|
251
245
|
readonly vars?: Readonly<Record<string, string>>
|
|
252
|
-
readonly config?: Readonly<Record<string, string>>
|
|
253
246
|
readonly cost?: { readonly cost: number; readonly model?: string }
|
|
254
247
|
readonly judge?: readonly JudgeVerdict[]
|
|
255
248
|
/** Written after `Gtd-Judge`, before `Gtd-Approved`. */
|
|
256
249
|
readonly marks?: readonly Mark[]
|
|
257
250
|
/** Written after `Gtd-Mark`, before `Gtd-Branch`. */
|
|
258
251
|
readonly approved?: Approval
|
|
252
|
+
readonly pin?: PinChange
|
|
259
253
|
readonly branch?: string
|
|
260
254
|
readonly version?: string
|
|
261
255
|
}
|
|
@@ -264,27 +258,27 @@ const sortedEntries = (record: Readonly<Record<string, string>> = {}): [string,
|
|
|
264
258
|
Object.entries(record).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
265
259
|
|
|
266
260
|
/** Trailer order is fixed so the same spec always yields the same bytes. */
|
|
261
|
+
const trailer = (name: string, value: string | undefined): readonly string[] =>
|
|
262
|
+
value === undefined ? [] : [`Gtd-${name}: ${value}`]
|
|
263
|
+
|
|
264
|
+
const costValue = (cost: CommitSpec["cost"]): string | undefined =>
|
|
265
|
+
cost === undefined ? undefined : `${cost.cost}${cost.model !== undefined ? ` ${cost.model}` : ""}`
|
|
266
|
+
|
|
267
267
|
export const formatCommitMessage = (spec: CommitSpec): string => {
|
|
268
|
-
const lines
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
)
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
if (spec.approved !== undefined) {
|
|
283
|
-
lines.push(`Gtd-Approved: ${encodeApproval(spec.approved)}`)
|
|
284
|
-
}
|
|
285
|
-
if (spec.branch !== undefined) lines.push(`Gtd-Branch: ${spec.branch}`)
|
|
286
|
-
if (spec.version !== undefined) lines.push(`Gtd-Version: ${spec.version}`)
|
|
287
|
-
lines.push(`Gtd-Format: ${HISTORY_FORMAT}`)
|
|
268
|
+
const lines = [
|
|
269
|
+
...trailer("Step", spec.step && formatStepId(spec.step)),
|
|
270
|
+
...trailer("Workflow", spec.workflow),
|
|
271
|
+
...trailer("Review-Base", spec.reviewBase),
|
|
272
|
+
...sortedEntries(spec.vars).map(([name, value]) => `Gtd-Var: ${name}=${value}`),
|
|
273
|
+
...trailer("Cost", costValue(spec.cost)),
|
|
274
|
+
...(spec.judge ?? []).map((verdict) => `Gtd-Judge: ${JSON.stringify(verdict)}`),
|
|
275
|
+
...(spec.marks ?? []).map((m) => `Gtd-Mark: ${m.name}=${m.value}`),
|
|
276
|
+
...trailer("Approved", spec.approved && encodeApproval(spec.approved)),
|
|
277
|
+
...trailer("Pin", spec.pin),
|
|
278
|
+
...trailer("Branch", spec.branch),
|
|
279
|
+
...trailer("Version", spec.version),
|
|
280
|
+
`Gtd-Format: ${HISTORY_FORMAT}`,
|
|
281
|
+
]
|
|
288
282
|
const subject = formatSubject(spec.actor, spec.to, spec.from)
|
|
289
283
|
return `${subject}\n\n${lines.join("\n")}`
|
|
290
284
|
}
|