@pmelab/gtd 62.0.0 → 63.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 +14 -8
- package/dist/chunks/{testkit-XQeYCNMn.mjs → testkit-DwypA7cH.mjs} +183 -28
- package/dist/gtd.bundle.mjs +182 -26
- package/dist/gtd.core.mjs +80 -77
- package/dist/testkit/gherkin.mjs +1 -1
- package/dist/testkit.mjs +1 -1
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +2 -0
- package/src/cli/Cli.ts +12 -0
- package/src/cli/Launch.ts +1 -0
- package/src/cli/Program.test.ts +9 -7
- package/src/cli/Program.ts +51 -0
- package/src/edge/Edge.test.ts +3 -3
- package/src/edge/Edge.ts +68 -3
- package/src/edge/index.ts +1 -0
- package/src/emit/GitScript.ts +7 -0
- package/src/emit/index.ts +1 -0
- package/src/replay/Format.test.ts +26 -7
- package/src/replay/Format.ts +3 -2
- package/src/replay/Replay.test.ts +87 -0
- package/src/replay/Replay.ts +111 -23
- package/src/replay/Trailers.test.ts +10 -10
- package/src/replay/Trailers.ts +9 -0
- package/src/replay/index.ts +1 -0
- package/src/step/planStart.test.ts +2 -2
- package/src/step/planStep.test.ts +4 -4
- package/src/twins/EmittedScriptRecognizer.ts +3 -2
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-DwypA7cH.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-DwypA7cH.mjs";
|
|
3
3
|
export { inTurn, installContext, runsItsScript, testRepo, verdict, verdictWhen, whenPrompted };
|
package/package.json
CHANGED
package/src/cli/Cli.test.ts
CHANGED
|
@@ -114,6 +114,7 @@ describe("parseArgv — scope", () => {
|
|
|
114
114
|
["validate", "--json"],
|
|
115
115
|
["abandon", "--json"],
|
|
116
116
|
["restore", "--json"],
|
|
117
|
+
["repin", "--json"],
|
|
117
118
|
["start", "--json"],
|
|
118
119
|
["start", "fix", "--json"],
|
|
119
120
|
["history", "fetch", "--json"],
|
|
@@ -1116,6 +1117,7 @@ describe("standaloneKinds / needsOf", () => {
|
|
|
1116
1117
|
"start",
|
|
1117
1118
|
"abandon",
|
|
1118
1119
|
"restore",
|
|
1120
|
+
"repin",
|
|
1119
1121
|
"next",
|
|
1120
1122
|
"validate",
|
|
1121
1123
|
"ui",
|
package/src/cli/Cli.ts
CHANGED
|
@@ -131,6 +131,7 @@ const SCRIPT_KINDS: ReadonlySet<Command["kind"]> = new Set<Command["kind"]>([
|
|
|
131
131
|
"start",
|
|
132
132
|
"abandon",
|
|
133
133
|
"restore",
|
|
134
|
+
"repin",
|
|
134
135
|
"validate",
|
|
135
136
|
"historyFetch",
|
|
136
137
|
])
|
|
@@ -448,6 +449,16 @@ With no <name>, list every workflow and door it can start. An unknown <name>, th
|
|
|
448
449
|
summary: "Bring back the last abandoned process",
|
|
449
450
|
details: `Bring back the process the last \`gtd abandon\` stopped: the printed script resets HEAD to that process's last commit. Refuses on a dirty working tree, when nothing was abandoned, or when commits made since would be lost.`,
|
|
450
451
|
},
|
|
452
|
+
{
|
|
453
|
+
token: "repin",
|
|
454
|
+
kind: "repin",
|
|
455
|
+
arity: "none",
|
|
456
|
+
group: "Start and stop",
|
|
457
|
+
summary: "Pin the process to the live gtd and workflow",
|
|
458
|
+
details: `Switch the process underway to the live engine and workflow, the way out when the workflow needs a fix mid-process: the printed script runs the pin beat again, writing \`gtd bundle\`'s output to .gtd/process.gtdpin and committing that file alone. Always runs on the gtd on PATH, never on the pin. When the live workflow no longer replays the process, the commit continues from the divergence: a Gtd-Anchor trailer names the last commit it replays, the later commits stay as files only, and the process rests at the step the live workflow reached there. Refuses when no process is underway, when the next beat is a pin or unpin beat already, when an unpinned process diverges, or when the live workflow would continue at the default workflow's first step.
|
|
459
|
+
|
|
460
|
+
s=$(gtd repin --json=script) && sh -c "$s"`,
|
|
461
|
+
},
|
|
451
462
|
{
|
|
452
463
|
token: "summary",
|
|
453
464
|
kind: "summary",
|
|
@@ -1306,6 +1317,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
|
|
|
1306
1317
|
| "lsp"
|
|
1307
1318
|
| "abandon"
|
|
1308
1319
|
| "restore"
|
|
1320
|
+
| "repin"
|
|
1309
1321
|
| "next"
|
|
1310
1322
|
| "validate"
|
|
1311
1323
|
| "guide"
|
package/src/cli/Launch.ts
CHANGED
package/src/cli/Program.test.ts
CHANGED
|
@@ -110,7 +110,7 @@ export const hotfix = workflow(
|
|
|
110
110
|
expect(exitCode).toBe(0)
|
|
111
111
|
expect(stdout).toContain(
|
|
112
112
|
commitAll(
|
|
113
|
-
`gtd(human): working\n\nGtd-Workflow: hotfix\nGtd-Var: greeting=hello\nGtd-Format:
|
|
113
|
+
`gtd(human): working\n\nGtd-Workflow: hotfix\nGtd-Var: greeting=hello\nGtd-Format: 3`,
|
|
114
114
|
),
|
|
115
115
|
)
|
|
116
116
|
expect(repo.commitHistory()).toHaveLength(before)
|
|
@@ -791,14 +791,14 @@ describe("gtd next — refuses when history diverges from what replay reaches",
|
|
|
791
791
|
|
|
792
792
|
describe("a history written in a format this gtd does not read", () => {
|
|
793
793
|
// The exact wording is pinned in Format.test.ts; this is the part that proves the format gate fired.
|
|
794
|
-
const FOREIGN = "is written in history format
|
|
794
|
+
const FOREIGN = "is written in history format 4, but this gtd reads formats 1 to 3"
|
|
795
795
|
|
|
796
796
|
/** A step commit that would also diverge, so only the format gate can explain the refusal. */
|
|
797
797
|
const foreignStep = async (): Promise<InMemRepo> => {
|
|
798
798
|
const repo = seed(IDLE_THEN_WORKING)
|
|
799
799
|
await landTurn(repo, { "NOTE.md": "a note\n" })
|
|
800
800
|
repo.writeFile("WORK.md", "done\n")
|
|
801
|
-
repo.seedCommit("gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1\nGtd-Format:
|
|
801
|
+
repo.seedCommit("gtd(agent): elsewhere → review\n\nGtd-Step: elsewhere#1\nGtd-Format: 4")
|
|
802
802
|
return repo
|
|
803
803
|
}
|
|
804
804
|
|
|
@@ -823,7 +823,7 @@ describe("a history written in a format this gtd does not read", () => {
|
|
|
823
823
|
it("refuses even when a foreign subject hides the commit from the episode", async () => {
|
|
824
824
|
const repo = seed(IDLE_THEN_WORKING)
|
|
825
825
|
repo.writeFile("NOTE.md", "a note\n")
|
|
826
|
-
repo.seedCommit("gtd2[human] idle -> working\n\nGtd-Format:
|
|
826
|
+
repo.seedCommit("gtd2[human] idle -> working\n\nGtd-Format: 4")
|
|
827
827
|
for (const command of ["next", "abandon"]) {
|
|
828
828
|
const { exitCode, stderr } = await run(repo, command)
|
|
829
829
|
expect(exitCode).toBe(1)
|
|
@@ -834,7 +834,7 @@ describe("a history written in a format this gtd does not read", () => {
|
|
|
834
834
|
it("a commit gtd never wrote is not read for a format, even quoting the trailer", async () => {
|
|
835
835
|
const repo = seed(IDLE_THEN_WORKING)
|
|
836
836
|
repo.writeFile("NOTE.md", "a note\n")
|
|
837
|
-
repo.seedCommit("feat: x\n\nGtd-Format:
|
|
837
|
+
repo.seedCommit("feat: x\n\nGtd-Format: 4")
|
|
838
838
|
expect((await run(repo, "next")).exitCode).toBe(0)
|
|
839
839
|
})
|
|
840
840
|
|
|
@@ -842,7 +842,7 @@ describe("a history written in a format this gtd does not read", () => {
|
|
|
842
842
|
const repo = seed(IDLE_THEN_WORKING)
|
|
843
843
|
const base = repo.resolveRef("HEAD")!
|
|
844
844
|
repo.writeFile("NOTE.md", "a note\n")
|
|
845
|
-
repo.seedCommit("gtd(human): idle → working\n\nGtd-Step: idle#1\nGtd-Format:
|
|
845
|
+
repo.seedCommit("gtd(human): idle → working\n\nGtd-Step: idle#1\nGtd-Format: 4")
|
|
846
846
|
repo.updateRef(HISTORY_REF, repo.resolveRef("HEAD")!)
|
|
847
847
|
repo.hardResetTo(base)
|
|
848
848
|
const { exitCode, stderr } = await run(repo, "restore")
|
|
@@ -1717,6 +1717,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
|
|
|
1717
1717
|
start: { kind: "start", name: "fix", args: [], vars: {} },
|
|
1718
1718
|
abandon: { kind: "abandon" },
|
|
1719
1719
|
restore: { kind: "restore" },
|
|
1720
|
+
repin: { kind: "repin" },
|
|
1720
1721
|
next: { kind: "next" },
|
|
1721
1722
|
validate: { kind: "validate" },
|
|
1722
1723
|
check: { kind: "check", mode: "qa", file: ".gtd/TODO.md" },
|
|
@@ -1743,7 +1744,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
|
|
|
1743
1744
|
const NO_COMMITS_MESSAGE =
|
|
1744
1745
|
"gtd: the repository has no commits yet — make an initial commit, then run gtd again"
|
|
1745
1746
|
|
|
1746
|
-
it("derives exactly the
|
|
1747
|
+
it("derives exactly the eighteen non-standalone kinds — a canary for the table-driven cases below", () => {
|
|
1747
1748
|
expect(stateKinds.sort()).toEqual(
|
|
1748
1749
|
[
|
|
1749
1750
|
"abandon",
|
|
@@ -1753,6 +1754,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
|
|
|
1753
1754
|
"land",
|
|
1754
1755
|
"next",
|
|
1755
1756
|
"restore",
|
|
1757
|
+
"repin",
|
|
1756
1758
|
"summary",
|
|
1757
1759
|
"validate",
|
|
1758
1760
|
"ui",
|
package/src/cli/Program.ts
CHANGED
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
} from "../wire/index.js"
|
|
52
52
|
import {
|
|
53
53
|
currentRest,
|
|
54
|
+
repinAnchor,
|
|
54
55
|
startRefusal,
|
|
55
56
|
currentRun,
|
|
56
57
|
renderRest,
|
|
@@ -65,6 +66,7 @@ import {
|
|
|
65
66
|
summaryRun,
|
|
66
67
|
type RenderedRest,
|
|
67
68
|
} from "../edge/index.js"
|
|
69
|
+
import { formatCommitMessage, PIN_STATES } from "../replay/index.js"
|
|
68
70
|
import { planStart, planStep as planStepPure, type JudgeVerdict } from "../step/index.js"
|
|
69
71
|
import {
|
|
70
72
|
BUILT_IN_MODE_NAMES,
|
|
@@ -82,6 +84,9 @@ import {
|
|
|
82
84
|
hardResetTo,
|
|
83
85
|
mixedResetTo,
|
|
84
86
|
unpinScript,
|
|
87
|
+
repinScript,
|
|
88
|
+
commitPath,
|
|
89
|
+
transitionOutcome,
|
|
85
90
|
ScriptSurface,
|
|
86
91
|
updateRef,
|
|
87
92
|
type RunnableScript,
|
|
@@ -154,6 +159,7 @@ export type Command =
|
|
|
154
159
|
}
|
|
155
160
|
| { readonly kind: "abandon" }
|
|
156
161
|
| { readonly kind: "restore" }
|
|
162
|
+
| { readonly kind: "repin" }
|
|
157
163
|
| { readonly kind: "next" }
|
|
158
164
|
| { readonly kind: "validate" }
|
|
159
165
|
| {
|
|
@@ -862,6 +868,49 @@ const runRestoreCommand = (
|
|
|
862
868
|
yield* writeScript(json, out, combinedScript(required, ""))
|
|
863
869
|
})
|
|
864
870
|
|
|
871
|
+
/** The step an ordinary repin rests at — the one the process rests at now. */
|
|
872
|
+
const repinTarget = Effect.gen(function* () {
|
|
873
|
+
const rest = yield* currentRest
|
|
874
|
+
const refusal =
|
|
875
|
+
rest.pin === "add"
|
|
876
|
+
? "the next beat pins the process already — run it (`gtd next --json=content`), then land"
|
|
877
|
+
: rest.pin === "remove"
|
|
878
|
+
? "the flow has ended — its unpin beat is next, so there is nothing to repin"
|
|
879
|
+
: noProcessUnderway(rest)
|
|
880
|
+
? "no gtd process is underway — nothing to repin"
|
|
881
|
+
: undefined
|
|
882
|
+
if (refusal !== undefined) return yield* Effect.fail(new Error(`gtd repin: ${refusal}`))
|
|
883
|
+
return rest.step.name
|
|
884
|
+
})
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* `gtd repin`: the pin beat's script and landing, on demand. It runs live: a
|
|
888
|
+
* pinned process the live workflow no longer replays is anchored where it
|
|
889
|
+
* diverged, and any other failure to read the rest refuses before any pin is
|
|
890
|
+
* written.
|
|
891
|
+
*/
|
|
892
|
+
const runRepinCommand = (
|
|
893
|
+
json: JsonMode,
|
|
894
|
+
out: ArtifactOut,
|
|
895
|
+
): Effect.Effect<void, Error, CommandRequirements> =>
|
|
896
|
+
Effect.gen(function* () {
|
|
897
|
+
const anchor = yield* repinAnchor
|
|
898
|
+
const to = anchor?.to ?? (yield* repinTarget)
|
|
899
|
+
const message = formatCommitMessage({
|
|
900
|
+
actor: "check",
|
|
901
|
+
from: PIN_STATES.add,
|
|
902
|
+
to,
|
|
903
|
+
pin: "add",
|
|
904
|
+
...(anchor !== undefined ? { anchor: anchor.at } : {}),
|
|
905
|
+
})
|
|
906
|
+
const steps: EmitStep[] = [
|
|
907
|
+
{ kind: "gitWrite", command: repinScript(PIN_PATH) },
|
|
908
|
+
{ kind: "gitWrite", command: commitPath(PIN_PATH, message) },
|
|
909
|
+
{ kind: "outcome", command: transitionOutcome(PIN_STATES.add, to) },
|
|
910
|
+
]
|
|
911
|
+
yield* writeScript(json, out, combinedScript(emitScripts(steps).required, ""))
|
|
912
|
+
})
|
|
913
|
+
|
|
865
914
|
/**
|
|
866
915
|
* The mode's own validate command for `file`: a declared `validate:` command
|
|
867
916
|
* verbatim; a `"builtin"` validator or no mode at all names the leaf
|
|
@@ -1339,6 +1388,8 @@ const dispatchVoidCommand = (
|
|
|
1339
1388
|
return runAbandonCommand(json, out)
|
|
1340
1389
|
case "restore":
|
|
1341
1390
|
return runRestoreCommand(json, out)
|
|
1391
|
+
case "repin":
|
|
1392
|
+
return runRepinCommand(json, out)
|
|
1342
1393
|
case "next":
|
|
1343
1394
|
return runNextCommand(json, out)
|
|
1344
1395
|
case "validate":
|
package/src/edge/Edge.test.ts
CHANGED
|
@@ -217,7 +217,7 @@ describe("currentRun", () => {
|
|
|
217
217
|
const before = headOf(repo)
|
|
218
218
|
const opening = await enter(repo, "side")
|
|
219
219
|
expect(repo.lastCommitMessage(opening)).toBe(
|
|
220
|
-
`gtd(human): fixing\n\nGtd-Workflow: side\nGtd-Var: base=\nGtd-Var: reviewer=nobody\nGtd-Format:
|
|
220
|
+
`gtd(human): fixing\n\nGtd-Workflow: side\nGtd-Var: base=\nGtd-Var: reviewer=nobody\nGtd-Format: 3`,
|
|
221
221
|
)
|
|
222
222
|
const fixed = await land(repo, { "fix.txt": "x\n" })
|
|
223
223
|
const run = await provide(currentRun, repo)
|
|
@@ -250,7 +250,7 @@ export const again = workflow(async () => {
|
|
|
250
250
|
const before = headOf(repo)
|
|
251
251
|
const opening = await enter(repo, "again")
|
|
252
252
|
expect(repo.lastCommitMessage(opening)).toBe(
|
|
253
|
-
`gtd(human): idle\n\nGtd-Workflow: again\nGtd-Format:
|
|
253
|
+
`gtd(human): idle\n\nGtd-Workflow: again\nGtd-Format: 3`,
|
|
254
254
|
)
|
|
255
255
|
const fixed = await land(repo, { "fix.txt": "x\n" })
|
|
256
256
|
const run = await provide(currentRun, repo)
|
|
@@ -403,7 +403,7 @@ export const again = workflow(async () => {
|
|
|
403
403
|
const repo = repoWith(LINEAR)
|
|
404
404
|
await land(repo, { "a.txt": "a\n" })
|
|
405
405
|
await land(repo)
|
|
406
|
-
expect(repo.lastCommitMessage()).toBe("gtd(agent): building\n\nGtd-Format:
|
|
406
|
+
expect(repo.lastCommitMessage()).toBe("gtd(agent): building\n\nGtd-Format: 3")
|
|
407
407
|
const run = await provide(currentRun, repo)
|
|
408
408
|
expect(run.headTurn).toEqual({ state: "building", actor: "agent", empty: true, step: false })
|
|
409
409
|
})
|
package/src/edge/Edge.ts
CHANGED
|
@@ -36,7 +36,9 @@ import {
|
|
|
36
36
|
parseCommitMessage,
|
|
37
37
|
replay,
|
|
38
38
|
type CommitMessage,
|
|
39
|
+
formatCommitMessage,
|
|
39
40
|
scopeRunStart,
|
|
41
|
+
setAsideBy,
|
|
40
42
|
treeFromRecord,
|
|
41
43
|
type EpisodeCommit,
|
|
42
44
|
type JudgeVerdict,
|
|
@@ -259,6 +261,18 @@ const approvalEntryOf = (
|
|
|
259
261
|
},
|
|
260
262
|
]
|
|
261
263
|
|
|
264
|
+
// What an anchor set aside answers no step: it records no mark, approval or
|
|
265
|
+
// verdict. An anchor naming no commit is replay's to refuse.
|
|
266
|
+
const setAsideIn = (history: History, location: EpisodeLocation): ReadonlySet<string> => {
|
|
267
|
+
const anchored = setAsideBy(
|
|
268
|
+
hashAt(history, location.baseIndex) ?? EMPTY_TREE,
|
|
269
|
+
history
|
|
270
|
+
.slice(location.baseIndex + 1)
|
|
271
|
+
.map((c) => ({ hash: c.hash, parsed: parseCommitMessage(c.message) })),
|
|
272
|
+
)
|
|
273
|
+
return "setAside" in anchored ? anchored.setAside : new Set()
|
|
274
|
+
}
|
|
275
|
+
|
|
262
276
|
const buildRun = (
|
|
263
277
|
history: History,
|
|
264
278
|
location: EpisodeLocation,
|
|
@@ -269,6 +283,9 @@ const buildRun = (
|
|
|
269
283
|
// A pin commit is no turn; the unpin commit stays, as the one that closes the process.
|
|
270
284
|
const processCommits = allProcessCommits.filter((_, i) => allParsed[i]!.pin !== "add")
|
|
271
285
|
const parsed = allParsed.filter((m) => m.pin !== "add")
|
|
286
|
+
const setAside = setAsideIn(history, location)
|
|
287
|
+
const answering = parsed.filter((_, i) => !setAside.has(processCommits[i]!.hash))
|
|
288
|
+
const answeringCommits = processCommits.filter((c) => !setAside.has(c.hash))
|
|
272
289
|
const first = parsed[0]
|
|
273
290
|
const startParentHash = hashAt(history, location.processStart - 1) ?? EMPTY_TREE
|
|
274
291
|
const head = history[history.length - 1]
|
|
@@ -280,10 +297,10 @@ const buildRun = (
|
|
|
280
297
|
diffBase: first?.reviewBase ?? startParentHash,
|
|
281
298
|
trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i]!)),
|
|
282
299
|
costEntries: parsed.flatMap((m, i) => costEntriesOf(processCommits[i]!.hash, m)),
|
|
283
|
-
judgeVerdicts:
|
|
300
|
+
judgeVerdicts: answering.flatMap((m) => m.judge),
|
|
284
301
|
pinCommitted: allParsed.some((m) => m.pin === "add"),
|
|
285
|
-
marks:
|
|
286
|
-
approvals:
|
|
302
|
+
marks: answering.flatMap((m) => m.marks),
|
|
303
|
+
approvals: answeringCommits.flatMap((c, i) => approvalEntryOf(c.hash, answering[i]!)),
|
|
287
304
|
pinnedVars: { ...first?.vars },
|
|
288
305
|
headTurn: headTurnOf(head),
|
|
289
306
|
closingHash,
|
|
@@ -1012,6 +1029,54 @@ export const restAt = (
|
|
|
1012
1029
|
|
|
1013
1030
|
export const currentRest: Effect.Effect<Rest, Error, RestRequirements> = restAt(undefined)
|
|
1014
1031
|
|
|
1032
|
+
/**
|
|
1033
|
+
* Where `gtd repin` anchors a pinned process the live workflow no longer
|
|
1034
|
+
* replays: the commit replay last stood on, and the step the live workflow
|
|
1035
|
+
* reaches from there on HEAD's tree — the unpin when the flow ends instead.
|
|
1036
|
+
* `undefined` when there is nothing to anchor: no divergence, or no pin.
|
|
1037
|
+
*/
|
|
1038
|
+
export const repinAnchor: Effect.Effect<
|
|
1039
|
+
{ readonly at: string; readonly to: string } | undefined,
|
|
1040
|
+
Error,
|
|
1041
|
+
RestRequirements
|
|
1042
|
+
> = Effect.gen(function* () {
|
|
1043
|
+
const git = yield* GitService
|
|
1044
|
+
const config = yield* (yield* ConfigService).load
|
|
1045
|
+
const workspace = yield* Workspace
|
|
1046
|
+
const run = yield* computeProcessRun(git, config.workflow)
|
|
1047
|
+
const { def, setup } = yield* setupFor(config, run, workspace, yield* Host)
|
|
1048
|
+
const diverged = yield* Effect.promise(() => replayFor(setup))
|
|
1049
|
+
if (diverged.kind !== "divergence" || !headHasPin(workspace, run)) return undefined
|
|
1050
|
+
// A trial anchor naming the reserved pin state diverges at whatever step the
|
|
1051
|
+
// flow reaches once it stands on HEAD's tree.
|
|
1052
|
+
const trial: EpisodeCommit = {
|
|
1053
|
+
hash: "",
|
|
1054
|
+
message: formatCommitMessage({
|
|
1055
|
+
actor: "check",
|
|
1056
|
+
from: PIN_STATES.add,
|
|
1057
|
+
to: PIN_STATES.add,
|
|
1058
|
+
pin: "add",
|
|
1059
|
+
anchor: diverged.at,
|
|
1060
|
+
}),
|
|
1061
|
+
tree: setup.episode.at(-1)?.tree ?? setup.base.tree,
|
|
1062
|
+
}
|
|
1063
|
+
const outcome = yield* Effect.promise(() =>
|
|
1064
|
+
replayFor({ ...setup, episode: [...setup.episode, trial] }),
|
|
1065
|
+
)
|
|
1066
|
+
if (outcome.kind !== "divergence") {
|
|
1067
|
+
return yield* Effect.fail(replayError(outcome) ?? new Error("gtd repin: replay found no step"))
|
|
1068
|
+
}
|
|
1069
|
+
const to = outcome.reached ?? PIN_STATES.remove
|
|
1070
|
+
if (run.workflow === undefined && to === def.initial) {
|
|
1071
|
+
return yield* Effect.fail(
|
|
1072
|
+
new Error(
|
|
1073
|
+
`gtd repin: the live workflow continues this process at "${to}", the default workflow's first step, where a finished process waits — run \`gtd abandon\` to start over`,
|
|
1074
|
+
),
|
|
1075
|
+
)
|
|
1076
|
+
}
|
|
1077
|
+
return { at: diverged.at, to }
|
|
1078
|
+
})
|
|
1079
|
+
|
|
1015
1080
|
/** The step the process rests at and the trace that reached it — at an unpin, the flow's last step. */
|
|
1016
1081
|
const restingOn = (outcome: ReplayOutcome, pin: PinChange | undefined) => {
|
|
1017
1082
|
if (outcome.kind === "rest") return { reached: outcome, step: outcome.rest }
|
package/src/edge/index.ts
CHANGED
package/src/emit/GitScript.ts
CHANGED
|
@@ -44,6 +44,13 @@ export const pinScript = (path: string): string => {
|
|
|
44
44
|
return `${mkdir}gtd bundle >${p} || { rm -f -- ${p}; exit 1; }`
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/** `gtd repin`'s pin write: a failed `gtd bundle` leaves the committed pin in place. */
|
|
48
|
+
export const repinScript = (path: string): string => {
|
|
49
|
+
const p = shellQuote(path)
|
|
50
|
+
const next = shellQuote(`${path}.new`)
|
|
51
|
+
return `gtd bundle >${next} || { rm -f -- ${next}; exit 1; }\nmv -f -- ${next} ${p}`
|
|
52
|
+
}
|
|
53
|
+
|
|
47
54
|
/** An unpin beat's script, and `gtd abandon`'s removal of the pin. */
|
|
48
55
|
export const unpinScript = (path: string): string => `rm -f -- ${shellQuote(path)}`
|
|
49
56
|
|
package/src/emit/index.ts
CHANGED
|
@@ -3,11 +3,12 @@ import { formatCommitMessage, formatFault, parseCommitMessage } from "./index.js
|
|
|
3
3
|
|
|
4
4
|
describe("the history format", () => {
|
|
5
5
|
it("accepts a history in the current format and in the one before it", () => {
|
|
6
|
-
expect(formatFault([{ hash: "a".repeat(40), format:
|
|
6
|
+
expect(formatFault([{ hash: "a".repeat(40), format: 3 }])).toBeUndefined()
|
|
7
7
|
expect(
|
|
8
8
|
formatFault([
|
|
9
9
|
{ hash: "a".repeat(40), format: 1 },
|
|
10
10
|
{ hash: "b".repeat(40), format: 2 },
|
|
11
|
+
{ hash: "c".repeat(40), format: 3 },
|
|
11
12
|
]),
|
|
12
13
|
).toBeUndefined()
|
|
13
14
|
expect(formatFault([])).toBeUndefined()
|
|
@@ -16,11 +17,11 @@ describe("the history format", () => {
|
|
|
16
17
|
it("names the first foreign commit, the found and readable formats, and what to do", () => {
|
|
17
18
|
const fault = formatFault([
|
|
18
19
|
{ hash: "a".repeat(40), format: 1 },
|
|
19
|
-
{ hash: "b".repeat(40), format:
|
|
20
|
-
{ hash: "c".repeat(40), format:
|
|
20
|
+
{ hash: "b".repeat(40), format: 4 },
|
|
21
|
+
{ hash: "c".repeat(40), format: 5 },
|
|
21
22
|
])
|
|
22
23
|
expect(fault).toBe(
|
|
23
|
-
"gtd: commit bbbbbbb is written in history format
|
|
24
|
+
"gtd: commit bbbbbbb is written in history format 4, but this gtd reads formats 1 to 3 — run a gtd release that reads format 4 to continue this process",
|
|
24
25
|
)
|
|
25
26
|
})
|
|
26
27
|
})
|
|
@@ -35,8 +36,8 @@ describe("Gtd-Approved and the history format", () => {
|
|
|
35
36
|
expect(message).toContain(
|
|
36
37
|
'Gtd-Approved: {"step":"seam#1","name":"Test","email":"test@test.com"}',
|
|
37
38
|
)
|
|
38
|
-
expect(message).toContain("Gtd-Format:
|
|
39
|
-
expect(parseCommitMessage(message).format).toBe(
|
|
39
|
+
expect(message).toContain("Gtd-Format: 3")
|
|
40
|
+
expect(parseCommitMessage(message).format).toBe(3)
|
|
40
41
|
})
|
|
41
42
|
})
|
|
42
43
|
|
|
@@ -50,7 +51,7 @@ describe("Gtd-Pin", () => {
|
|
|
50
51
|
branch: "main",
|
|
51
52
|
})
|
|
52
53
|
expect(message).toBe(
|
|
53
|
-
"gtd(check): unpin → idle\n\nGtd-Pin: remove\nGtd-Branch: main\nGtd-Format:
|
|
54
|
+
"gtd(check): unpin → idle\n\nGtd-Pin: remove\nGtd-Branch: main\nGtd-Format: 3",
|
|
54
55
|
)
|
|
55
56
|
expect(parseCommitMessage(message).pin).toBe("remove")
|
|
56
57
|
expect(parseCommitMessage("gtd(check): pin → a\n\nGtd-Pin: add").pin).toBe("add")
|
|
@@ -60,3 +61,21 @@ describe("Gtd-Pin", () => {
|
|
|
60
61
|
expect(parseCommitMessage("gtd(check): pin → a\n\nGtd-Pin: frozen").pin).toBeUndefined()
|
|
61
62
|
})
|
|
62
63
|
})
|
|
64
|
+
|
|
65
|
+
describe("Gtd-Anchor", () => {
|
|
66
|
+
it("round-trips after Gtd-Pin, and reads a non-hash value as no anchor", () => {
|
|
67
|
+
const anchor = "a".repeat(40)
|
|
68
|
+
const message = formatCommitMessage({
|
|
69
|
+
actor: "check",
|
|
70
|
+
from: "pin",
|
|
71
|
+
to: "b",
|
|
72
|
+
pin: "add",
|
|
73
|
+
anchor,
|
|
74
|
+
})
|
|
75
|
+
expect(message).toBe(
|
|
76
|
+
`gtd(check): pin → b\n\nGtd-Pin: add\nGtd-Anchor: ${anchor}\nGtd-Format: 3`,
|
|
77
|
+
)
|
|
78
|
+
expect(parseCommitMessage(message).anchor).toBe(anchor)
|
|
79
|
+
expect(parseCommitMessage("gtd(check): pin → b\n\nGtd-Anchor: HEAD~2").anchor).toBeUndefined()
|
|
80
|
+
})
|
|
81
|
+
})
|
package/src/replay/Format.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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. Format 2 adds pin commits, which format 1 would replay as turns
|
|
4
|
-
|
|
3
|
+
// untouched. Format 2 adds pin commits, which format 1 would replay as turns;
|
|
4
|
+
// format 3 adds the anchor, whose set-aside commits format 2 would replay.
|
|
5
|
+
export const HISTORY_FORMAT = 3
|
|
5
6
|
const OLDEST_READABLE_FORMAT = 1
|
|
6
7
|
|
|
7
8
|
const short = (hash: string): string => hash.slice(0, 7)
|
|
@@ -1154,6 +1154,8 @@ describe("human({ approve })", () => {
|
|
|
1154
1154
|
expect(outcome).toEqual({
|
|
1155
1155
|
kind: "divergence",
|
|
1156
1156
|
message: `gtd: commit ${hashOf(1).slice(0, 7)} completes approval gate "gate#1" but records no Gtd-Approved for it — the workflow changed under this process; run \`gtd abandon\` to start over`,
|
|
1157
|
+
at: hashOf(0),
|
|
1158
|
+
reached: "gate",
|
|
1157
1159
|
})
|
|
1158
1160
|
})
|
|
1159
1161
|
|
|
@@ -1271,3 +1273,88 @@ describe("pin commits", () => {
|
|
|
1271
1273
|
expect((await replayOf(flow, h)).kind).toBe("ended")
|
|
1272
1274
|
})
|
|
1273
1275
|
})
|
|
1276
|
+
|
|
1277
|
+
describe("replay: a repin's anchor", () => {
|
|
1278
|
+
const anchorTo = (to: string, at: number): string =>
|
|
1279
|
+
formatCommitMessage({ actor: "check", from: "pin", to, pin: "add", anchor: hashOf(at) })
|
|
1280
|
+
|
|
1281
|
+
// The old flow went idle → build → old; this one goes idle → build → check.
|
|
1282
|
+
const anchoredHistory = (): History =>
|
|
1283
|
+
new History()
|
|
1284
|
+
.land("human", "idle", 1, "build", { "a.txt": "a" })
|
|
1285
|
+
.land("agent", "build", 1, "old", { "b.txt": "b" })
|
|
1286
|
+
.raw(anchorTo("build", 1))
|
|
1287
|
+
|
|
1288
|
+
it("records where replay diverged and the step it reached there", async () => {
|
|
1289
|
+
const h = new History().land("human", "idle", 1, "fix", { "TODO.md": "go" })
|
|
1290
|
+
const outcome = await replayOf(fixLoop, h)
|
|
1291
|
+
expect(outcome).toMatchObject({ kind: "divergence", at: hashOf(1), reached: "check" })
|
|
1292
|
+
})
|
|
1293
|
+
|
|
1294
|
+
it("rests at the anchored step, redone on the anchor's tree", async () => {
|
|
1295
|
+
let afterBuild: readonly string[] = []
|
|
1296
|
+
const flow: Flow = async () => {
|
|
1297
|
+
await human("idle")
|
|
1298
|
+
await agent("build", "p")
|
|
1299
|
+
afterBuild = changes().map((c) => c.path)
|
|
1300
|
+
await agent("check", "p")
|
|
1301
|
+
}
|
|
1302
|
+
expect(restName(await replayOf(flow, anchoredHistory()))).toBe("build#1")
|
|
1303
|
+
const outcome = await replayOf(flow, anchoredHistory(), { pending: { "c.txt": "c" } })
|
|
1304
|
+
expect(restName(outcome)).toBe("check#1")
|
|
1305
|
+
expect(afterBuild).toEqual(["c.txt"])
|
|
1306
|
+
})
|
|
1307
|
+
|
|
1308
|
+
it("fails changesSince() on a commit it set aside", async () => {
|
|
1309
|
+
const flow: Flow = async () => {
|
|
1310
|
+
await human("idle")
|
|
1311
|
+
changesSince(hashOf(2))
|
|
1312
|
+
await agent("build", "p")
|
|
1313
|
+
}
|
|
1314
|
+
const outcome = await replayOf(flow, anchoredHistory())
|
|
1315
|
+
expect(outcome.kind === "failed" && outcome.message).toContain("a repin set 0000000 aside")
|
|
1316
|
+
})
|
|
1317
|
+
|
|
1318
|
+
it("diverges again when the flow reaches another step than the anchor names", async () => {
|
|
1319
|
+
const flow: Flow = async () => {
|
|
1320
|
+
await human("idle")
|
|
1321
|
+
await agent("make", "p")
|
|
1322
|
+
}
|
|
1323
|
+
expect(await replayOf(flow, anchoredHistory())).toMatchObject({
|
|
1324
|
+
kind: "divergence",
|
|
1325
|
+
at: hashOf(3),
|
|
1326
|
+
reached: "make",
|
|
1327
|
+
})
|
|
1328
|
+
})
|
|
1329
|
+
|
|
1330
|
+
it("lets the flow code leading to the step read HEAD's tree", async () => {
|
|
1331
|
+
const flow: Flow = async () => {
|
|
1332
|
+
await human("idle")
|
|
1333
|
+
await agent("build", `seen: ${read("b.txt") ?? "nothing"}`)
|
|
1334
|
+
}
|
|
1335
|
+
const outcome = await replayOf(flow, anchoredHistory())
|
|
1336
|
+
expect(outcome.kind === "rest" && outcome.rest.request).toMatchObject({ prompt: "seen: b" })
|
|
1337
|
+
})
|
|
1338
|
+
|
|
1339
|
+
it("diverges when the flow ends where it names a step", async () => {
|
|
1340
|
+
const outcome = await replayOf(async () => void (await human("idle")), anchoredHistory())
|
|
1341
|
+
expect(outcome).toMatchObject({ kind: "divergence", at: hashOf(3), reached: undefined })
|
|
1342
|
+
})
|
|
1343
|
+
|
|
1344
|
+
it("closes the process when it names the unpin and the flow ends", async () => {
|
|
1345
|
+
const h = new History()
|
|
1346
|
+
.land("human", "idle", 1, "build", { "a.txt": "a" })
|
|
1347
|
+
.land("agent", "build", 1, "old")
|
|
1348
|
+
.raw(anchorTo("unpin", 1))
|
|
1349
|
+
const outcome = await replayOf(async () => void (await human("idle")), h)
|
|
1350
|
+
expect(outcome.kind).toBe("ended")
|
|
1351
|
+
})
|
|
1352
|
+
|
|
1353
|
+
it("fails when it names no commit replay reads", async () => {
|
|
1354
|
+
const h = anchoredHistory().raw(anchorTo("build", 2))
|
|
1355
|
+
const outcome = await replayOf(async () => void (await human("idle")), h)
|
|
1356
|
+
expect(outcome.kind === "failed" && outcome.message).toContain(
|
|
1357
|
+
"which is no commit replay reads",
|
|
1358
|
+
)
|
|
1359
|
+
})
|
|
1360
|
+
})
|