@pmelab/gtd 27.8.0 → 28.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 +59 -30
- package/claude/hooks/doors.ts +12 -0
- package/claude/hooks/register.tsx +2 -4
- package/claude/hooks/ship.ts +115 -20
- package/dist/chunks/{reserved-UdSuHDUJ.mjs → reserved-BIsngMGL.mjs} +168 -27
- package/dist/chunks/{tail-fj60rQ3y.mjs → tail-DHEaOHyp.mjs} +1 -1
- package/dist/chunks/{wire-C12dahrH.mjs → wire-D3Rc5r22.mjs} +14 -1
- package/dist/gtd.bundle.mjs +174 -48
- package/dist/testkit.mjs +158 -49
- package/dist/wire.mjs +2 -2
- package/dist/workflows/cleanup.mjs +3 -4
- package/dist/workflows/feature.mjs +2 -2
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +2 -2
- package/dist/workflows/rollout.mjs +19 -4
- package/dist/workflows.mjs +186 -127
- package/package.json +1 -1
- package/skills/authoring/SKILL.md +4 -3
- package/src/cli/Cli.test.ts +39 -3
- package/src/cli/Cli.ts +27 -6
- package/src/cli/Program.test.ts +3 -1
- package/src/cli/Program.ts +22 -0
- package/src/edge/Edge.test.ts +209 -54
- package/src/edge/Edge.ts +121 -42
- package/src/edge/index.ts +1 -0
- package/src/install/ConfigScaffold.ts +4 -1
- package/src/install/Install.test.ts +10 -0
- package/src/install/Install.ts +5 -2
- package/src/replay/Replay.test.ts +32 -0
- package/src/replay/Replay.ts +3 -1
- package/src/replay/Trailers.test.ts +11 -0
- package/src/replay/Trailers.ts +3 -1
- package/src/replay/index.ts +1 -1
- package/src/wire/Slices.ts +25 -0
- package/src/wire/WireSchema.test.ts +18 -0
- package/src/wire/golden/1/slices.json +11 -0
- package/src/wire/index.ts +1 -0
- package/src/workflow/load.test.ts +82 -5
- package/src/workflow/load.ts +40 -16
- package/src/workflows/cleanup/cleanup.feature +21 -0
- package/src/workflows/cleanup/cleanup.ts +1 -1
- package/src/workflows/index.ts +2 -1
- package/src/workflows/rollout/rollout.feature +282 -2
- package/src/workflows/rollout/rollout.test.ts +96 -2
- package/src/workflows/rollout/rollout.ts +41 -3
- package/src/workflows/shared/options.test.ts +25 -2
- package/src/workflows/shared/options.ts +7 -4
- package/src/workflows/shared/registry.test.ts +41 -1
- package/src/workflows/shared/registry.ts +26 -1
- package/src/workflows/shared/text.test.ts +252 -2
- package/src/workflows/shared/text.ts +44 -1
- package/src/workflows/shared/vars.ts +2 -0
- package/dist/chunks/registry-CP3YuV9j.mjs +0 -101
package/src/edge/Edge.test.ts
CHANGED
|
@@ -3,10 +3,10 @@ import { describe, expect, it } from "vitest"
|
|
|
3
3
|
import { runCli } from "../cli/index.js"
|
|
4
4
|
import {
|
|
5
5
|
currentRest,
|
|
6
|
-
closedRunAt,
|
|
7
6
|
currentRun,
|
|
8
7
|
historyTrail,
|
|
9
8
|
noProcessUnderway,
|
|
9
|
+
processSlices,
|
|
10
10
|
renderRest,
|
|
11
11
|
restAt,
|
|
12
12
|
restIsIdle,
|
|
@@ -596,31 +596,168 @@ export const summary = (it) => it.marks.map((m) => m.name + "=" + m.value).join(
|
|
|
596
596
|
})
|
|
597
597
|
})
|
|
598
598
|
|
|
599
|
-
|
|
600
|
-
const
|
|
599
|
+
const withTipMessage = (repo: InMemRepo, edit: (message: string) => string): GitOperations => {
|
|
600
|
+
const real = fakeGitOperations(repo)
|
|
601
|
+
const head = repo.resolveRef("HEAD")
|
|
602
|
+
return {
|
|
603
|
+
...real,
|
|
604
|
+
commitHistory: (base, tip) =>
|
|
605
|
+
Effect.map(real.commitHistory(base, tip), (commits) =>
|
|
606
|
+
commits.map((c) => (c.hash === head ? { ...c, message: edit(c.message) } : c)),
|
|
607
|
+
),
|
|
608
|
+
}
|
|
609
|
+
}
|
|
601
610
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
611
|
+
describe("processSlices", () => {
|
|
612
|
+
const failure = async (repo: InMemRepo): Promise<string> => {
|
|
613
|
+
const exit = await provideExit(processSlices, repo)
|
|
614
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
615
|
+
return Exit.isFailure(exit) ? String(exit.cause) : ""
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
it("never reports an open slice for a closed run whose replay no longer ends", async () => {
|
|
619
|
+
const repo = repoWith(LINEAR)
|
|
620
|
+
await land(repo, { "a.txt": "a\n" })
|
|
621
|
+
await land(repo, { "b.txt": "b\n" })
|
|
622
|
+
await land(repo, { "c.txt": "c\n" })
|
|
623
|
+
repo.writeFile(
|
|
624
|
+
"gtd.config.ts",
|
|
625
|
+
LINEAR.replace(
|
|
626
|
+
'await agent("checking", "check-prompt")',
|
|
627
|
+
'await agent("checking", "check-prompt")\n await human("idle", { message: "again" })\n await agent("polishing", "polish-prompt")',
|
|
628
|
+
),
|
|
629
|
+
)
|
|
630
|
+
// Stands for a flow change with no in-repo config edit (an upgraded
|
|
631
|
+
// out-of-repo import): every ref reads as the working tree, so the
|
|
632
|
+
// config-differs refusal stays quiet and only replay drift remains.
|
|
633
|
+
const git: GitOperations = {
|
|
634
|
+
...fakeGitOperations(repo),
|
|
635
|
+
readFileAtRef: (_ref, path) => Effect.succeed(repo.readFile(path) ?? ""),
|
|
636
|
+
}
|
|
637
|
+
const exit = await Effect.runPromiseExit(
|
|
638
|
+
processSlices.pipe(
|
|
639
|
+
Effect.provideService(GitService, git),
|
|
640
|
+
Effect.provide(testLayers(repo, {})),
|
|
641
|
+
),
|
|
642
|
+
)
|
|
643
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
644
|
+
const message = Exit.isFailure(exit) ? String(exit.cause) : ""
|
|
645
|
+
expect(message).toContain(
|
|
646
|
+
`gtd slices: refused — ${repo.resolveRef("HEAD")!.slice(0, 7)} closes a process that does not end under today's config`,
|
|
647
|
+
)
|
|
606
648
|
})
|
|
607
|
-
|
|
608
|
-
|
|
649
|
+
|
|
650
|
+
it("never reports a closed slice for an underway run whose replay now ends", async () => {
|
|
651
|
+
const repo = repoWith(LINEAR)
|
|
652
|
+
await land(repo, { "a.txt": "a\n" })
|
|
653
|
+
await land(repo, { "b.txt": "b\n" })
|
|
654
|
+
repo.writeFile(
|
|
655
|
+
"gtd.config.ts",
|
|
656
|
+
LINEAR.replace(' await agent("checking", "check-prompt")\n', ""),
|
|
657
|
+
)
|
|
658
|
+
const message = await failure(repo)
|
|
659
|
+
expect(message).toContain("gtd slices: refused")
|
|
660
|
+
expect(message).toContain("the workflow changed under this process")
|
|
661
|
+
expect(message).toContain("gtd abandon")
|
|
662
|
+
expect(message).not.toContain("gtd slices: gtd:")
|
|
609
663
|
})
|
|
610
|
-
}
|
|
611
|
-
`
|
|
612
664
|
|
|
613
|
-
it("
|
|
614
|
-
const repo = repoWith(
|
|
615
|
-
|
|
665
|
+
it("refuses with no process at HEAD, naming that HEAD neither closes nor sits inside one", async () => {
|
|
666
|
+
const repo = repoWith(LINEAR)
|
|
667
|
+
expect(await failure(repo)).toContain(
|
|
668
|
+
"gtd slices: refused — HEAD neither closes nor sits inside a process",
|
|
669
|
+
)
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
it("refuses a renamed step with replay's message, prefixed and without a gtd: lead", async () => {
|
|
673
|
+
const repo = repoWith(LINEAR)
|
|
616
674
|
await land(repo, { "a.txt": "a\n" })
|
|
617
|
-
|
|
618
|
-
const
|
|
619
|
-
expect(
|
|
620
|
-
expect(
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
675
|
+
repo.writeFile("gtd.config.ts", LINEAR.replace('agent("building"', 'agent("constructing"'))
|
|
676
|
+
const message = await failure(repo)
|
|
677
|
+
expect(message).toContain("gtd slices: refused — ")
|
|
678
|
+
expect(message).not.toContain("HEAD neither closes nor sits inside a process")
|
|
679
|
+
expect(message).not.toContain("refused — gtd: ")
|
|
680
|
+
expect(message).not.toContain("gtd slices: gtd:")
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
it("refuses a finished process whose config file was edited since", async () => {
|
|
684
|
+
const repo = repoWith(LINEAR)
|
|
685
|
+
await land(repo, { "a.txt": "a\n" })
|
|
686
|
+
await land(repo, { "b.txt": "b\n" })
|
|
687
|
+
await land(repo, { "c.txt": "c\n" })
|
|
688
|
+
repo.writeFile("gtd.config.ts", `${LINEAR}\n// edited\n`)
|
|
689
|
+
const message = await failure(repo)
|
|
690
|
+
expect(message).toContain(
|
|
691
|
+
`gtd slices: refused — config differs at ${repo.resolveRef("HEAD")!.slice(0, 7)}: gtd.config.ts. HEAD's process replays under today's config; revert or stash the edit to list its slices`,
|
|
692
|
+
)
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
const finishedProcess = async () => {
|
|
696
|
+
const repo = repoWith(LINEAR)
|
|
697
|
+
await land(repo, { "a.txt": "a\n" })
|
|
698
|
+
await land(repo, { "b.txt": "b\n" })
|
|
699
|
+
await land(repo, { "c.txt": "c\n" })
|
|
700
|
+
return repo
|
|
701
|
+
}
|
|
702
|
+
const slicesWith = (repo: InMemRepo, git: GitOperations) =>
|
|
703
|
+
Effect.runPromise(
|
|
704
|
+
processSlices.pipe(
|
|
705
|
+
Effect.provideService(GitService, git),
|
|
706
|
+
Effect.provide(testLayers(repo, {})),
|
|
707
|
+
),
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
it("warns about a closed run recorded by another version, slices unchanged", async () => {
|
|
711
|
+
const repo = await finishedProcess()
|
|
712
|
+
const plain = await provide(processSlices, repo)
|
|
713
|
+
const git = withTipMessage(repo, (m) =>
|
|
714
|
+
m.replace(`Gtd-Version: ${GTD_VERSION}`, "Gtd-Version: 0.0.1"),
|
|
715
|
+
)
|
|
716
|
+
const result = await slicesWith(repo, git)
|
|
717
|
+
expect(result.versionWarning).toContain("0.0.1")
|
|
718
|
+
expect(result.versionWarning).toContain(GTD_VERSION)
|
|
719
|
+
expect(result.slices).toEqual(plain.slices)
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
it("warns about an unknown version when the closing commit records none", async () => {
|
|
723
|
+
const repo = await finishedProcess()
|
|
724
|
+
const git = withTipMessage(repo, (m) => m.replace(/Gtd-Version: .*\n?/, ""))
|
|
725
|
+
expect((await slicesWith(repo, git)).versionWarning).toContain("an unknown gtd version")
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
it("does not warn for a closed run landed at the running version", async () => {
|
|
729
|
+
const repo = await finishedProcess()
|
|
730
|
+
expect((await provide(processSlices, repo)).versionWarning).toBeUndefined()
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
it("does not warn for an underway process", async () => {
|
|
734
|
+
const repo = repoWith(LINEAR)
|
|
735
|
+
await land(repo, { "a.txt": "a\n" })
|
|
736
|
+
const git = withTipMessage(repo, (m) =>
|
|
737
|
+
m.replace(`Gtd-Version: ${GTD_VERSION}`, "Gtd-Version: 0.0.1"),
|
|
738
|
+
)
|
|
739
|
+
expect((await slicesWith(repo, git)).versionWarning).toBeUndefined()
|
|
740
|
+
})
|
|
741
|
+
|
|
742
|
+
it("refuses on config drift before replay-ends drift", async () => {
|
|
743
|
+
const repo = await finishedProcess()
|
|
744
|
+
repo.writeFile(
|
|
745
|
+
"gtd.config.ts",
|
|
746
|
+
LINEAR.replace(
|
|
747
|
+
'await agent("checking", "check-prompt")',
|
|
748
|
+
'await agent("checking", "check-prompt")\n await human("idle", { message: "again" })\n await agent("polishing", "polish-prompt")',
|
|
749
|
+
),
|
|
750
|
+
)
|
|
751
|
+
const message = await failure(repo)
|
|
752
|
+
expect(message).toContain("config differs at")
|
|
753
|
+
expect(message).not.toContain("does not end")
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
it("still lists an underway process's slices after the same config edit", async () => {
|
|
757
|
+
const repo = repoWith(LINEAR)
|
|
758
|
+
await land(repo, { "a.txt": "a\n" })
|
|
759
|
+
repo.writeFile("gtd.config.ts", `${LINEAR}\n// edited\n`)
|
|
760
|
+
expect((await provide(processSlices, repo)).slices).toHaveLength(1)
|
|
624
761
|
})
|
|
625
762
|
})
|
|
626
763
|
|
|
@@ -1154,30 +1291,6 @@ describe("the pending working tree is read at most once per restAt, and never st
|
|
|
1154
1291
|
})
|
|
1155
1292
|
})
|
|
1156
1293
|
|
|
1157
|
-
describe("closedRunAt", () => {
|
|
1158
|
-
it("is the run summaryRun reads when HEAD is the tip", async () => {
|
|
1159
|
-
const repo = repoWith(LINEAR)
|
|
1160
|
-
await land(repo, { "a.txt": "a\n" })
|
|
1161
|
-
await land(repo, { "b.txt": "b\n" })
|
|
1162
|
-
const tip = await land(repo, { "c.txt": "c\n" })
|
|
1163
|
-
expect(await provide(closedRunAt(tip), repo)).toEqual(await provide(summaryRun, repo))
|
|
1164
|
-
})
|
|
1165
|
-
|
|
1166
|
-
it("resolves the closed run with HEAD elsewhere", async () => {
|
|
1167
|
-
const repo = repoWith(LINEAR)
|
|
1168
|
-
const building = await land(repo, { "a.txt": "a\n" })
|
|
1169
|
-
await land(repo, { "b.txt": "b\n" })
|
|
1170
|
-
const tip = await land(repo, { "c.txt": "c\n" })
|
|
1171
|
-
const closed = await provide(closedRunAt(tip), repo)
|
|
1172
|
-
repo.writeFile("d.txt", "d\n")
|
|
1173
|
-
repo.seedCommit("chore: unrelated")
|
|
1174
|
-
const later = await provide(closedRunAt(tip), repo)
|
|
1175
|
-
expect(later).toEqual(closed)
|
|
1176
|
-
expect(later.closingHash).toBe(tip)
|
|
1177
|
-
expect(later.startHash).toBe(building)
|
|
1178
|
-
})
|
|
1179
|
-
})
|
|
1180
|
-
|
|
1181
1294
|
describe("finishing landings keep history", () => {
|
|
1182
1295
|
const landingOf = async (repo: InMemRepo, git?: GitOperations) => {
|
|
1183
1296
|
const rest = await provide(currentRest, repo)
|
|
@@ -1467,14 +1580,6 @@ export default async () => {
|
|
|
1467
1580
|
expect(trail.versionWarning).toBeUndefined()
|
|
1468
1581
|
})
|
|
1469
1582
|
|
|
1470
|
-
const withTipMessage = (repo: InMemRepo, edit: (message: string) => string): GitOperations => {
|
|
1471
|
-
const real = fakeGitOperations(repo)
|
|
1472
|
-
return {
|
|
1473
|
-
...real,
|
|
1474
|
-
lastCommitMessage: (ref) => Effect.map(real.lastCommitMessage(ref), edit),
|
|
1475
|
-
}
|
|
1476
|
-
}
|
|
1477
|
-
|
|
1478
1583
|
it("warns, never refuses, when the recorded version differs", async () => {
|
|
1479
1584
|
const { repo, tip } = await finished()
|
|
1480
1585
|
const git = withTipMessage(repo, (m) =>
|
|
@@ -1504,13 +1609,63 @@ export default async () => {
|
|
|
1504
1609
|
expect(trail.versionWarning).toContain(GTD_VERSION)
|
|
1505
1610
|
})
|
|
1506
1611
|
|
|
1612
|
+
it("reads a recorded version carrying control bytes as unknown", async () => {
|
|
1613
|
+
const { repo, tip } = await finished()
|
|
1614
|
+
const raw = "1.0\u001b]0;x\u0007"
|
|
1615
|
+
const git = withTipMessage(repo, (m) =>
|
|
1616
|
+
m.replace(`Gtd-Version: ${GTD_VERSION}`, `Gtd-Version: ${raw}`),
|
|
1617
|
+
)
|
|
1618
|
+
const trail = await Effect.runPromise(
|
|
1619
|
+
historyTrail(tip).pipe(
|
|
1620
|
+
Effect.provideService(GitService, git),
|
|
1621
|
+
Effect.provide(testLayers(repo, {})),
|
|
1622
|
+
),
|
|
1623
|
+
)
|
|
1624
|
+
expect(trail.version.recorded).toBeUndefined()
|
|
1625
|
+
expect(trail.versionWarning).toContain("an unknown gtd version")
|
|
1626
|
+
// eslint-disable-next-line no-control-regex
|
|
1627
|
+
expect(trail.versionWarning).not.toMatch(/[\u0000-\u001f\u007f-\u009f]/)
|
|
1628
|
+
})
|
|
1629
|
+
|
|
1507
1630
|
it("refuses naming the config file that differs at the tip", async () => {
|
|
1508
1631
|
const { repo, tip } = await finished()
|
|
1509
1632
|
repo.writeFile("gtd.config.ts", `${LINEAR}\n// edited\n`)
|
|
1510
1633
|
repo.seedCommit("chore: edit config")
|
|
1511
1634
|
const exit = await provideExit(historyTrail(tip), repo)
|
|
1512
1635
|
expect(Exit.isFailure(exit)).toBe(true)
|
|
1513
|
-
expect(String(Exit.isFailure(exit) && exit.cause)).toContain(
|
|
1636
|
+
expect(String(Exit.isFailure(exit) && exit.cause)).toContain(
|
|
1637
|
+
`gtd history: refused — config differs at ${tip.slice(0, 7)}: gtd.config.ts. A history ref replays under today's config, so it is unreadable after the config changes`,
|
|
1638
|
+
)
|
|
1639
|
+
})
|
|
1640
|
+
|
|
1641
|
+
it("refuses a resting tip with the exact message", async () => {
|
|
1642
|
+
const repo = repoWith(LINEAR)
|
|
1643
|
+
const tip = await land(repo, { "a.txt": "a\n" })
|
|
1644
|
+
const exit = await provideExit(historyTrail(tip), repo)
|
|
1645
|
+
expect(String(Exit.isFailure(exit) && exit.cause)).toContain(
|
|
1646
|
+
`gtd history: ${tip.slice(0, 7)} does not close a process under today's config`,
|
|
1647
|
+
)
|
|
1648
|
+
})
|
|
1649
|
+
|
|
1650
|
+
it("reads the episode's trees exactly once", async () => {
|
|
1651
|
+
const { repo, tip } = await finished()
|
|
1652
|
+
let calls = 0
|
|
1653
|
+
const trail = await Effect.runPromise(
|
|
1654
|
+
Effect.gen(function* () {
|
|
1655
|
+
const real = yield* Workspace
|
|
1656
|
+
return yield* historyTrail(tip).pipe(
|
|
1657
|
+
Effect.provideService(Workspace, {
|
|
1658
|
+
...real,
|
|
1659
|
+
episodeTrees: (...args: Parameters<typeof real.episodeTrees>) => {
|
|
1660
|
+
calls++
|
|
1661
|
+
return real.episodeTrees(...args)
|
|
1662
|
+
},
|
|
1663
|
+
}),
|
|
1664
|
+
)
|
|
1665
|
+
}).pipe(Effect.provide(testLayers(repo, {}))),
|
|
1666
|
+
)
|
|
1667
|
+
expect(trail.steps).toHaveLength(3)
|
|
1668
|
+
expect(calls).toBe(1)
|
|
1514
1669
|
})
|
|
1515
1670
|
|
|
1516
1671
|
it("refuses naming a config file missing from the tip", async () => {
|
package/src/edge/Edge.ts
CHANGED
|
@@ -43,7 +43,6 @@ import {
|
|
|
43
43
|
type JudgeVerdict,
|
|
44
44
|
type ReachedStep,
|
|
45
45
|
type ReplayOutcome,
|
|
46
|
-
type SliceRange,
|
|
47
46
|
type TreeView,
|
|
48
47
|
} from "../replay/index.js"
|
|
49
48
|
import type { Mark, ScopeAccess } from "../flows/index.js"
|
|
@@ -53,6 +52,7 @@ import {
|
|
|
53
52
|
UNATTRIBUTED_MODEL,
|
|
54
53
|
type ApprovalEntry,
|
|
55
54
|
type ModelCost,
|
|
55
|
+
type SliceEntry,
|
|
56
56
|
type StepAccess,
|
|
57
57
|
} from "../wire/index.js"
|
|
58
58
|
|
|
@@ -476,26 +476,21 @@ const runAtTip = (
|
|
|
476
476
|
return yield* runOf([...before, ...closing], { ...location }, tip.hash)
|
|
477
477
|
})
|
|
478
478
|
|
|
479
|
+
const runAtHead = (git: GitOperations, def: WorkflowDefinition) =>
|
|
480
|
+
Effect.gen(function* () {
|
|
481
|
+
// Cheap: just HEAD's own subject, never the commits before it.
|
|
482
|
+
return yield* runAtTip(git, def, (yield* git.subjectHistory(1, 0))[0])
|
|
483
|
+
})
|
|
484
|
+
|
|
479
485
|
/** The process HEAD closes or sits inside — `gtd summary`'s run. */
|
|
480
486
|
export const summaryRun: Effect.Effect<ProcessRun, Error, ConfigRequirements> = Effect.gen(
|
|
481
487
|
function* () {
|
|
482
488
|
const git = yield* GitService
|
|
483
489
|
const def = (yield* (yield* ConfigService).load).workflow
|
|
484
|
-
|
|
485
|
-
return yield* runAtTip(git, def, (yield* git.subjectHistory(1, 0))[0])
|
|
490
|
+
return yield* runAtHead(git, def)
|
|
486
491
|
},
|
|
487
492
|
)
|
|
488
493
|
|
|
489
|
-
/** The process whose finishing commit is `tip` — reads from `tip`'s own ancestry, so it works when no branch reaches it. */
|
|
490
|
-
export const closedRunAt = (tip: string): Effect.Effect<ProcessRun, Error, ConfigRequirements> =>
|
|
491
|
-
Effect.gen(function* () {
|
|
492
|
-
const git = yield* GitService
|
|
493
|
-
const def = (yield* (yield* ConfigService).load).workflow
|
|
494
|
-
const hash = yield* git.resolveRef(tip)
|
|
495
|
-
const subject = yield* git.lastCommitSubject(hash)
|
|
496
|
-
return yield* runAtTip(git, def, { hash, subject })
|
|
497
|
-
})
|
|
498
|
-
|
|
499
494
|
// ── Trees ───────────────────────────────────────────────────────────────────
|
|
500
495
|
|
|
501
496
|
const commitTree = (workspace: WorkspaceOps, hash: string): TreeView => {
|
|
@@ -897,7 +892,6 @@ interface Rest extends ResolvedRest {
|
|
|
897
892
|
/** Total recorded cost of the process, and its per-model breakdown. */
|
|
898
893
|
readonly cost: { readonly total: number; readonly byModel: readonly ModelCost[] }
|
|
899
894
|
readonly setup: ReplaySetup
|
|
900
|
-
readonly slices: readonly SliceRange[]
|
|
901
895
|
}
|
|
902
896
|
|
|
903
897
|
/** Per-model token totals, highest-cost first (ties broken by model name). */
|
|
@@ -1072,12 +1066,74 @@ export const restAt = (
|
|
|
1072
1066
|
byModel: costByModel(run.costEntries),
|
|
1073
1067
|
},
|
|
1074
1068
|
setup,
|
|
1075
|
-
slices: sliceRangesOf(outcome.slices, run),
|
|
1076
1069
|
}
|
|
1077
1070
|
})
|
|
1078
1071
|
|
|
1079
1072
|
export const currentRest: Effect.Effect<Rest, Error, RestRequirements> = restAt(undefined)
|
|
1080
1073
|
|
|
1074
|
+
export interface ProcessSlices {
|
|
1075
|
+
readonly slices: readonly SliceEntry[]
|
|
1076
|
+
readonly versionWarning?: string
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* The slices of the process HEAD closes or sits inside, current episode only,
|
|
1081
|
+
* stack order. Reads like `historyTrail`, never `restAt`: it narrates and
|
|
1082
|
+
* writes nothing, and returns a version warning for a closed run.
|
|
1083
|
+
*/
|
|
1084
|
+
export const processSlices: Effect.Effect<ProcessSlices, Error, RestRequirements> = Effect.gen(
|
|
1085
|
+
function* () {
|
|
1086
|
+
const git = yield* GitService
|
|
1087
|
+
const config = yield* (yield* ConfigService).load
|
|
1088
|
+
const workspace = yield* Workspace
|
|
1089
|
+
const host = yield* Host
|
|
1090
|
+
const run = yield* runAtHead(git, config.workflow)
|
|
1091
|
+
if (run.trace.length === 0) {
|
|
1092
|
+
return yield* Effect.fail(
|
|
1093
|
+
new Error("gtd slices: refused — HEAD neither closes nor sits inside a process"),
|
|
1094
|
+
)
|
|
1095
|
+
}
|
|
1096
|
+
const closing = run.closingHash
|
|
1097
|
+
let outcome: ReplayOutcome
|
|
1098
|
+
let versionWarning: string | undefined
|
|
1099
|
+
if (closing !== undefined) {
|
|
1100
|
+
const replayed = yield* replayFinishedProcess(config, run, closing, {
|
|
1101
|
+
command: "gtd slices",
|
|
1102
|
+
configReason:
|
|
1103
|
+
"HEAD's process replays under today's config; revert or stash the edit to list its slices",
|
|
1104
|
+
notEnded: (o) => slicesDivergence(run, o),
|
|
1105
|
+
})
|
|
1106
|
+
outcome = replayed.outcome
|
|
1107
|
+
versionWarning = replayed.versionWarning
|
|
1108
|
+
} else {
|
|
1109
|
+
const { setup } = yield* setupFor(config, run, workspace, host)
|
|
1110
|
+
outcome = yield* Effect.promise(() => replayFor(setup))
|
|
1111
|
+
if (outcome.kind !== "rest") return yield* Effect.fail(slicesDivergence(run, outcome))
|
|
1112
|
+
}
|
|
1113
|
+
return {
|
|
1114
|
+
slices: sliceRangesOf(outcome.slices, run).map(({ name, base, tip, open }) => ({
|
|
1115
|
+
name,
|
|
1116
|
+
base,
|
|
1117
|
+
tip,
|
|
1118
|
+
closed: closing !== undefined || !open,
|
|
1119
|
+
})),
|
|
1120
|
+
...(versionWarning !== undefined ? { versionWarning } : {}),
|
|
1121
|
+
}
|
|
1122
|
+
},
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
type EndedOutcome = Extract<ReplayOutcome, { kind: "ended" }>
|
|
1126
|
+
type UnendedOutcome = Exclude<ReplayOutcome, { kind: "ended" }>
|
|
1127
|
+
|
|
1128
|
+
/** Every `gtd slices` refusal reads `gtd slices: refused — <reason>`; replay's own `gtd: ` lead is dropped. */
|
|
1129
|
+
const slicesDivergence = (run: ProcessRun, outcome: ReplayOutcome): Error => {
|
|
1130
|
+
const reason =
|
|
1131
|
+
outcome.kind === "rest" && run.closingHash !== undefined
|
|
1132
|
+
? `${run.closingHash.slice(0, 7)} closes a process that does not end under today's config`
|
|
1133
|
+
: (replayError(outcome)?.message.replace(/^gtd: /, "") ?? "replay failed")
|
|
1134
|
+
return new Error(`gtd slices: refused — ${reason}`)
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1081
1137
|
/**
|
|
1082
1138
|
* Whether `gtd --workflow <name>` can start a process here: the workflow must
|
|
1083
1139
|
* reach a step from the tree the opening commit would capture. That step is
|
|
@@ -1231,7 +1287,7 @@ const multilineRefusal = (vars: Record<string, string>): Landing | undefined =>
|
|
|
1231
1287
|
}
|
|
1232
1288
|
|
|
1233
1289
|
const landingTarget = (
|
|
1234
|
-
outcome:
|
|
1290
|
+
outcome: ReplayOutcome,
|
|
1235
1291
|
rest: Rest,
|
|
1236
1292
|
): Effect.Effect<StateName | Landing, Error> => {
|
|
1237
1293
|
if (outcome.kind === "refused")
|
|
@@ -1442,10 +1498,14 @@ const inRepoConfigPaths = (
|
|
|
1442
1498
|
return [...paths].filter((p) => p !== "" && !p.startsWith("..") && !isAbsolute(p)).sort()
|
|
1443
1499
|
}
|
|
1444
1500
|
|
|
1445
|
-
const configRefusal = (
|
|
1501
|
+
const configRefusal = (
|
|
1502
|
+
command: string,
|
|
1503
|
+
tip: string,
|
|
1504
|
+
paths: readonly string[],
|
|
1505
|
+
reason = "A history ref replays under today's config, so it is unreadable after the config changes",
|
|
1506
|
+
): Error =>
|
|
1446
1507
|
new Error(
|
|
1447
|
-
|
|
1448
|
-
`A history ref replays under today's config, so it is unreadable after the config changes`,
|
|
1508
|
+
`${command}: refused — config differs at ${tip.slice(0, 7)}: ${paths.join(", ")}. ${reason}`,
|
|
1449
1509
|
)
|
|
1450
1510
|
|
|
1451
1511
|
const differingConfigPaths = (
|
|
@@ -1469,10 +1529,7 @@ const differingConfigPaths = (
|
|
|
1469
1529
|
return differing
|
|
1470
1530
|
})
|
|
1471
1531
|
|
|
1472
|
-
const notClosedError = (
|
|
1473
|
-
hash: string,
|
|
1474
|
-
outcome: Exclude<Awaited<ReturnType<typeof replayFor>>, { kind: "ended" }>,
|
|
1475
|
-
): Error =>
|
|
1532
|
+
const notClosedError = (hash: string, outcome: UnendedOutcome): Error =>
|
|
1476
1533
|
outcome.kind === "rest"
|
|
1477
1534
|
? new Error(`gtd history: ${hash.slice(0, 7)} does not close a process under today's config`)
|
|
1478
1535
|
: (replayError(outcome) ?? new Error("gtd history: replay failed"))
|
|
@@ -1507,30 +1564,52 @@ export const historyTrail = (tip: string): Effect.Effect<HistoryTrail, Error, Re
|
|
|
1507
1564
|
Effect.gen(function* () {
|
|
1508
1565
|
const git = yield* GitService
|
|
1509
1566
|
const config = yield* (yield* ConfigService).load
|
|
1510
|
-
const workspace = yield* Workspace
|
|
1511
|
-
const host = yield* Host
|
|
1512
1567
|
const hash = yield* git.resolveRef(tip)
|
|
1513
|
-
const
|
|
1514
|
-
|
|
1515
|
-
const
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
yield* workspace.episodeTrees(
|
|
1520
|
-
run.episode.base,
|
|
1521
|
-
run.episode.commits.map((c) => c.hash),
|
|
1522
|
-
)
|
|
1523
|
-
const outcome = yield* Effect.promise(() => replayFor(setup))
|
|
1524
|
-
if (outcome.kind !== "ended") return yield* Effect.fail(notClosedError(hash, outcome))
|
|
1525
|
-
|
|
1526
|
-
const steps = historySteps(run)
|
|
1527
|
-
const recorded = parseCommitMessage(yield* git.lastCommitMessage(hash)).version
|
|
1528
|
-
const versionWarning = versionWarningFor(recorded)
|
|
1568
|
+
const subject = yield* git.lastCommitSubject(hash)
|
|
1569
|
+
const run = yield* runAtTip(git, config.workflow, { hash, subject })
|
|
1570
|
+
const { outcome, recorded, versionWarning } = yield* replayFinishedProcess(config, run, hash, {
|
|
1571
|
+
command: "gtd history",
|
|
1572
|
+
notEnded: (o) => notClosedError(hash, o),
|
|
1573
|
+
})
|
|
1529
1574
|
return {
|
|
1530
1575
|
tip: hash,
|
|
1531
|
-
steps,
|
|
1576
|
+
steps: historySteps(run),
|
|
1532
1577
|
ended: outcome.via,
|
|
1533
1578
|
version: { recorded, running: GTD_VERSION },
|
|
1534
1579
|
...(versionWarning !== undefined ? { versionWarning } : {}),
|
|
1535
1580
|
}
|
|
1536
1581
|
})
|
|
1582
|
+
|
|
1583
|
+
interface FinishedReplay {
|
|
1584
|
+
readonly outcome: EndedOutcome
|
|
1585
|
+
readonly recorded: string | undefined
|
|
1586
|
+
readonly versionWarning?: string
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/** Refuses on config drift before replaying, so a stale config never runs the flow. */
|
|
1590
|
+
const replayFinishedProcess = (
|
|
1591
|
+
config: ConfigOperations,
|
|
1592
|
+
run: ProcessRun,
|
|
1593
|
+
hash: string,
|
|
1594
|
+
how: {
|
|
1595
|
+
readonly command: string
|
|
1596
|
+
readonly configReason?: string
|
|
1597
|
+
readonly notEnded: (outcome: UnendedOutcome) => Error
|
|
1598
|
+
},
|
|
1599
|
+
): Effect.Effect<FinishedReplay, Error, RestRequirements> =>
|
|
1600
|
+
Effect.gen(function* () {
|
|
1601
|
+
const git = yield* GitService
|
|
1602
|
+
const workspace = yield* Workspace
|
|
1603
|
+
const host = yield* Host
|
|
1604
|
+
const differing = yield* differingConfigPaths(git, workspace, host.root, config, run, hash)
|
|
1605
|
+
if (differing.length > 0)
|
|
1606
|
+
return yield* Effect.fail(configRefusal(how.command, hash, differing, how.configReason))
|
|
1607
|
+
const { setup } = yield* setupFor(config, run, workspace, host)
|
|
1608
|
+
const outcome = yield* Effect.promise(() => replayFor(setup))
|
|
1609
|
+
if (outcome.kind !== "ended") return yield* Effect.fail(how.notEnded(outcome))
|
|
1610
|
+
const recorded = parseCommitMessage(
|
|
1611
|
+
run.episode.commits.find((c) => c.hash === hash)?.message ?? "",
|
|
1612
|
+
).version
|
|
1613
|
+
const versionWarning = versionWarningFor(recorded)
|
|
1614
|
+
return { outcome, recorded, ...(versionWarning !== undefined ? { versionWarning } : {}) }
|
|
1615
|
+
})
|
package/src/edge/index.ts
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
export const CONFIG_SCAFFOLD = `// The workflows gtd runs here: gtd's shipped feature, review and fix.
|
|
4
4
|
// Every exported function is startable with \`gtd --workflow <name>\`; the
|
|
5
5
|
// default export is what a change in the tree starts. Each workflow brings
|
|
6
|
-
// its own doors: \`gtd door review [base]\` and \`gtd door fix\`.
|
|
6
|
+
// its own doors: \`gtd door review [base]\` and \`gtd door fix\`. Export \`rollout\`
|
|
7
|
+
// from \`@pmelab/gtd/workflows/rollout\` and \`cleanup\` from
|
|
8
|
+
// \`@pmelab/gtd/workflows/cleanup\` to add \`gtd door rollout <flag>\` and
|
|
9
|
+
// \`gtd door cleanup <flag>\`.
|
|
7
10
|
export {
|
|
8
11
|
feature as default,
|
|
9
12
|
feature,
|
|
@@ -10,6 +10,7 @@ import { resolve } from "node:path"
|
|
|
10
10
|
import { createRequire } from "node:module"
|
|
11
11
|
import { describe, expect, it } from "vitest"
|
|
12
12
|
import { EDIT_COMMAND, DOOR_COMMAND, MINIMAL_DRIVER, renderBriefing } from "./Install.js"
|
|
13
|
+
import { CONFIG_SCAFFOLD } from "./index.js"
|
|
13
14
|
|
|
14
15
|
const SUITE_PATHS = ["gtd-build", "gtd-edit", "gtd-door"]
|
|
15
16
|
const SUPERSEDED_PATHS = ["gtd-fix", "gtd-review"]
|
|
@@ -424,3 +425,12 @@ describe("DOOR_COMMAND", () => {
|
|
|
424
425
|
expect(DOOR_COMMAND).toMatch(/usage: gtd-door <name> \[args\.\.\.\]" >&2\n\s*exit 2/)
|
|
425
426
|
})
|
|
426
427
|
})
|
|
428
|
+
|
|
429
|
+
describe("bundled door lists", () => {
|
|
430
|
+
it("the scaffold's exports stay feature, review, fix, never rollout or cleanup", () => {
|
|
431
|
+
const code = CONFIG_SCAFFOLD.split("\n")
|
|
432
|
+
.filter((l) => !l.startsWith("//"))
|
|
433
|
+
.join("\n")
|
|
434
|
+
expect(code).not.toMatch(/rollout|cleanup/)
|
|
435
|
+
})
|
|
436
|
+
})
|
package/src/install/Install.ts
CHANGED
|
@@ -515,8 +515,11 @@ hands off to \`gtd-build\`, which halts at the blocked gate and prints it,
|
|
|
515
515
|
rather than starting the review; \`gtd-door fix\` on a GREEN suite is a no-op
|
|
516
516
|
straight back to \`idle\` — the exec'd \`gtd-build\` exits immediately on it.
|
|
517
517
|
\`gtd doors\` lists the doors a repository offers (those of the workflows its
|
|
518
|
-
config exports — the bundled \`fix\` and \`review [base]
|
|
519
|
-
|
|
518
|
+
config exports — the bundled \`fix\` and \`review [base]\`, plus the opt-in
|
|
519
|
+
\`rollout <flag>\` (turn a release flag default-on) and \`cleanup <flag>\`
|
|
520
|
+
(remove it), which a config adds by exporting \`rollout\` from
|
|
521
|
+
\`@pmelab/gtd/workflows/rollout\` and \`cleanup\` from
|
|
522
|
+
\`@pmelab/gtd/workflows/cleanup\` — plus any it declares). Refuses with
|
|
520
523
|
\`usage: gtd-door <name> [args...]\` on stderr and exit \`2\` when the name is
|
|
521
524
|
missing — nothing was even attempted. Default path: \`~/.local/bin/gtd-door\`.
|
|
522
525
|
|
|
@@ -1231,6 +1231,38 @@ describe("slice()", () => {
|
|
|
1231
1231
|
])
|
|
1232
1232
|
})
|
|
1233
1233
|
|
|
1234
|
+
it("an ended outcome carries every span of the episode that just ended", async () => {
|
|
1235
|
+
const wf = async () => {
|
|
1236
|
+
await slice("one", async () => {
|
|
1237
|
+
await human("a")
|
|
1238
|
+
})
|
|
1239
|
+
await slice("two", async () => {
|
|
1240
|
+
await human("b")
|
|
1241
|
+
})
|
|
1242
|
+
}
|
|
1243
|
+
const h = new History().land("human", "a", 1, "b").land("human", "b", 1, "done")
|
|
1244
|
+
const outcome = await replayOf(wf, h)
|
|
1245
|
+
expect(outcome).toMatchObject({ kind: "ended", via: "return" })
|
|
1246
|
+
expect(spansOf(outcome)).toEqual([
|
|
1247
|
+
{ name: "one", enteredAt: hashOf(0), leftAt: hashOf(1) },
|
|
1248
|
+
{ name: "two", enteredAt: hashOf(1), leftAt: hashOf(2) },
|
|
1249
|
+
])
|
|
1250
|
+
})
|
|
1251
|
+
|
|
1252
|
+
it("restart() inside a slice ends via restart with that span's leftAt undefined", async () => {
|
|
1253
|
+
const wf = async () => {
|
|
1254
|
+
await human("a")
|
|
1255
|
+
await slice("risky", async () => {
|
|
1256
|
+
await human("b")
|
|
1257
|
+
await restart()
|
|
1258
|
+
})
|
|
1259
|
+
}
|
|
1260
|
+
const h = new History().land("human", "a", 1, "b").land("human", "b", 1, "done")
|
|
1261
|
+
const outcome = await replayOf(wf, h)
|
|
1262
|
+
expect(outcome).toMatchObject({ kind: "ended", via: "restart" })
|
|
1263
|
+
expect(spansOf(outcome)).toEqual([{ name: "risky", enteredAt: hashOf(1), leftAt: undefined }])
|
|
1264
|
+
})
|
|
1265
|
+
|
|
1234
1266
|
it("keeps leftAt undefined when the process rests inside the slice", async () => {
|
|
1235
1267
|
const wf = async () => {
|
|
1236
1268
|
await slice("refactor", async () => {
|
package/src/replay/Replay.ts
CHANGED
|
@@ -126,6 +126,8 @@ export type ReplayOutcome =
|
|
|
126
126
|
readonly trace: readonly ReachedStep[]
|
|
127
127
|
readonly landed: ReachedStep | undefined
|
|
128
128
|
readonly marks: readonly Mark[]
|
|
129
|
+
/** Spans of the episode that just ended; a restart() inside a slice leaves its leftAt undefined. */
|
|
130
|
+
readonly slices: readonly SliceSpan[]
|
|
129
131
|
}
|
|
130
132
|
| { readonly kind: "divergence"; readonly message: string }
|
|
131
133
|
| { readonly kind: "refused"; readonly message: string }
|
|
@@ -613,7 +615,7 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
613
615
|
kind: "divergence",
|
|
614
616
|
message: `gtd: the flow ended, but commit ${short(commits[cursor]!.hash)} continues the episode — ${DIVERGED}`,
|
|
615
617
|
}
|
|
616
|
-
: { kind: "ended", via, trace, landed, marks }
|
|
618
|
+
: { kind: "ended", via, trace, landed, marks, slices: slices.map((s) => ({ ...s })) }
|
|
617
619
|
|
|
618
620
|
const changesSince = (hash: string): readonly Change[] => {
|
|
619
621
|
const tree = treeAt(hash)
|