@pmelab/gtd 26.0.0 → 27.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 +21 -3
- package/dist/chunks/{Schema-B5yK7QrT.mjs → Schema-CyImoHTj.mjs} +56 -3
- package/dist/chunks/{reserved-6pAgFGgB.mjs → reserved-BL9krPao.mjs} +1 -1
- package/dist/chunks/{wire-DA7q2ikx.mjs → wire-BY1S-obW.mjs} +5 -2
- package/dist/flows.mjs +15 -1
- package/dist/gtd.bundle.mjs +219 -34
- package/dist/testkit.mjs +447 -72
- package/dist/wire.mjs +2 -2
- package/dist/workflows/feature.mjs +1 -1
- package/dist/workflows/fix.mjs +1 -1
- package/dist/workflows/review.mjs +1 -1
- package/package.json +2 -2
- package/src/cli/Cli.test.ts +56 -4
- package/src/cli/Cli.ts +48 -6
- package/src/cli/History.test.ts +2 -2
- package/src/cli/Output.ts +3 -1
- package/src/cli/Program.test.ts +11 -12
- package/src/cli/Program.ts +28 -7
- package/src/cli/Strays.test.ts +88 -0
- package/src/cli/Strays.ts +56 -0
- package/src/edge/Edge.test.ts +113 -16
- package/src/edge/Edge.ts +22 -6
- package/src/emit/GitScript.test.ts +65 -2
- package/src/emit/GitScript.ts +34 -1
- package/src/emit/index.ts +1 -0
- package/src/flags/index.ts +0 -1
- package/src/flows/runtime.test.ts +33 -0
- package/src/flows/runtime.ts +31 -0
- package/src/platform/Git.test.ts +96 -1
- package/src/platform/Git.ts +24 -0
- package/src/platform/RetainedHistory.test.ts +9 -0
- package/src/platform/RetainedHistory.ts +3 -0
- package/src/platform/Workspace.test.ts +1 -1
- package/src/platform/index.ts +1 -0
- package/src/replay/RefName.test.ts +40 -0
- package/src/replay/RefName.ts +19 -0
- package/src/replay/Replay.test.ts +193 -2
- package/src/replay/Replay.ts +51 -6
- package/src/replay/Trailers.test.ts +26 -0
- package/src/replay/Trailers.ts +8 -0
- package/src/replay/index.ts +1 -0
- package/src/step/LandStep.ts +1 -0
- package/src/step/RepoSnapshot.ts +2 -0
- package/src/step/index.ts +1 -0
- package/src/step/planStart.test.ts +5 -5
- package/src/step/planStep.test.ts +53 -0
- package/src/step/planStep.ts +7 -1
- package/src/step/strayPaths.test.ts +17 -0
- package/src/step/strayPaths.ts +8 -0
- package/src/testkit/EmittedScriptRecognizer.test.ts +57 -29
- package/src/testkit/EmittedScriptRecognizer.ts +26 -4
- package/src/testkit/EmittedScripts.test.ts +182 -0
- package/src/testkit/FakeGitOperations.ts +10 -2
- package/src/testkit/GitTiers.ts +170 -25
- package/src/testkit/InMemRepo.ts +64 -12
- package/src/testkit/Layers.test.ts +34 -1
- package/src/testkit/Layers.ts +21 -1
- package/src/testkit/TestRepo.ts +3 -3
- package/src/wire/ExitCodes.test.ts +11 -3
- package/src/wire/ExitCodes.ts +3 -0
- package/src/wire/Glob.test.ts +35 -0
- package/src/wire/Glob.ts +1 -1
- package/src/workflow/Workflow.ts +1 -0
- package/src/workflow/discovery.test.ts +1 -1
- package/src/workflows/shared/text.fixture.ts +1 -0
package/src/edge/Edge.test.ts
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
type RestRequirements,
|
|
20
20
|
} from "./Edge.js"
|
|
21
21
|
import { GTD_VERSION } from "../install/index.js"
|
|
22
|
-
import { processHistoryRef } from "../platform/index.js"
|
|
22
|
+
import { keptRef, processHistoryRef } from "../platform/index.js"
|
|
23
23
|
import { GitService, Workspace, type GitOperations, type WorkspaceOps } from "../platform/index.js"
|
|
24
24
|
import { formatCommitMessage, type CommitSpec } from "../replay/index.js"
|
|
25
25
|
import { contentHash } from "../workflow/index.js"
|
|
@@ -78,7 +78,7 @@ const headOf = (repo: InMemRepo): string => repo.resolveRef("HEAD")!
|
|
|
78
78
|
const repoWith = (config: string, files: Files = {}): InMemRepo => {
|
|
79
79
|
const repo = new InMemRepo()
|
|
80
80
|
write(repo, { "gtd.config.ts": config, ...files })
|
|
81
|
-
repo.
|
|
81
|
+
repo.seedCommit("chore: add workflow")
|
|
82
82
|
return repo
|
|
83
83
|
}
|
|
84
84
|
|
|
@@ -112,7 +112,7 @@ const enter = async (repo: InMemRepo, workflow: string, ...vars: string[]): Prom
|
|
|
112
112
|
/** Commits an exact message — for trailers no landing writes on its own. */
|
|
113
113
|
const commit = (repo: InMemRepo, spec: CommitSpec, files: Files = {}): string => {
|
|
114
114
|
write(repo, files)
|
|
115
|
-
repo.
|
|
115
|
+
repo.seedCommit(formatCommitMessage(spec))
|
|
116
116
|
return headOf(repo)
|
|
117
117
|
}
|
|
118
118
|
|
|
@@ -179,7 +179,7 @@ describe("currentRun", () => {
|
|
|
179
179
|
const repo = repoWith(LINEAR)
|
|
180
180
|
await land(repo, { "a.txt": "a\n" })
|
|
181
181
|
repo.writeFile("b.txt", "b\n")
|
|
182
|
-
repo.
|
|
182
|
+
repo.seedCommit("feat: unrelated")
|
|
183
183
|
const run = await provide(currentRun, repo)
|
|
184
184
|
expect(run.trace).toEqual([])
|
|
185
185
|
expect(run.startParentHash).toBe(headOf(repo))
|
|
@@ -296,7 +296,7 @@ export const again = async () => {
|
|
|
296
296
|
{ "a.txt": "a\n" },
|
|
297
297
|
)
|
|
298
298
|
repo.writeFile("b.txt", "b\n")
|
|
299
|
-
repo.
|
|
299
|
+
repo.seedCommit(
|
|
300
300
|
[
|
|
301
301
|
"gtd(agent): building → checking",
|
|
302
302
|
"",
|
|
@@ -316,7 +316,7 @@ export const again = async () => {
|
|
|
316
316
|
const repo = repoWith(LINEAR)
|
|
317
317
|
const base = headOf(repo)
|
|
318
318
|
repo.writeFile("later.txt", "later\n")
|
|
319
|
-
repo.
|
|
319
|
+
repo.seedCommit("feat: later")
|
|
320
320
|
const before = headOf(repo)
|
|
321
321
|
await enter(repo, "review", `base=${base}`)
|
|
322
322
|
const run = await provide(currentRun, repo)
|
|
@@ -398,13 +398,13 @@ describe("history reads stay bounded by the episode, not the repository", () =>
|
|
|
398
398
|
const repoWithHistory = (count: number): InMemRepo => {
|
|
399
399
|
const repo = new InMemRepo()
|
|
400
400
|
repo.writeFile("seed.txt", "0")
|
|
401
|
-
repo.
|
|
401
|
+
repo.seedCommit("chore: seed 0")
|
|
402
402
|
for (let i = 1; i < count; i++) {
|
|
403
403
|
repo.writeFile("seed.txt", String(i))
|
|
404
|
-
repo.
|
|
404
|
+
repo.seedCommit(`chore: seed ${i}`)
|
|
405
405
|
}
|
|
406
406
|
write(repo, { "gtd.config.ts": LINEAR })
|
|
407
|
-
repo.
|
|
407
|
+
repo.seedCommit("chore: add workflow")
|
|
408
408
|
return repo
|
|
409
409
|
}
|
|
410
410
|
|
|
@@ -529,7 +529,7 @@ describe("summaryRun", () => {
|
|
|
529
529
|
await land(repo, { "b.txt": "b\n" })
|
|
530
530
|
await land(repo, { "c.txt": "c\n" })
|
|
531
531
|
repo.writeFile("d.txt", "d\n")
|
|
532
|
-
repo.
|
|
532
|
+
repo.seedCommit("chore: unrelated")
|
|
533
533
|
const summary = await provide(summaryRun, repo)
|
|
534
534
|
expect(summary.closingHash).toBeUndefined()
|
|
535
535
|
expect(summary.trace).toEqual([])
|
|
@@ -638,7 +638,7 @@ export const base = (workflow, vars) => (workflow === "review" ? (vars.base ?? "
|
|
|
638
638
|
const repo = repoWith(LOOP)
|
|
639
639
|
const base = headOf(repo)
|
|
640
640
|
repo.writeFile("later.txt", "later\n")
|
|
641
|
-
repo.
|
|
641
|
+
repo.seedCommit("feat: later")
|
|
642
642
|
await enter(repo, "review", `base=${base}`)
|
|
643
643
|
expect(reviewBaseFor(await provide(currentRest, repo))).toBe(base)
|
|
644
644
|
})
|
|
@@ -850,7 +850,7 @@ describe("stalledAt", () => {
|
|
|
850
850
|
const repo = repoWith(LINEAR)
|
|
851
851
|
await enter(repo, "side")
|
|
852
852
|
await land(repo, { "fix.txt": "x\n" })
|
|
853
|
-
repo.
|
|
853
|
+
repo.seedCommit("gtd(agent): tidying")
|
|
854
854
|
const rest = await provide(currentRest, repo)
|
|
855
855
|
expect(rest.state).toBe("tidying")
|
|
856
856
|
expect(stalledAt(rest)).toBe(false)
|
|
@@ -1038,7 +1038,7 @@ describe("closedRunAt", () => {
|
|
|
1038
1038
|
const tip = await land(repo, { "c.txt": "c\n" })
|
|
1039
1039
|
const closed = await provide(closedRunAt(tip), repo)
|
|
1040
1040
|
repo.writeFile("d.txt", "d\n")
|
|
1041
|
-
repo.
|
|
1041
|
+
repo.seedCommit("chore: unrelated")
|
|
1042
1042
|
const later = await provide(closedRunAt(tip), repo)
|
|
1043
1043
|
expect(later).toEqual(closed)
|
|
1044
1044
|
expect(later.closingHash).toBe(tip)
|
|
@@ -1146,6 +1146,103 @@ export const side = async () => {
|
|
|
1146
1146
|
})
|
|
1147
1147
|
})
|
|
1148
1148
|
|
|
1149
|
+
describe("keep steps", () => {
|
|
1150
|
+
const KEEP = `import { agent, human } from "@pmelab/gtd/flows"
|
|
1151
|
+
|
|
1152
|
+
export default async () => {
|
|
1153
|
+
await human("idle")
|
|
1154
|
+
await agent("building", "build-prompt", { keep: true })
|
|
1155
|
+
await agent("checking", "check-prompt")
|
|
1156
|
+
}
|
|
1157
|
+
`
|
|
1158
|
+
|
|
1159
|
+
const snapshotOf = async (repo: InMemRepo) =>
|
|
1160
|
+
(await provide(snapshotFromRest(await provide(currentRest, repo)), repo)).landing
|
|
1161
|
+
|
|
1162
|
+
it("landing at a keep step carries keepTurn under refs/gtd/kept/<startHash>/<step>", async () => {
|
|
1163
|
+
const repo = repoWith(KEEP)
|
|
1164
|
+
const building = await land(repo, { "a.txt": "a\n" })
|
|
1165
|
+
repo.writeFile("naive.txt", "naive\n")
|
|
1166
|
+
const landing = await snapshotOf(repo)
|
|
1167
|
+
if (landing.kind !== "commit") throw new Error(`expected commit, got ${landing.kind}`)
|
|
1168
|
+
expect(landing.to).toBe("checking")
|
|
1169
|
+
expect(landing.keepTurn).toBe(keptRef(building, "building"))
|
|
1170
|
+
expect(landing.keepTurn).toBe(`refs/gtd/kept/${building}/building`)
|
|
1171
|
+
})
|
|
1172
|
+
|
|
1173
|
+
it("a landing at a step that does not keep carries no keepTurn", async () => {
|
|
1174
|
+
const repo = repoWith(KEEP)
|
|
1175
|
+
repo.writeFile("a.txt", "a\n")
|
|
1176
|
+
const landing = await snapshotOf(repo)
|
|
1177
|
+
if (landing.kind !== "commit") throw new Error(`expected commit, got ${landing.kind}`)
|
|
1178
|
+
expect(landing.keepTurn).toBeUndefined()
|
|
1179
|
+
})
|
|
1180
|
+
|
|
1181
|
+
it("a clean tree at a keep step is still an attempt, never a keep", async () => {
|
|
1182
|
+
const repo = repoWith(KEEP)
|
|
1183
|
+
await land(repo, { "a.txt": "a\n" })
|
|
1184
|
+
const landing = await snapshotOf(repo)
|
|
1185
|
+
expect(landing.kind).toBe("attempt")
|
|
1186
|
+
})
|
|
1187
|
+
|
|
1188
|
+
it("a marker at HEAD is not a stall: the process rests at the step after the keep", async () => {
|
|
1189
|
+
const repo = repoWith(KEEP)
|
|
1190
|
+
await land(repo, { "a.txt": "a\n" })
|
|
1191
|
+
await land(repo, { "naive.txt": "naive\n" })
|
|
1192
|
+
expect(repo.readFile("naive.txt")).toBeUndefined()
|
|
1193
|
+
expect(repo.lastCommitMessage()).toMatch(/\nGtd-Kept: [0-9a-f]{40}$/)
|
|
1194
|
+
const rest = await provide(currentRest, repo)
|
|
1195
|
+
expect(rest.state).toBe("checking")
|
|
1196
|
+
expect(stalledAt(rest)).toBe(false)
|
|
1197
|
+
})
|
|
1198
|
+
})
|
|
1199
|
+
|
|
1200
|
+
describe("kept episode commits", () => {
|
|
1201
|
+
const KEEP = `import { agent, human } from "@pmelab/gtd/flows"
|
|
1202
|
+
|
|
1203
|
+
export default async () => {
|
|
1204
|
+
await human("idle")
|
|
1205
|
+
await agent("building", "build-prompt", { keep: true })
|
|
1206
|
+
await agent("checking", "check-prompt")
|
|
1207
|
+
}
|
|
1208
|
+
`
|
|
1209
|
+
|
|
1210
|
+
const keptRepo = async () => {
|
|
1211
|
+
const repo = repoWith(KEEP)
|
|
1212
|
+
await land(repo, { "a.txt": "a\n" })
|
|
1213
|
+
await land(repo, { "naive.txt": "naive\n" })
|
|
1214
|
+
return repo
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
it("builds kept: { hash, tree } for a marker whose kept object exists", async () => {
|
|
1218
|
+
const repo = await keptRepo()
|
|
1219
|
+
const rest = await provide(currentRest, repo)
|
|
1220
|
+
const marker = rest.setup.episode.at(-1)!
|
|
1221
|
+
const hash = /Gtd-Kept: ([0-9a-f]{40})/.exec(repo.lastCommitMessage()!)![1]!
|
|
1222
|
+
expect(marker.kept?.hash).toBe(hash)
|
|
1223
|
+
expect(marker.kept?.tree.read("naive.txt")).toBe("naive\n")
|
|
1224
|
+
expect(marker.tree.read("naive.txt")).toBeUndefined()
|
|
1225
|
+
})
|
|
1226
|
+
|
|
1227
|
+
it("builds no kept for a marker whose kept object is missing", async () => {
|
|
1228
|
+
const repo = await keptRepo()
|
|
1229
|
+
const message = repo
|
|
1230
|
+
.lastCommitMessage()!
|
|
1231
|
+
.replace(/Gtd-Kept: [0-9a-f]{40}/, `Gtd-Kept: ${"ab".repeat(20)}`)
|
|
1232
|
+
repo.hardResetTo("HEAD~1")
|
|
1233
|
+
repo.commitAsIs(message)
|
|
1234
|
+
const rest = await provide(currentRest, repo)
|
|
1235
|
+
expect(rest.state).toBe("checking")
|
|
1236
|
+
expect(rest.setup.episode.at(-1)!.kept).toBeUndefined()
|
|
1237
|
+
})
|
|
1238
|
+
|
|
1239
|
+
it("builds no kept for a commit without the trailer", async () => {
|
|
1240
|
+
const repo = await keptRepo()
|
|
1241
|
+
const rest = await provide(currentRest, repo)
|
|
1242
|
+
expect(rest.setup.episode.slice(0, -1).every((c) => c.kept === undefined)).toBe(true)
|
|
1243
|
+
})
|
|
1244
|
+
})
|
|
1245
|
+
|
|
1149
1246
|
describe("historyTrail", () => {
|
|
1150
1247
|
const finished = async (config = LINEAR) => {
|
|
1151
1248
|
const repo = repoWith(config)
|
|
@@ -1158,7 +1255,7 @@ describe("historyTrail", () => {
|
|
|
1158
1255
|
it("replays every commit of the closed run, with HEAD elsewhere", async () => {
|
|
1159
1256
|
const { repo, building, checking, tip } = await finished()
|
|
1160
1257
|
repo.writeFile("d.txt", "d\n")
|
|
1161
|
-
repo.
|
|
1258
|
+
repo.seedCommit("chore: unrelated")
|
|
1162
1259
|
const trail = await provide(historyTrail(tip), repo)
|
|
1163
1260
|
expect(trail.tip).toBe(tip)
|
|
1164
1261
|
expect(trail.ended).toBe("return")
|
|
@@ -1256,7 +1353,7 @@ export default async () => {
|
|
|
1256
1353
|
it("refuses naming the config file that differs at the tip", async () => {
|
|
1257
1354
|
const { repo, tip } = await finished()
|
|
1258
1355
|
repo.writeFile("gtd.config.ts", `${LINEAR}\n// edited\n`)
|
|
1259
|
-
repo.
|
|
1356
|
+
repo.seedCommit("chore: edit config")
|
|
1260
1357
|
const exit = await provideExit(historyTrail(tip), repo)
|
|
1261
1358
|
expect(Exit.isFailure(exit)).toBe(true)
|
|
1262
1359
|
expect(String(Exit.isFailure(exit) && exit.cause)).toContain("gtd.config.ts")
|
|
@@ -1266,7 +1363,7 @@ export default async () => {
|
|
|
1266
1363
|
const { repo, tip } = await finished()
|
|
1267
1364
|
repo.writeFile("gtd.config.ts", `import "./extra.ts"\n${LINEAR}`)
|
|
1268
1365
|
repo.writeFile("extra.ts", "export {}\n")
|
|
1269
|
-
repo.
|
|
1366
|
+
repo.seedCommit("chore: split config")
|
|
1270
1367
|
const exit = await provideExit(historyTrail(tip), repo)
|
|
1271
1368
|
expect(Exit.isFailure(exit)).toBe(true)
|
|
1272
1369
|
expect(String(Exit.isFailure(exit) && exit.cause)).toContain("gtd.config.ts")
|
package/src/edge/Edge.ts
CHANGED
|
@@ -43,7 +43,7 @@ import type { ScopeAccess } from "../flows/index.js"
|
|
|
43
43
|
import { steeringFormatFor } from "../steering/index.js"
|
|
44
44
|
import { judgeJson, UNATTRIBUTED_MODEL, type ModelCost, type StepAccess } from "../wire/index.js"
|
|
45
45
|
import { GTD_VERSION } from "../install/index.js"
|
|
46
|
-
import { processHistoryRef } from "../platform/index.js"
|
|
46
|
+
import { keptRef, processHistoryRef } from "../platform/index.js"
|
|
47
47
|
import { isAbsolute, relative, resolve, sep } from "node:path"
|
|
48
48
|
import { accessRefusal, type Landing, type RepoSnapshot } from "../step/index.js"
|
|
49
49
|
|
|
@@ -700,6 +700,7 @@ const promptDef = (
|
|
|
700
700
|
...optional("model", request.options.model),
|
|
701
701
|
...optional("system", request.options.system),
|
|
702
702
|
...optional("allowEmpty", request.options.allowEmpty),
|
|
703
|
+
...optional("keep", request.options.keep),
|
|
703
704
|
...optional("skills", skills),
|
|
704
705
|
...optional("access", access),
|
|
705
706
|
})
|
|
@@ -902,11 +903,19 @@ const setupFor = (
|
|
|
902
903
|
run.episode.base === undefined
|
|
903
904
|
? { hash: EMPTY_TREE, tree: treeFromRecord({}) }
|
|
904
905
|
: { hash: run.episode.base, tree: commitTree(workspace, run.episode.base) }
|
|
905
|
-
const
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
906
|
+
const git = yield* GitService
|
|
907
|
+
const episode: EpisodeCommit[] = []
|
|
908
|
+
for (const c of run.episode.commits) {
|
|
909
|
+
const tip = parseCommitMessage(c.message).kept
|
|
910
|
+
// A plain `rev-parse --verify` of a missing 40-hex object succeeds; `^{commit}` does not.
|
|
911
|
+
const exists = tip !== undefined && Option.isSome(yield* git.readRefOption(`${tip}^{commit}`))
|
|
912
|
+
episode.push({
|
|
913
|
+
hash: c.hash,
|
|
914
|
+
message: c.message,
|
|
915
|
+
tree: commitTree(workspace, c.hash),
|
|
916
|
+
...(exists ? { kept: { hash: tip, tree: commitTree(workspace, tip) } } : {}),
|
|
917
|
+
})
|
|
918
|
+
}
|
|
910
919
|
let pendingMemo: ReadonlyMap<string, string | undefined> | undefined
|
|
911
920
|
const setup: ReplaySetup = {
|
|
912
921
|
def,
|
|
@@ -1167,6 +1176,12 @@ const closingTrailers = (branch: Option.Option<string>) => ({
|
|
|
1167
1176
|
const isSettledNoop = (rest: Rest, to: StateName): boolean =>
|
|
1168
1177
|
rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state
|
|
1169
1178
|
|
|
1179
|
+
// A clean tree has nothing to set aside (an allowEmpty keep step lands as an ordinary empty commit).
|
|
1180
|
+
const keptTurn = (rest: Rest) =>
|
|
1181
|
+
rest.stepDef.keep === true && rest.changes.length > 0
|
|
1182
|
+
? { keepTurn: keptRef(rest.run.startHash, rest.state) }
|
|
1183
|
+
: {}
|
|
1184
|
+
|
|
1170
1185
|
const landingTo = (rest: Rest, to: StateName, branch: Option.Option<string>): Landing => {
|
|
1171
1186
|
if (isSettledNoop(rest, to)) return { kind: "noop", settled: true }
|
|
1172
1187
|
// The landing that leaves the flow's first step starts the process: it
|
|
@@ -1191,6 +1206,7 @@ const landingTo = (rest: Rest, to: StateName, branch: Option.Option<string>): La
|
|
|
1191
1206
|
...(finishes ? closingTrailers(branch) : {}),
|
|
1192
1207
|
},
|
|
1193
1208
|
...(finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {}),
|
|
1209
|
+
...keptTurn(rest),
|
|
1194
1210
|
}
|
|
1195
1211
|
}
|
|
1196
1212
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync, spawnSync } from "node:child_process"
|
|
2
|
-
import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"
|
|
3
3
|
import { tmpdir } from "node:os"
|
|
4
4
|
import { join } from "node:path"
|
|
5
5
|
import { describe, expect, it } from "vitest"
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
discardPending,
|
|
12
12
|
fetchHistoryRefs,
|
|
13
13
|
hardResetTo,
|
|
14
|
+
keepTurn,
|
|
14
15
|
mixedResetTo,
|
|
15
16
|
pathspec,
|
|
16
17
|
ScriptSurface,
|
|
@@ -61,7 +62,7 @@ const withFakeGit = (
|
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
const runWithFakeGit = (script: string, binDir: string): { status: number | null } =>
|
|
64
|
-
spawnSync("
|
|
65
|
+
spawnSync("sh", ["-c", script], {
|
|
65
66
|
env: { ...process.env, PATH: `${binDir}:${process.env["PATH"]}` },
|
|
66
67
|
encoding: "utf8",
|
|
67
68
|
})
|
|
@@ -74,6 +75,7 @@ const allBuilders: ReadonlyArray<[string, string]> = [
|
|
|
74
75
|
["hardResetTo", hardResetTo("HEAD~1")],
|
|
75
76
|
["discardPending", discardPending()],
|
|
76
77
|
["updateRef", updateRef("refs/worktree/gtd/review-head", "abc123")],
|
|
78
|
+
["keepTurn", keepTurn("refs/gtd/kept/abc/build.naive", "gtd(agent): it's done\n\nGtd-Format: 1")],
|
|
77
79
|
["deleteRef", deleteRef("refs/worktree/gtd/review-head")],
|
|
78
80
|
]
|
|
79
81
|
|
|
@@ -285,3 +287,64 @@ describe("fetchHistoryRefs", () => {
|
|
|
285
287
|
expect(runBashCheckSyntax(script)).toBe(0)
|
|
286
288
|
})
|
|
287
289
|
})
|
|
290
|
+
|
|
291
|
+
describe("keepTurn", () => {
|
|
292
|
+
const block = keepTurn("refs/gtd/kept/abc/build.naive", "gtd(agent): done")
|
|
293
|
+
|
|
294
|
+
it("a branch update refused by the guard exits 1, deleting the kept ref after reset -q", () => {
|
|
295
|
+
const binDir = mkdtempSync(join(tmpdir(), "gitscript-fakebin-"))
|
|
296
|
+
const log = join(binDir, "log")
|
|
297
|
+
writeFileSync(
|
|
298
|
+
join(binDir, "git"),
|
|
299
|
+
[
|
|
300
|
+
"#!/bin/sh",
|
|
301
|
+
`echo "$*" >> '${log}'`,
|
|
302
|
+
`case "$1" in`,
|
|
303
|
+
` rev-parse) echo head0 ;;`,
|
|
304
|
+
` write-tree) echo tree0 ;;`,
|
|
305
|
+
` commit-tree) echo commit0 ;;`,
|
|
306
|
+
` config) echo false ;;`,
|
|
307
|
+
` update-ref) [ "$2" = HEAD ] && exit 1; exit 0 ;;`,
|
|
308
|
+
` *) exit 0 ;;`,
|
|
309
|
+
`esac`,
|
|
310
|
+
].join("\n"),
|
|
311
|
+
{ mode: 0o755 },
|
|
312
|
+
)
|
|
313
|
+
const result = runWithFakeGit(`set -eu\n${block}`, binDir)
|
|
314
|
+
expect(result.status).toBe(1)
|
|
315
|
+
const calls = readFileSync(log, "utf8").trim().split("\n")
|
|
316
|
+
expect(calls).toContain("update-ref HEAD commit0 head0")
|
|
317
|
+
const resetAt = calls.indexOf("reset -q")
|
|
318
|
+
expect(resetAt).toBeGreaterThan(-1)
|
|
319
|
+
expect(calls.slice(resetAt + 1)).toEqual([
|
|
320
|
+
"update-ref -d refs/gtd/kept/abc/build.naive/commit0",
|
|
321
|
+
])
|
|
322
|
+
expect(calls).not.toContain("reset --hard HEAD")
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it("a failed reset -q exits 1 and keeps the kept ref", () => {
|
|
326
|
+
const binDir = mkdtempSync(join(tmpdir(), "gitscript-fakebin-"))
|
|
327
|
+
const log = join(binDir, "log")
|
|
328
|
+
writeFileSync(
|
|
329
|
+
join(binDir, "git"),
|
|
330
|
+
[
|
|
331
|
+
"#!/bin/sh",
|
|
332
|
+
`echo "$*" >> '${log}'`,
|
|
333
|
+
`case "$1" in`,
|
|
334
|
+
` rev-parse) echo head0 ;;`,
|
|
335
|
+
` write-tree) echo tree0 ;;`,
|
|
336
|
+
` commit-tree) echo commit0 ;;`,
|
|
337
|
+
` config) echo false ;;`,
|
|
338
|
+
` update-ref) [ "$2" = HEAD ] && exit 1; exit 0 ;;`,
|
|
339
|
+
` reset) exit 1 ;;`,
|
|
340
|
+
` *) exit 0 ;;`,
|
|
341
|
+
`esac`,
|
|
342
|
+
].join("\n"),
|
|
343
|
+
{ mode: 0o755 },
|
|
344
|
+
)
|
|
345
|
+
const result = runWithFakeGit(`set -eu\n${block}`, binDir)
|
|
346
|
+
expect(result.status).toBe(1)
|
|
347
|
+
const calls = readFileSync(log, "utf8").trim().split("\n")
|
|
348
|
+
expect(calls).not.toContain("update-ref -d refs/gtd/kept/abc/build.naive/commit0")
|
|
349
|
+
})
|
|
350
|
+
})
|
package/src/emit/GitScript.ts
CHANGED
|
@@ -17,7 +17,7 @@ const commitAllowEmpty = (message: string): string => {
|
|
|
17
17
|
return [
|
|
18
18
|
`if ! out=$(git commit --allow-empty -m ${m} 2>&1); then`,
|
|
19
19
|
` case "$out" in`,
|
|
20
|
-
` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m} ;;`,
|
|
20
|
+
` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m} || exit 1 ;;`,
|
|
21
21
|
` *) printf '%s\\n' "$out" >&2; exit 1 ;;`,
|
|
22
22
|
` esac`,
|
|
23
23
|
`fi`,
|
|
@@ -47,6 +47,37 @@ export const hardResetTo = (ref: string): string => `git reset --hard ${shellQuo
|
|
|
47
47
|
*/
|
|
48
48
|
export const discardPending = (): string => `git add -A && git reset --hard HEAD`
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* POSIX sh (runs under dash). The marker is a `commit-tree` over HEAD's own
|
|
52
|
+
* tree, so no hook runs on it and its tree is the pre-turn tree by
|
|
53
|
+
* construction; `commit-tree` ignores `commit.gpgSign`, hence the explicit
|
|
54
|
+
* `-S`, read (and failing closed) before any object or ref is written; the
|
|
55
|
+
* index is already staged by then, so an early exit leaves the turn staged.
|
|
56
|
+
* The kept ref is deleted only after `reset -q` succeeds: it is then the
|
|
57
|
+
* turn's only handle.
|
|
58
|
+
* The branch moves before the final hard reset, so if that reset fails the
|
|
59
|
+
* marker has landed and the turn is still in the working tree (kept ref
|
|
60
|
+
* present); every other failure leaves the branch untouched.
|
|
61
|
+
*/
|
|
62
|
+
export const keepTurn = (refPrefix: string, message: string): string => {
|
|
63
|
+
const keptRef = `${shellQuote(`${refPrefix}/`)}"$gtd_kept"`
|
|
64
|
+
return [
|
|
65
|
+
`git add -A || exit 1`,
|
|
66
|
+
`gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
|
|
67
|
+
`if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
|
|
68
|
+
`gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
|
|
69
|
+
`gtd_tree=$(git write-tree) || exit 1`,
|
|
70
|
+
`gtd_kept=$(git commit-tree "$gtd_tree" -p "$gtd_head" -m ${shellQuote(message)}) || exit 1`,
|
|
71
|
+
`git update-ref ${keptRef} "$gtd_kept" || exit 1`,
|
|
72
|
+
`gtd_marker=$(printf '%s\\nGtd-Kept: %s' ${shellQuote(message)} "$gtd_kept")`,
|
|
73
|
+
`if ! gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m "$gtd_marker") || ! git update-ref HEAD "$gtd_tip" "$gtd_head"; then`,
|
|
74
|
+
` git reset -q && git update-ref -d ${keptRef}`,
|
|
75
|
+
` exit 1`,
|
|
76
|
+
`fi`,
|
|
77
|
+
`git reset --hard HEAD`,
|
|
78
|
+
].join("\n")
|
|
79
|
+
}
|
|
80
|
+
|
|
50
81
|
export const updateRef = (ref: string, hash: string): string =>
|
|
51
82
|
`git update-ref ${shellQuote(ref)} ${shellQuote(hash)}`
|
|
52
83
|
|
|
@@ -100,6 +131,8 @@ const renderGitWrite = (write: GitWrite): string => {
|
|
|
100
131
|
return commitAll(write.message)
|
|
101
132
|
case "updateRef":
|
|
102
133
|
return updateRef(write.ref, write.target)
|
|
134
|
+
case "keepTurn":
|
|
135
|
+
return keepTurn(write.ref, write.message)
|
|
103
136
|
}
|
|
104
137
|
}
|
|
105
138
|
|
package/src/emit/index.ts
CHANGED
package/src/flags/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
FlowsProtocolError,
|
|
5
5
|
head,
|
|
6
6
|
installContext,
|
|
7
|
+
kept,
|
|
7
8
|
protocolMismatch,
|
|
8
9
|
type FlowContext,
|
|
9
10
|
} from "./runtime.js"
|
|
@@ -52,3 +53,35 @@ describe("the flows protocol handshake", () => {
|
|
|
52
53
|
)
|
|
53
54
|
})
|
|
54
55
|
})
|
|
56
|
+
|
|
57
|
+
describe("kept()", () => {
|
|
58
|
+
const change = { path: "a.ts", status: "added", before: undefined, after: "a" } as const
|
|
59
|
+
const withKept = (tip: unknown) =>
|
|
60
|
+
installContext({
|
|
61
|
+
kept: (name: string) => (name === "naive" ? tip : undefined),
|
|
62
|
+
} as unknown as FlowContext)
|
|
63
|
+
|
|
64
|
+
it("returns undefined as given", () => {
|
|
65
|
+
withKept(undefined)
|
|
66
|
+
expect(kept("naive")).toBeUndefined()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("wraps a tip's changes with paths and get", () => {
|
|
70
|
+
withKept({ hash: "abc", changes: [change] })
|
|
71
|
+
const tip = kept("naive")
|
|
72
|
+
expect(tip?.hash).toBe("abc")
|
|
73
|
+
expect(tip?.changes.paths).toEqual(["a.ts"])
|
|
74
|
+
expect(tip?.changes.get("a.ts")?.after).toBe("a")
|
|
75
|
+
expect(tip?.changes.get("b.ts")).toBeUndefined()
|
|
76
|
+
expect(tip?.changes).toHaveLength(1)
|
|
77
|
+
expect(kept("other")).toBeUndefined()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("throws the upgrade message on a context without kept, protocol still 1", () => {
|
|
81
|
+
installContext(context)
|
|
82
|
+
expect(() => kept("naive")).toThrow(
|
|
83
|
+
"gtd: kept() needs a newer gtd — upgrade the gtd that runs it",
|
|
84
|
+
)
|
|
85
|
+
expect(FLOWS_PROTOCOL).toBe(1)
|
|
86
|
+
})
|
|
87
|
+
})
|
package/src/flows/runtime.ts
CHANGED
|
@@ -24,6 +24,12 @@ export interface AgentOptions extends SteeringOptions {
|
|
|
24
24
|
* is an attempt: recorded, but the process stays at the step (a stall).
|
|
25
25
|
*/
|
|
26
26
|
readonly allowEmpty?: boolean | undefined
|
|
27
|
+
/**
|
|
28
|
+
* Set the turn aside: its tip goes to `refs/gtd/kept/<entry>/<step>/<hash>` and the
|
|
29
|
+
* branch stays where it was, a marker commit recording that the step ran.
|
|
30
|
+
* The step's full name must be usable as a git ref component.
|
|
31
|
+
*/
|
|
32
|
+
readonly keep?: boolean | undefined
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
export interface HumanOptions extends SteeringOptions {
|
|
@@ -157,6 +163,10 @@ export interface FlowContext {
|
|
|
157
163
|
readonly skillsFor: (localName: string) => readonly string[]
|
|
158
164
|
/** The folded access the memory scope `localName` lands in resolves to; `file` is the step's own steering file. */
|
|
159
165
|
readonly accessFor: (localName: string, file?: string) => AccessDef
|
|
166
|
+
/** Absent on an engine older than `kept`. */
|
|
167
|
+
readonly kept?: (
|
|
168
|
+
name: string,
|
|
169
|
+
) => { readonly hash: string; readonly changes: readonly Change[] } | undefined
|
|
160
170
|
}
|
|
161
171
|
|
|
162
172
|
/**
|
|
@@ -302,6 +312,27 @@ export const changesSince = (hash: string, pattern?: string): Changes => {
|
|
|
302
312
|
)
|
|
303
313
|
}
|
|
304
314
|
|
|
315
|
+
export interface KeptTip {
|
|
316
|
+
/** The kept commit. `""` while the keeping turn is still pending (a landing decision): never branch on it, the landed replay sees the real hash. */
|
|
317
|
+
readonly hash: string
|
|
318
|
+
/** The kept tip against the commit it diverged from. */
|
|
319
|
+
readonly changes: Changes
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The latest kept tip of step `name` (scoped from here, same as `agent()`) in
|
|
324
|
+
* this episode, or `undefined` when none.
|
|
325
|
+
*/
|
|
326
|
+
export const kept = (name: string): KeptTip | undefined => {
|
|
327
|
+
const context = ctx()
|
|
328
|
+
// FLOWS_PROTOCOL stays 1, so an older engine's context simply lacks `kept`.
|
|
329
|
+
if (typeof context.kept !== "function") {
|
|
330
|
+
throw new Error("gtd: kept() needs a newer gtd — upgrade the gtd that runs it")
|
|
331
|
+
}
|
|
332
|
+
const tip = context.kept(name)
|
|
333
|
+
return tip === undefined ? undefined : { hash: tip.hash, changes: toChanges(tip.changes) }
|
|
334
|
+
}
|
|
335
|
+
|
|
305
336
|
/** The commit the process stands on at this point of the flow. */
|
|
306
337
|
export const head = (): string => ctx().head()
|
|
307
338
|
|
package/src/platform/Git.test.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process"
|
|
2
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { NodeContext } from "@effect/platform-node"
|
|
1
6
|
import { Cause, Effect, Exit } from "effect"
|
|
2
7
|
import { describe, expect, it } from "vitest"
|
|
3
|
-
import { InvalidRefError, resolvedRefOrCorrupted } from "./Git.js"
|
|
8
|
+
import { GitService, InvalidRefError, resolvedRefOrCorrupted } from "./Git.js"
|
|
9
|
+
import { Host } from "./index.js"
|
|
4
10
|
import { gitTiers, runGitServiceContract, runGitScriptContract } from "../testkit/index.js"
|
|
5
11
|
|
|
6
12
|
/**
|
|
@@ -45,3 +51,92 @@ for (const makeTier of gitTiers) {
|
|
|
45
51
|
runGitScriptContract(makeTier)
|
|
46
52
|
})
|
|
47
53
|
}
|
|
54
|
+
|
|
55
|
+
describe("diffPaths [Live]", () => {
|
|
56
|
+
it("stays repo-root-relative with diff.relative=true and a subdirectory cwd", async () => {
|
|
57
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-diffpaths-")))
|
|
58
|
+
try {
|
|
59
|
+
const git = (...args: string[]) =>
|
|
60
|
+
execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: "pipe" }).trim()
|
|
61
|
+
git("init")
|
|
62
|
+
git("config", "user.email", "test@test.com")
|
|
63
|
+
git("config", "user.name", "Test")
|
|
64
|
+
git("config", "commit.gpgsign", "false")
|
|
65
|
+
git("config", "diff.relative", "true")
|
|
66
|
+
mkdirSync(join(root, "sub"))
|
|
67
|
+
writeFileSync(join(root, "sub", "a.txt"), "a")
|
|
68
|
+
writeFileSync(join(root, "top.txt"), "t")
|
|
69
|
+
git("add", "-A")
|
|
70
|
+
git("commit", "-m", "one")
|
|
71
|
+
const base = git("rev-parse", "HEAD")
|
|
72
|
+
writeFileSync(join(root, "sub", "a.txt"), "b")
|
|
73
|
+
writeFileSync(join(root, "top.txt"), "u")
|
|
74
|
+
git("add", "-A")
|
|
75
|
+
git("commit", "-m", "two")
|
|
76
|
+
const tip = git("rev-parse", "HEAD")
|
|
77
|
+
const paths = await Effect.runPromise(
|
|
78
|
+
Effect.flatMap(GitService, (g) => g.diffPaths(base, tip)).pipe(
|
|
79
|
+
Effect.provide(GitService.Live),
|
|
80
|
+
Effect.provide(Host.layer({ root: join(root, "sub"), home: root, env: {} })),
|
|
81
|
+
Effect.provide(NodeContext.layer),
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
expect([...paths].sort()).toEqual(["sub/a.txt", "top.txt"])
|
|
85
|
+
} finally {
|
|
86
|
+
rmSync(root, { recursive: true, force: true })
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// An exported GIT_DIR/GIT_WORK_TREE would send this test's commits to the real repo;
|
|
91
|
+
// the pinned config keeps a global commit.gpgsign or missing identity from failing them.
|
|
92
|
+
const cleanEnv = {
|
|
93
|
+
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))),
|
|
94
|
+
GIT_CONFIG_COUNT: "3",
|
|
95
|
+
GIT_CONFIG_KEY_0: "commit.gpgsign",
|
|
96
|
+
GIT_CONFIG_VALUE_0: "false",
|
|
97
|
+
GIT_CONFIG_KEY_1: "user.email",
|
|
98
|
+
GIT_CONFIG_VALUE_1: "test@test.com",
|
|
99
|
+
GIT_CONFIG_KEY_2: "user.name",
|
|
100
|
+
GIT_CONFIG_VALUE_2: "Test",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
it("reports a submodule pointer bump whatever diff.ignoreSubmodules and .gitmodules ignore say", async () => {
|
|
104
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-diffpaths-sm-")))
|
|
105
|
+
try {
|
|
106
|
+
const run = (cwd: string, ...args: string[]) =>
|
|
107
|
+
execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe", env: cleanEnv }).trim()
|
|
108
|
+
const source = join(root, "source")
|
|
109
|
+
const repo = join(root, "super")
|
|
110
|
+
mkdirSync(source)
|
|
111
|
+
mkdirSync(repo)
|
|
112
|
+
run(source, "init")
|
|
113
|
+
writeFileSync(join(source, "f.txt"), "1")
|
|
114
|
+
run(source, "add", "-A")
|
|
115
|
+
run(source, "commit", "-m", "s1")
|
|
116
|
+
run(repo, "init")
|
|
117
|
+
run(repo, "config", "diff.ignoreSubmodules", "all")
|
|
118
|
+
run(repo, "-c", "protocol.file.allow=always", "submodule", "add", source, "sub")
|
|
119
|
+
run(repo, "config", "-f", ".gitmodules", "submodule.sub.ignore", "all")
|
|
120
|
+
run(repo, "add", "-A")
|
|
121
|
+
run(repo, "commit", "-m", "one")
|
|
122
|
+
const base = run(repo, "rev-parse", "HEAD")
|
|
123
|
+
const clone = join(repo, "sub")
|
|
124
|
+
writeFileSync(join(clone, "f.txt"), "2")
|
|
125
|
+
run(clone, "add", "-A")
|
|
126
|
+
run(clone, "commit", "-m", "s2")
|
|
127
|
+
run(repo, "add", "--force", "sub")
|
|
128
|
+
run(repo, "commit", "-m", "two")
|
|
129
|
+
const tip = run(repo, "rev-parse", "HEAD")
|
|
130
|
+
const paths = await Effect.runPromise(
|
|
131
|
+
Effect.flatMap(GitService, (g) => g.diffPaths(base, tip)).pipe(
|
|
132
|
+
Effect.provide(GitService.Live),
|
|
133
|
+
Effect.provide(Host.layer({ root: repo, home: root, env: {} })),
|
|
134
|
+
Effect.provide(NodeContext.layer),
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
expect([...paths]).toEqual(["sub"])
|
|
138
|
+
} finally {
|
|
139
|
+
rmSync(root, { recursive: true, force: true })
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
})
|