@pmelab/gtd 60.1.0 → 61.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 +7 -0
- package/dist/chunks/{testkit-BhTey1Do.mjs → testkit-C9sq0x07.mjs} +1695 -1454
- package/dist/chunks/{wire-DZ8R1RU9.mjs → wire-CPNSm_9N.mjs} +27 -12
- package/dist/gtd.bundle.mjs +1666 -1449
- package/dist/gtd.core.mjs +86 -86
- 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/History.test.ts +10 -1
- package/src/cli/Metrics.test.ts +10 -1
- package/src/cli/Program.test.ts +30 -19
- package/src/cli/Program.ts +4 -1
- package/src/cli/Strays.ts +3 -2
- package/src/edge/Edge.test.ts +78 -25
- package/src/edge/Edge.ts +161 -28
- 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/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 +12 -12
- package/src/replay/Trailers.ts +35 -20
- 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 -2
- 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/index.ts +1 -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/dist/testkit/gherkin.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as runsItsScript, r as testRepo } from "../chunks/testkit-
|
|
1
|
+
import { n as runsItsScript, r as testRepo } from "../chunks/testkit-C9sq0x07.mjs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
4
|
import { After, AfterAll, Given, QuickPickleWorld, Then, When, setWorldConstructor } from "quickpickle";
|
package/dist/testkit.mjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { H as installContext } from "./chunks/flows-DENt0uWV.mjs";
|
|
2
|
-
import { a as verdictWhen, i as verdict, n as runsItsScript, o as whenPrompted, r as testRepo, t as inTurn } from "./chunks/testkit-
|
|
2
|
+
import { a as verdictWhen, i as verdict, n as runsItsScript, o as whenPrompted, r as testRepo, t as inTurn } from "./chunks/testkit-C9sq0x07.mjs";
|
|
3
3
|
export { inTurn, installContext, runsItsScript, testRepo, verdict, verdictWhen, whenPrompted };
|
package/dist/wire.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { A as
|
|
2
|
-
export { ACCESS_REFUSAL, BEAT_KINDS, DEMAND_BRIEFING, EXIT_CODES, EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_SIGINT, EXIT_SIGTERM, EXIT_STRAYS_FOUND, EXIT_USAGE_ERROR, UNATTRIBUTED_MODEL, WIRE_SCHEMA, beatDocument, beatKindOf, demandOf, globMatches, judgeJson, landFields, landProseText, noopText, renderBeatJson, renderBeatPlain, renderFormat, renderLandJson, renderLandPlain, renderScriptJson, resolveSession, scriptFields, selectPath, stallDiagnosis, statusOf };
|
|
1
|
+
import { A as UNATTRIBUTED_MODEL, C as EXIT_RUNTIME_ERROR, D as EXIT_USAGE_ERROR, E as EXIT_STRAYS_FOUND, M as renderFormat, O as ACCESS_REFUSAL, S as EXIT_OK, T as EXIT_SIGTERM, _ as BEAT_KINDS, a as renderScriptJson, b as stallDiagnosis, c as landProseText, d as renderLandPlain, f as DEMAND_BRIEFING, g as statusOf, h as renderBeatJson, i as judgeJson, j as WIRE_SCHEMA, k as PIN_PATH, l as noopText, m as beatDocument, n as resolveSession, o as scriptFields, p as renderBeatPlain, r as selectPath, s as landFields, t as globMatches, u as renderLandJson, v as beatKindOf, w as EXIT_SIGINT, x as EXIT_CODES, y as demandOf } from "./chunks/wire-CPNSm_9N.mjs";
|
|
2
|
+
export { ACCESS_REFUSAL, BEAT_KINDS, DEMAND_BRIEFING, EXIT_CODES, EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_SIGINT, EXIT_SIGTERM, EXIT_STRAYS_FOUND, EXIT_USAGE_ERROR, PIN_PATH, UNATTRIBUTED_MODEL, WIRE_SCHEMA, beatDocument, beatKindOf, demandOf, globMatches, judgeJson, landFields, landProseText, noopText, renderBeatJson, renderBeatPlain, renderFormat, renderLandJson, renderLandPlain, renderScriptJson, resolveSession, scriptFields, selectPath, stallDiagnosis, statusOf };
|
package/package.json
CHANGED
package/src/cli/History.test.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { Effect, Exit } from "effect"
|
|
2
2
|
import { describe, expect, it } from "vitest"
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
applyEmittedScript,
|
|
5
|
+
InMemRepo,
|
|
6
|
+
makeCapturingCliIo,
|
|
7
|
+
takePinBeat,
|
|
8
|
+
testLayers,
|
|
9
|
+
} from "../twins/index.js"
|
|
4
10
|
import { runCli } from "./index.js"
|
|
5
11
|
import { historyEntries, resolveHistoryRef, type HistoryEntry } from "./History.js"
|
|
6
12
|
|
|
@@ -12,7 +18,9 @@ export default async () => {
|
|
|
12
18
|
}
|
|
13
19
|
`
|
|
14
20
|
|
|
21
|
+
/** Lands `files` as one turn, taking a pin beat before it and an unpin beat after it as a driver does. */
|
|
15
22
|
const land = async (repo: InMemRepo, files: Record<string, string>): Promise<string> => {
|
|
23
|
+
await takePinBeat(repo, "add")
|
|
16
24
|
for (const [path, content] of Object.entries(files)) repo.writeFile(path, content)
|
|
17
25
|
const { io, result } = makeCapturingCliIo(repo)
|
|
18
26
|
await Effect.runPromise(runCli(["node", "gtd.js", "land", "--json"], io))
|
|
@@ -24,6 +32,7 @@ const land = async (repo: InMemRepo, files: Record<string, string>): Promise<str
|
|
|
24
32
|
(JSON.parse(stdout) as { script: string }).script,
|
|
25
33
|
)
|
|
26
34
|
if (!outcome.ok) throw new Error(outcome.error)
|
|
35
|
+
await takePinBeat(repo, "remove")
|
|
27
36
|
return repo.resolveRef("HEAD")!
|
|
28
37
|
}
|
|
29
38
|
|
package/src/cli/Metrics.test.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
2
|
import { describe, expect, it } from "vitest"
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
applyEmittedScript,
|
|
5
|
+
InMemRepo,
|
|
6
|
+
makeCapturingCliIo,
|
|
7
|
+
takePinBeat,
|
|
8
|
+
testLayers,
|
|
9
|
+
} from "../twins/index.js"
|
|
4
10
|
import { runCli } from "./index.js"
|
|
5
11
|
import { processMetrics, readProcessMetrics, totalMetrics, type MetricCommit } from "./Metrics.js"
|
|
6
12
|
|
|
@@ -213,7 +219,9 @@ export default async () => {
|
|
|
213
219
|
}
|
|
214
220
|
`
|
|
215
221
|
|
|
222
|
+
/** Lands `files` as one turn, taking a pin beat before it and an unpin beat after it as a driver does. */
|
|
216
223
|
const land = async (repo: InMemRepo, files: Record<string, string>): Promise<string> => {
|
|
224
|
+
await takePinBeat(repo, "add")
|
|
217
225
|
for (const [path, content] of Object.entries(files)) repo.writeFile(path, content)
|
|
218
226
|
const { io, result } = makeCapturingCliIo(repo)
|
|
219
227
|
await Effect.runPromise(runCli(["node", "gtd.js", "land", "--json"], io))
|
|
@@ -225,6 +233,7 @@ const land = async (repo: InMemRepo, files: Record<string, string>): Promise<str
|
|
|
225
233
|
(JSON.parse(stdout) as { script: string }).script,
|
|
226
234
|
)
|
|
227
235
|
if (!outcome.ok) throw new Error(outcome.error)
|
|
236
|
+
await takePinBeat(repo, "remove")
|
|
228
237
|
return repo.resolveRef("HEAD")!
|
|
229
238
|
}
|
|
230
239
|
|
package/src/cli/Program.test.ts
CHANGED
|
@@ -18,7 +18,13 @@ vi.mock("node:os", async (importOriginal) => ({
|
|
|
18
18
|
import { runCli, type Command } from "./index.js"
|
|
19
19
|
import { EXIT_USAGE_ERROR, stallDiagnosis, noopText, WIRE_SCHEMA } from "../wire/index.js"
|
|
20
20
|
import { needsOf, runCommand, SelectorUsageError, type CommandRequirements } from "./Program.js"
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
InMemRepo,
|
|
23
|
+
makeCapturingCliIo,
|
|
24
|
+
testLayers,
|
|
25
|
+
applyEmittedScript,
|
|
26
|
+
takePinBeat,
|
|
27
|
+
} from "../twins/index.js"
|
|
22
28
|
import {
|
|
23
29
|
commitAll,
|
|
24
30
|
DID_NOT_RUN_COMMENT,
|
|
@@ -39,13 +45,14 @@ const run = async (
|
|
|
39
45
|
return result()
|
|
40
46
|
}
|
|
41
47
|
|
|
42
|
-
/** Lands via `gtd land --json` and applies the emitted script, as a driver would. */
|
|
48
|
+
/** Lands via `gtd land --json` and applies the emitted script, then takes the pin or unpin beat it leads to, as a driver would. */
|
|
43
49
|
const landAndApply = async (
|
|
44
50
|
repo: InMemRepo,
|
|
45
51
|
): Promise<{ readonly exitCode: number; readonly script: string }> => {
|
|
46
52
|
const { stdout, exitCode } = await run(repo, "land", "--json")
|
|
47
53
|
const script = exitCode === 0 ? (JSON.parse(stdout) as { readonly script: string }).script : ""
|
|
48
54
|
if (exitCode === 0) expect(applyEmittedScript(repo, new Map(), script).ok).toBe(true)
|
|
55
|
+
if (exitCode === 0) await takePinBeat(repo)
|
|
49
56
|
return { exitCode, script }
|
|
50
57
|
}
|
|
51
58
|
|
|
@@ -104,7 +111,7 @@ export const hotfix = workflow(
|
|
|
104
111
|
expect(exitCode).toBe(0)
|
|
105
112
|
expect(stdout).toContain(
|
|
106
113
|
commitAll(
|
|
107
|
-
`gtd(human): working\n\nGtd-Workflow: hotfix\nGtd-Var: greeting=hello\nGtd-Config: ${contentHash(WORKFLOW)} gtd.config.ts\nGtd-Format:
|
|
114
|
+
`gtd(human): working\n\nGtd-Workflow: hotfix\nGtd-Var: greeting=hello\nGtd-Config: ${contentHash(WORKFLOW)} gtd.config.ts\nGtd-Format: 2`,
|
|
108
115
|
),
|
|
109
116
|
)
|
|
110
117
|
expect(repo.commitHistory()).toHaveLength(before)
|
|
@@ -190,6 +197,9 @@ export const hotfix = workflow(
|
|
|
190
197
|
const { stdout } = await run(repo, "start", "hotfix")
|
|
191
198
|
expect(applyEmittedScript(repo, new Map(), stdout).ok).toBe(true)
|
|
192
199
|
expect(repo.lastCommitSubject()).toBe("gtd(human): working")
|
|
200
|
+
const pin = JSON.parse((await run(repo, "next", "--json")).stdout) as Record<string, unknown>
|
|
201
|
+
expect(pin).toMatchObject({ kind: "script", state: "pin", actor: "check", pin: "add" })
|
|
202
|
+
expect(await takePinBeat(repo, "add")).toBe(true)
|
|
193
203
|
const next = JSON.parse((await run(repo, "next", "--json")).stdout) as Record<string, unknown>
|
|
194
204
|
expect(next.kind).toBe("prompt")
|
|
195
205
|
expect(next.state).toBe("working")
|
|
@@ -496,6 +506,7 @@ export const resume = workflow(work)
|
|
|
496
506
|
const repo = seed(WORKFLOW)
|
|
497
507
|
const { stdout: entry } = await run(repo, "start", "resume")
|
|
498
508
|
expect(applyEmittedScript(repo, new Map(), entry).ok).toBe(true)
|
|
509
|
+
await takePinBeat(repo, "add")
|
|
499
510
|
const { stdout, exitCode } = await run(repo, "next", "--json")
|
|
500
511
|
expect(exitCode).toBe(0)
|
|
501
512
|
expect(JSON.parse(stdout).kind).toBe("prompt")
|
|
@@ -775,20 +786,20 @@ describe("gtd next — refuses when history diverges from what replay reaches",
|
|
|
775
786
|
expect(repo.commitHistory()).toHaveLength(before)
|
|
776
787
|
|
|
777
788
|
expect(applyEmittedScript(repo, new Map(), stdout).ok).toBe(true)
|
|
778
|
-
expect(repo.commitHistory()).toHaveLength(before -
|
|
789
|
+
expect(repo.commitHistory()).toHaveLength(before - 3)
|
|
779
790
|
})
|
|
780
791
|
})
|
|
781
792
|
|
|
782
793
|
describe("a history written in a format this gtd does not read", () => {
|
|
783
794
|
// The exact wording is pinned in Format.test.ts; this is the part that proves the format gate fired.
|
|
784
|
-
const FOREIGN = "is written in history format
|
|
795
|
+
const FOREIGN = "is written in history format 3, but this gtd reads formats 1 to 2"
|
|
785
796
|
|
|
786
797
|
/** A step commit that would also diverge, so only the format gate can explain the refusal. */
|
|
787
798
|
const foreignStep = async (): Promise<InMemRepo> => {
|
|
788
799
|
const repo = seed(IDLE_THEN_WORKING)
|
|
789
800
|
await landTurn(repo, { "NOTE.md": "a note\n" })
|
|
790
801
|
repo.writeFile("WORK.md", "done\n")
|
|
791
|
-
repo.seedCommit("gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1\nGtd-Format:
|
|
802
|
+
repo.seedCommit("gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1\nGtd-Format: 3")
|
|
792
803
|
return repo
|
|
793
804
|
}
|
|
794
805
|
|
|
@@ -813,7 +824,7 @@ describe("a history written in a format this gtd does not read", () => {
|
|
|
813
824
|
it("refuses even when a foreign subject hides the commit from the episode", async () => {
|
|
814
825
|
const repo = seed(IDLE_THEN_WORKING)
|
|
815
826
|
repo.writeFile("NOTE.md", "a note\n")
|
|
816
|
-
repo.seedCommit("gtd2[human] idle -> working\n\nGtd-Format:
|
|
827
|
+
repo.seedCommit("gtd2[human] idle -> working\n\nGtd-Format: 3")
|
|
817
828
|
for (const command of ["next", "abandon"]) {
|
|
818
829
|
const { exitCode, stderr } = await run(repo, command)
|
|
819
830
|
expect(exitCode).toBe(1)
|
|
@@ -824,7 +835,7 @@ describe("a history written in a format this gtd does not read", () => {
|
|
|
824
835
|
it("a commit gtd never wrote is not read for a format, even quoting the trailer", async () => {
|
|
825
836
|
const repo = seed(IDLE_THEN_WORKING)
|
|
826
837
|
repo.writeFile("NOTE.md", "a note\n")
|
|
827
|
-
repo.seedCommit("feat: x\n\nGtd-Format:
|
|
838
|
+
repo.seedCommit("feat: x\n\nGtd-Format: 3")
|
|
828
839
|
expect((await run(repo, "next")).exitCode).toBe(0)
|
|
829
840
|
})
|
|
830
841
|
|
|
@@ -832,7 +843,7 @@ describe("a history written in a format this gtd does not read", () => {
|
|
|
832
843
|
const repo = seed(IDLE_THEN_WORKING)
|
|
833
844
|
const base = repo.resolveRef("HEAD")!
|
|
834
845
|
repo.writeFile("NOTE.md", "a note\n")
|
|
835
|
-
repo.seedCommit("gtd(human): idle → working\n\nGtd-Step: idle#1\nGtd-Format:
|
|
846
|
+
repo.seedCommit("gtd(human): idle → working\n\nGtd-Step: idle#1\nGtd-Format: 3")
|
|
836
847
|
repo.updateRef(HISTORY_REF, repo.resolveRef("HEAD")!)
|
|
837
848
|
repo.hardResetTo(base)
|
|
838
849
|
const { exitCode, stderr } = await run(repo, "restore")
|
|
@@ -1390,10 +1401,10 @@ export default async () => {
|
|
|
1390
1401
|
expect(await nextOf(repo)).toEqual({ target: "accepted" })
|
|
1391
1402
|
})
|
|
1392
1403
|
|
|
1393
|
-
it("a turn that finishes
|
|
1404
|
+
it("a turn that finishes a pinned flow targets the unpin", async () => {
|
|
1394
1405
|
const repo = await atWorking()
|
|
1395
1406
|
repo.writeFile("REVIEW.md", "an updated review\n")
|
|
1396
|
-
expect(await nextOf(repo)).toMatchObject({ target: "
|
|
1407
|
+
expect(await nextOf(repo)).toMatchObject({ target: "unpin" })
|
|
1397
1408
|
})
|
|
1398
1409
|
|
|
1399
1410
|
it("plain gtd next shows the same preview as a `Next:` line", async () => {
|
|
@@ -1476,7 +1487,7 @@ export default async () => {
|
|
|
1476
1487
|
expect((JSON.parse(stdout) as { readonly script: string }).script).toContain("git commit")
|
|
1477
1488
|
})
|
|
1478
1489
|
|
|
1479
|
-
it("a clean run that finishes
|
|
1490
|
+
it("a clean run that finishes a pinned flow lands an ordinary commit at the unpin", async () => {
|
|
1480
1491
|
const repo = await atChecking(REENTRY_WORKFLOW)
|
|
1481
1492
|
const { stdout, exitCode } = await run(repo, "land", "--json")
|
|
1482
1493
|
expect(exitCode).toBe(0)
|
|
@@ -1485,7 +1496,7 @@ export default async () => {
|
|
|
1485
1496
|
expect(script).not.toContain("git reset --mixed")
|
|
1486
1497
|
expect(script).not.toContain("nothing to retain")
|
|
1487
1498
|
expect(applyEmittedScript(repo, new Map(), script).ok).toBe(true)
|
|
1488
|
-
expect(repo.lastCommitSubject()).toBe("gtd(check): checking →
|
|
1499
|
+
expect(repo.lastCommitSubject()).toBe("gtd(check): checking → unpin")
|
|
1489
1500
|
})
|
|
1490
1501
|
|
|
1491
1502
|
it("the same rest with a pending change lands an ordinary commit too", async () => {
|
|
@@ -1523,7 +1534,7 @@ export default async () => {
|
|
|
1523
1534
|
expect(parsed.script).toContain("nothing to do")
|
|
1524
1535
|
})
|
|
1525
1536
|
|
|
1526
|
-
it("gtd land --json a decision that
|
|
1537
|
+
it("gtd land --json a decision that ends a pinned flow reports settled:false, idle:false, state:unpin", async () => {
|
|
1527
1538
|
const repo = await atChecking(REENTRY_WORKFLOW)
|
|
1528
1539
|
const { stdout, exitCode } = await run(repo, "land", "--json")
|
|
1529
1540
|
expect(exitCode).toBe(0)
|
|
@@ -1533,8 +1544,8 @@ export default async () => {
|
|
|
1533
1544
|
readonly state: string
|
|
1534
1545
|
}
|
|
1535
1546
|
expect(parsed.settled).toBe(false)
|
|
1536
|
-
expect(parsed.idle).toBe(
|
|
1537
|
-
expect(parsed.state).toBe("
|
|
1547
|
+
expect(parsed.idle).toBe(false)
|
|
1548
|
+
expect(parsed.state).toBe("unpin")
|
|
1538
1549
|
})
|
|
1539
1550
|
|
|
1540
1551
|
it("gtd land --json an ordinary commit reports settled:false", async () => {
|
|
@@ -1683,7 +1694,7 @@ export default async () => {
|
|
|
1683
1694
|
const { exitCode, script } = await landAndApply(repo)
|
|
1684
1695
|
expect(exitCode).toBe(0)
|
|
1685
1696
|
expect(script).not.toContain("fmt-notes")
|
|
1686
|
-
expect(repo.lastCommitSubject()).toBe("gtd(agent): drafting →
|
|
1697
|
+
expect(repo.lastCommitSubject("HEAD~1")).toBe("gtd(agent): drafting → unpin")
|
|
1687
1698
|
})
|
|
1688
1699
|
|
|
1689
1700
|
it("`gtd next --json`'s `validate` field carries the mode's format command", async () => {
|
|
@@ -1867,7 +1878,7 @@ export default workflow(
|
|
|
1867
1878
|
it("writes the rendered summary, with a trailing newline, counting the human turns after the entry commit", async () => {
|
|
1868
1879
|
const repo = await reviewed()
|
|
1869
1880
|
const history = repo.commitHistory()
|
|
1870
|
-
const entryHash = history[history.length -
|
|
1881
|
+
const entryHash = history[history.length - 4]!.hash
|
|
1871
1882
|
const tipHash = history[history.length - 1]!.hash
|
|
1872
1883
|
|
|
1873
1884
|
const { exitCode, stdout } = await run(repo, "summary")
|
|
@@ -1961,7 +1972,7 @@ export default async () => {
|
|
|
1961
1972
|
it("refuses once the process has closed and HEAD rests at the initial step again", async () => {
|
|
1962
1973
|
const repo = await atDeciding()
|
|
1963
1974
|
await landTurn(repo)
|
|
1964
|
-
expect(repo.lastCommitSubject()).toBe("gtd(
|
|
1975
|
+
expect(repo.lastCommitSubject()).toBe("gtd(check): unpin → idle")
|
|
1965
1976
|
|
|
1966
1977
|
const { exitCode, stdout, stderr } = await run(repo, "base")
|
|
1967
1978
|
expect(exitCode).toBe(1)
|
package/src/cli/Program.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
type StatusChange,
|
|
48
48
|
selectPath,
|
|
49
49
|
EXIT_OK,
|
|
50
|
+
PIN_PATH,
|
|
50
51
|
} from "../wire/index.js"
|
|
51
52
|
import {
|
|
52
53
|
currentRest,
|
|
@@ -80,6 +81,7 @@ import {
|
|
|
80
81
|
deleteRef,
|
|
81
82
|
hardResetTo,
|
|
82
83
|
mixedResetTo,
|
|
84
|
+
unpinScript,
|
|
83
85
|
ScriptSurface,
|
|
84
86
|
updateRef,
|
|
85
87
|
type RunnableScript,
|
|
@@ -799,6 +801,7 @@ const runAbandonCommand = (
|
|
|
799
801
|
const steps: EmitStep[] = [
|
|
800
802
|
{ kind: "gitWrite", command: updateRef(HISTORY_REF, tip) },
|
|
801
803
|
{ kind: "gitWrite", command: mixedResetTo(run.startParentHash) },
|
|
804
|
+
{ kind: "gitWrite", command: unpinScript(PIN_PATH) },
|
|
802
805
|
{ kind: "outcome", command: abandonedOutcome(restState, run.startParentHash, initial) },
|
|
803
806
|
]
|
|
804
807
|
const required = emitScripts(steps).required
|
|
@@ -1060,7 +1063,7 @@ const runCodeThreadsCheckCommand = (): Effect.Effect<void, Error, CommandRequire
|
|
|
1060
1063
|
const workspace = yield* Workspace
|
|
1061
1064
|
const changed = yield* git.changedPaths(rest.run.diffBase)
|
|
1062
1065
|
const lines = changed
|
|
1063
|
-
.filter((c) => c.status !== "D")
|
|
1066
|
+
.filter((c) => c.status !== "D" && c.path !== PIN_PATH)
|
|
1064
1067
|
.flatMap(({ path }) => {
|
|
1065
1068
|
const { threads, findings } = parseCodeThreads(path, workspace.atPath(path) ?? "")
|
|
1066
1069
|
return [
|
package/src/cli/Strays.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
2
|
import { GitService, GtdError, Narrator, type GitOperations } from "../platform/index.js"
|
|
3
3
|
import { strayPaths } from "../step/index.js"
|
|
4
|
-
import { EXIT_OK, EXIT_STRAYS_FOUND } from "../wire/index.js"
|
|
4
|
+
import { EXIT_OK, EXIT_STRAYS_FOUND, PIN_PATH } from "../wire/index.js"
|
|
5
5
|
import {
|
|
6
6
|
SelectorUsageError,
|
|
7
7
|
writeSelection,
|
|
@@ -31,7 +31,8 @@ export const runStraysCommand = (
|
|
|
31
31
|
const narrator = yield* Narrator
|
|
32
32
|
const base = yield* resolveSide(git, command.base)
|
|
33
33
|
const tip = yield* resolveSide(git, command.tip)
|
|
34
|
-
|
|
34
|
+
// The pin is the engine's, never a turn's, so it is never a stray either.
|
|
35
|
+
const touched = (yield* git.diffPaths(base, tip)).filter((path) => path !== PIN_PATH)
|
|
35
36
|
const strays = strayPaths(touched, command.globs)
|
|
36
37
|
const total = touched.length
|
|
37
38
|
|
package/src/edge/Edge.test.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
applyEmittedScript,
|
|
31
31
|
makeCapturingCliIo,
|
|
32
32
|
makeInMemoryWorkspaceOps,
|
|
33
|
+
takePinBeat,
|
|
33
34
|
testLayers,
|
|
34
35
|
} from "../twins/index.js"
|
|
35
36
|
|
|
@@ -74,7 +75,7 @@ const write = (repo: InMemRepo, files: Files): void => {
|
|
|
74
75
|
for (const [path, content] of Object.entries(files)) repo.writeFile(path, content)
|
|
75
76
|
}
|
|
76
77
|
|
|
77
|
-
const headOf = (repo: InMemRepo): string => repo.resolveRef(
|
|
78
|
+
const headOf = (repo: InMemRepo, ref = "HEAD"): string => repo.resolveRef(ref)!
|
|
78
79
|
|
|
79
80
|
const repoWith = (config: string, files: Files = {}): InMemRepo => {
|
|
80
81
|
const repo = new InMemRepo()
|
|
@@ -95,19 +96,27 @@ const applied = (repo: InMemRepo, script: string): string => {
|
|
|
95
96
|
return headOf(repo)
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
/**
|
|
99
|
+
/**
|
|
100
|
+
* Lands the pending turn the way a driver does — `gtd land --json`, then its
|
|
101
|
+
* script, then the pin or unpin beat it leads to — and returns the turn's
|
|
102
|
+
* commit, or the closing commit when the turn led to the unpin.
|
|
103
|
+
*/
|
|
99
104
|
const land = async (repo: InMemRepo, files: Files = {}): Promise<string> => {
|
|
100
105
|
write(repo, files)
|
|
101
106
|
const { stdout, stderr, exitCode } = await cli(repo, "land", "--json")
|
|
102
107
|
if (exitCode !== 0) throw new Error(`gtd land exited ${exitCode}: ${stderr}${stdout}`)
|
|
103
|
-
|
|
108
|
+
const turn = applied(repo, (JSON.parse(stdout) as { readonly script: string }).script)
|
|
109
|
+
await takePinBeat(repo, "add")
|
|
110
|
+
return (await takePinBeat(repo, "remove")) ? headOf(repo) : turn
|
|
104
111
|
}
|
|
105
112
|
|
|
106
113
|
const enter = async (repo: InMemRepo, workflow: string, ...vars: string[]): Promise<string> => {
|
|
107
114
|
const flags = vars.flatMap((v) => ["--var", v])
|
|
108
115
|
const { stdout, stderr, exitCode } = await cli(repo, "start", workflow, ...flags)
|
|
109
116
|
if (exitCode !== 0) throw new Error(`gtd start exited ${exitCode}: ${stderr}`)
|
|
110
|
-
|
|
117
|
+
const opening = applied(repo, stdout)
|
|
118
|
+
await takePinBeat(repo, "add")
|
|
119
|
+
return opening
|
|
111
120
|
}
|
|
112
121
|
|
|
113
122
|
/** Commits an exact message — for trailers no landing writes on its own. */
|
|
@@ -174,7 +183,11 @@ describe("currentRun", () => {
|
|
|
174
183
|
{ state: "checking", hash: checking, actor: "agent" },
|
|
175
184
|
])
|
|
176
185
|
expect(run.episode.base).toBe(boundary)
|
|
177
|
-
expect(run.episode.commits.map((c) => c.
|
|
186
|
+
expect(run.episode.commits.map((c) => c.message.split("\n")[0])).toEqual([
|
|
187
|
+
"gtd(human): idle → building",
|
|
188
|
+
"gtd(check): pin → building",
|
|
189
|
+
"gtd(agent): building → checking",
|
|
190
|
+
])
|
|
178
191
|
})
|
|
179
192
|
|
|
180
193
|
it("a non-gtd commit on top of a process bounds a fresh episode", async () => {
|
|
@@ -192,7 +205,7 @@ describe("currentRun", () => {
|
|
|
192
205
|
await land(repo, { "a.txt": "a\n" })
|
|
193
206
|
await land(repo, { "b.txt": "b\n" })
|
|
194
207
|
const closing = await land(repo, { "c.txt": "c\n" })
|
|
195
|
-
expect(repo.lastCommitSubject()).toBe("gtd(
|
|
208
|
+
expect(repo.lastCommitSubject()).toBe("gtd(check): unpin → idle")
|
|
196
209
|
const next = await land(repo, { "d.txt": "d\n" })
|
|
197
210
|
const run = await provide(currentRun, repo)
|
|
198
211
|
expect(run.startParentHash).toBe(closing)
|
|
@@ -203,8 +216,8 @@ describe("currentRun", () => {
|
|
|
203
216
|
const repo = repoWith(LINEAR)
|
|
204
217
|
const before = headOf(repo)
|
|
205
218
|
const opening = await enter(repo, "side")
|
|
206
|
-
expect(repo.lastCommitMessage()).toBe(
|
|
207
|
-
`gtd(human): fixing\n\nGtd-Workflow: side\nGtd-Var: base=\nGtd-Var: reviewer=nobody\nGtd-Config: ${contentHash(LINEAR)} gtd.config.ts\nGtd-Format:
|
|
219
|
+
expect(repo.lastCommitMessage(opening)).toBe(
|
|
220
|
+
`gtd(human): fixing\n\nGtd-Workflow: side\nGtd-Var: base=\nGtd-Var: reviewer=nobody\nGtd-Config: ${contentHash(LINEAR)} gtd.config.ts\nGtd-Format: 2`,
|
|
208
221
|
)
|
|
209
222
|
const fixed = await land(repo, { "fix.txt": "x\n" })
|
|
210
223
|
const run = await provide(currentRun, repo)
|
|
@@ -216,7 +229,7 @@ describe("currentRun", () => {
|
|
|
216
229
|
{ state: "tidying", hash: fixed, actor: "agent" },
|
|
217
230
|
])
|
|
218
231
|
expect(run.episode.base).toBe(opening)
|
|
219
|
-
expect(run.episode.commits.map((c) => c.hash)).toEqual([fixed])
|
|
232
|
+
expect(run.episode.commits.map((c) => c.hash)).toEqual([headOf(repo, `${fixed}~1`), fixed])
|
|
220
233
|
})
|
|
221
234
|
|
|
222
235
|
it("a workflow starting at the default's initial step still opens its own episode, not the empty tree", async () => {
|
|
@@ -236,8 +249,8 @@ export const again = workflow(async () => {
|
|
|
236
249
|
const repo = repoWith(STARTS_AT_INITIAL)
|
|
237
250
|
const before = headOf(repo)
|
|
238
251
|
const opening = await enter(repo, "again")
|
|
239
|
-
expect(repo.lastCommitMessage()).toBe(
|
|
240
|
-
`gtd(human): idle\n\nGtd-Workflow: again\nGtd-Config: ${contentHash(STARTS_AT_INITIAL)} gtd.config.ts\nGtd-Format:
|
|
252
|
+
expect(repo.lastCommitMessage(opening)).toBe(
|
|
253
|
+
`gtd(human): idle\n\nGtd-Workflow: again\nGtd-Config: ${contentHash(STARTS_AT_INITIAL)} gtd.config.ts\nGtd-Format: 2`,
|
|
241
254
|
)
|
|
242
255
|
const fixed = await land(repo, { "fix.txt": "x\n" })
|
|
243
256
|
const run = await provide(currentRun, repo)
|
|
@@ -374,15 +387,23 @@ export const again = workflow(async () => {
|
|
|
374
387
|
it("describes a step commit that changed something", async () => {
|
|
375
388
|
const repo = repoWith(LINEAR)
|
|
376
389
|
await land(repo, { "a.txt": "a\n" })
|
|
390
|
+
await land(repo, { "b.txt": "b\n" })
|
|
377
391
|
const run = await provide(currentRun, repo)
|
|
378
|
-
expect(run.headTurn).toEqual({ state: "
|
|
392
|
+
expect(run.headTurn).toEqual({ state: "checking", actor: "agent", empty: false, step: true })
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it("is undefined at a pin commit, which is no turn", async () => {
|
|
396
|
+
const repo = repoWith(LINEAR)
|
|
397
|
+
await land(repo, { "a.txt": "a\n" })
|
|
398
|
+
expect(repo.lastCommitSubject()).toBe("gtd(check): pin → building")
|
|
399
|
+
expect((await provide(currentRun, repo)).headTurn).toBeUndefined()
|
|
379
400
|
})
|
|
380
401
|
|
|
381
402
|
it("describes an attempt: a step-less, empty commit", async () => {
|
|
382
403
|
const repo = repoWith(LINEAR)
|
|
383
404
|
await land(repo, { "a.txt": "a\n" })
|
|
384
405
|
await land(repo)
|
|
385
|
-
expect(repo.lastCommitMessage()).toBe("gtd(agent): building\n\nGtd-Format:
|
|
406
|
+
expect(repo.lastCommitMessage()).toBe("gtd(agent): building\n\nGtd-Format: 2")
|
|
386
407
|
const run = await provide(currentRun, repo)
|
|
387
408
|
expect(run.headTurn).toEqual({ state: "building", actor: "agent", empty: true, step: false })
|
|
388
409
|
})
|
|
@@ -523,7 +544,8 @@ describe("summaryRun", () => {
|
|
|
523
544
|
expect(summary.trace).toEqual([
|
|
524
545
|
{ state: "building", hash: building, actor: "human" },
|
|
525
546
|
{ state: "checking", hash: checking, actor: "agent" },
|
|
526
|
-
{ state: "
|
|
547
|
+
{ state: "unpin", hash: headOf(repo, `${closing}~1`), actor: "agent" },
|
|
548
|
+
{ state: "idle", hash: closing, actor: "check" },
|
|
527
549
|
])
|
|
528
550
|
})
|
|
529
551
|
|
|
@@ -635,7 +657,9 @@ export default workflow(async () => {
|
|
|
635
657
|
`--model=${model}`,
|
|
636
658
|
)
|
|
637
659
|
if (exitCode !== 0) throw new Error(`gtd land exited ${exitCode}: ${stderr}${stdout}`)
|
|
638
|
-
|
|
660
|
+
const turn = applied(repo, (JSON.parse(stdout) as { readonly script: string }).script)
|
|
661
|
+
await takePinBeat(repo, "add")
|
|
662
|
+
return (await takePinBeat(repo, "remove")) ? headOf(repo) : turn
|
|
639
663
|
}
|
|
640
664
|
type Rendered = {
|
|
641
665
|
entryCommit: string
|
|
@@ -711,6 +735,7 @@ describe("restAt", () => {
|
|
|
711
735
|
)
|
|
712
736
|
expect(exitCode).toBe(0)
|
|
713
737
|
await applied(repo, (JSON.parse(stdout) as { readonly script: string }).script)
|
|
738
|
+
await takePinBeat(repo, "add")
|
|
714
739
|
}
|
|
715
740
|
const { cost } = await provide(currentRest, repo)
|
|
716
741
|
expect(cost.total).toBe(11)
|
|
@@ -806,6 +831,7 @@ export const review = workflow(async () => {
|
|
|
806
831
|
it("is the base the resting step names", async () => {
|
|
807
832
|
const repo = repoWith(LOOP)
|
|
808
833
|
const first = await land(repo, { "a.txt": "a\n" })
|
|
834
|
+
expect(repo.lastCommitSubject()).toBe("gtd(check): pin → checkpoint")
|
|
809
835
|
expect(reviewBaseFor(await provide(currentRest, repo))).toBe(first)
|
|
810
836
|
await land(repo, { "b.txt": "b\n" })
|
|
811
837
|
expect(reviewBaseFor(await provide(currentRest, repo))).toBe(first)
|
|
@@ -1043,8 +1069,8 @@ export default workflow(async () => {
|
|
|
1043
1069
|
|
|
1044
1070
|
it("an ordinary start's first commit pins every process setting, sorted by name", async () => {
|
|
1045
1071
|
const repo = repoWith(LINEAR)
|
|
1046
|
-
await land(repo, { "a.txt": "a\n" })
|
|
1047
|
-
expect(repo.lastCommitMessage()).toContain(
|
|
1072
|
+
const start = await land(repo, { "a.txt": "a\n" })
|
|
1073
|
+
expect(repo.lastCommitMessage(start)).toContain(
|
|
1048
1074
|
"\n\nGtd-Step: idle#1\nGtd-Var: base=\nGtd-Var: reviewer=nobody",
|
|
1049
1075
|
)
|
|
1050
1076
|
await land(repo)
|
|
@@ -1265,13 +1291,34 @@ describe("finishing landings keep history", () => {
|
|
|
1265
1291
|
return landing
|
|
1266
1292
|
}
|
|
1267
1293
|
|
|
1294
|
+
/** Lands the turn that ends the flow, and runs the unpin beat's script without landing it. */
|
|
1295
|
+
const toUnpin = async (repo: InMemRepo, files: Files) => {
|
|
1296
|
+
write(repo, files)
|
|
1297
|
+
const turn = await cli(repo, "land", "--json")
|
|
1298
|
+
applied(repo, (JSON.parse(turn.stdout) as { readonly script: string }).script)
|
|
1299
|
+
const beat = await cli(repo, "next", "--json")
|
|
1300
|
+
applied(repo, (JSON.parse(beat.stdout) as { readonly content: string }).content)
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
it("a pinned process's last turn lands at the unpin, keeping no history yet", async () => {
|
|
1304
|
+
const repo = repoWith(LINEAR)
|
|
1305
|
+
await land(repo, { "a.txt": "a\n" })
|
|
1306
|
+
await land(repo, { "b.txt": "b\n" })
|
|
1307
|
+
repo.writeFile("c.txt", "c\n")
|
|
1308
|
+
const landing = await landingOf(repo)
|
|
1309
|
+
expect(landing.to).toBe("unpin")
|
|
1310
|
+
expect(landing.keepHistory).toBeUndefined()
|
|
1311
|
+
expect(landing.spec.version).toBeUndefined()
|
|
1312
|
+
})
|
|
1313
|
+
|
|
1268
1314
|
it("flow return: keeps refs/gtd/history/<startHash>, records version and branch", async () => {
|
|
1269
1315
|
const repo = repoWith(LINEAR)
|
|
1270
1316
|
const building = await land(repo, { "a.txt": "a\n" })
|
|
1271
1317
|
await land(repo, { "b.txt": "b\n" })
|
|
1272
|
-
repo
|
|
1318
|
+
await toUnpin(repo, { "c.txt": "c\n" })
|
|
1273
1319
|
const landing = await landingOf(repo)
|
|
1274
1320
|
expect(landing.to).toBe("idle")
|
|
1321
|
+
expect(landing.spec.pin).toBe("remove")
|
|
1275
1322
|
expect(landing.keepHistory).toBe(processHistoryRef(building))
|
|
1276
1323
|
expect(landing.spec.version).toBe(GTD_VERSION)
|
|
1277
1324
|
expect(landing.spec.branch).toBe(repo.branchName())
|
|
@@ -1287,7 +1334,7 @@ export default async () => {
|
|
|
1287
1334
|
}
|
|
1288
1335
|
`)
|
|
1289
1336
|
const building = await land(repo, { "a.txt": "a\n" })
|
|
1290
|
-
repo
|
|
1337
|
+
await toUnpin(repo, { "b.txt": "b\n" })
|
|
1291
1338
|
const landing = await landingOf(repo)
|
|
1292
1339
|
expect(landing.to).toBe("idle")
|
|
1293
1340
|
expect(landing.keepHistory).toBe(processHistoryRef(building))
|
|
@@ -1314,7 +1361,7 @@ export default async () => {
|
|
|
1314
1361
|
const repo = repoWith(LINEAR)
|
|
1315
1362
|
const building = await land(repo, { "a.txt": "a\n" })
|
|
1316
1363
|
await land(repo, { "b.txt": "b\n" })
|
|
1317
|
-
repo
|
|
1364
|
+
await toUnpin(repo, { "c.txt": "c\n" })
|
|
1318
1365
|
const detached: GitOperations = {
|
|
1319
1366
|
...fakeGitOperations(repo),
|
|
1320
1367
|
currentBranch: () => Effect.succeed(Option.none()),
|
|
@@ -1338,7 +1385,7 @@ export const side = workflow(async () => {
|
|
|
1338
1385
|
})
|
|
1339
1386
|
`)
|
|
1340
1387
|
const opening = await enter(repo, "side")
|
|
1341
|
-
repo
|
|
1388
|
+
await toUnpin(repo, { "a.txt": "a\n" })
|
|
1342
1389
|
const landing = await landingOf(repo)
|
|
1343
1390
|
expect(landing.to).toBe("idle")
|
|
1344
1391
|
expect(landing.keepHistory).toBe(processHistoryRef(opening))
|
|
@@ -1468,14 +1515,20 @@ describe("historyTrail", () => {
|
|
|
1468
1515
|
const trail = await provide(historyTrail(tip), repo)
|
|
1469
1516
|
expect(trail.tip).toBe(tip)
|
|
1470
1517
|
expect(trail.ended).toBe("return")
|
|
1471
|
-
expect(trail.steps.map((s) => s.hash)).toEqual([
|
|
1518
|
+
expect(trail.steps.map((s) => s.hash)).toEqual([
|
|
1519
|
+
building,
|
|
1520
|
+
checking,
|
|
1521
|
+
headOf(repo, `${tip}~1`),
|
|
1522
|
+
tip,
|
|
1523
|
+
])
|
|
1472
1524
|
expect(trail.steps[2]).toMatchObject({
|
|
1473
|
-
subject: "gtd(agent): checking →
|
|
1525
|
+
subject: "gtd(agent): checking → unpin",
|
|
1474
1526
|
actor: "agent",
|
|
1475
1527
|
from: "checking",
|
|
1476
|
-
to: "
|
|
1528
|
+
to: "unpin",
|
|
1477
1529
|
verdicts: [],
|
|
1478
1530
|
})
|
|
1531
|
+
expect(trail.steps[3]).toMatchObject({ subject: "gtd(check): unpin → idle", actor: "check" })
|
|
1479
1532
|
expect(trail.steps[0]).toMatchObject({ actor: "human", to: "building" })
|
|
1480
1533
|
})
|
|
1481
1534
|
|
|
@@ -1628,7 +1681,7 @@ export default async () => {
|
|
|
1628
1681
|
)
|
|
1629
1682
|
}).pipe(Effect.provide(testLayers(repo, {}))),
|
|
1630
1683
|
)
|
|
1631
|
-
expect(trail.steps).toHaveLength(
|
|
1684
|
+
expect(trail.steps).toHaveLength(4)
|
|
1632
1685
|
expect(calls).toBe(1)
|
|
1633
1686
|
})
|
|
1634
1687
|
|