@pmelab/gtd 24.2.0 → 24.3.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.
Files changed (38) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +26 -2
  3. package/dist/chunks/{Schema-Dh-mkwkq.mjs → Schema-B5yK7QrT.mjs} +47 -1
  4. package/dist/chunks/{reserved-ejn-LVYO.mjs → reserved-DJwU5C4z.mjs} +1 -1
  5. package/dist/gtd.bundle.mjs +337 -55
  6. package/dist/testkit.mjs +477 -65
  7. package/dist/workflows/feature.mjs +1 -1
  8. package/dist/workflows/fix.mjs +1 -1
  9. package/dist/workflows/review.mjs +1 -1
  10. package/package.json +1 -1
  11. package/src/cli/Cli.test.ts +57 -0
  12. package/src/cli/Cli.ts +89 -9
  13. package/src/cli/History.test.ts +225 -0
  14. package/src/cli/History.ts +143 -0
  15. package/src/cli/Output.ts +68 -0
  16. package/src/cli/Program.test.ts +7 -1
  17. package/src/cli/Program.ts +23 -64
  18. package/src/edge/Edge.test.ts +256 -1
  19. package/src/edge/Edge.ts +217 -39
  20. package/src/edge/index.ts +1 -0
  21. package/src/emit/GitScript.test.ts +27 -0
  22. package/src/emit/GitScript.ts +18 -2
  23. package/src/emit/index.ts +1 -0
  24. package/src/platform/Git.ts +48 -10
  25. package/src/platform/RetainedHistory.test.ts +14 -0
  26. package/src/platform/RetainedHistory.ts +5 -0
  27. package/src/platform/index.ts +7 -1
  28. package/src/replay/Trailers.test.ts +32 -0
  29. package/src/replay/Trailers.ts +17 -0
  30. package/src/step/LandStep.ts +3 -1
  31. package/src/step/RepoSnapshot.ts +7 -1
  32. package/src/step/planStart.test.ts +6 -3
  33. package/src/step/planStep.test.ts +29 -1
  34. package/src/step/planStep.ts +8 -0
  35. package/src/testkit/EmittedScriptRecognizer.ts +1 -1
  36. package/src/testkit/FakeGitOperations.ts +6 -2
  37. package/src/testkit/GitTiers.ts +133 -2
  38. package/src/testkit/InMemRepo.ts +26 -6
package/src/edge/Edge.ts CHANGED
@@ -1,10 +1,11 @@
1
- import { Effect } from "effect"
1
+ import { Effect, Option } from "effect"
2
2
  import {
3
3
  Narrator,
4
4
  GitService,
5
5
  Host,
6
6
  Workspace,
7
7
  type GitOperations,
8
+ type HostOps,
8
9
  type WorkspaceOps,
9
10
  } from "../platform/index.js"
10
11
  import {
@@ -42,6 +43,9 @@ import {
42
43
  import type { ScopeAccess } from "../flows/index.js"
43
44
  import { steeringFormatFor } from "../steering/index.js"
44
45
  import { judgeJson, UNATTRIBUTED_MODEL, type ModelCost, type StepAccess } from "../wire/index.js"
46
+ import { GTD_VERSION } from "../install/index.js"
47
+ import { processHistoryRef } from "../platform/index.js"
48
+ import { isAbsolute, relative, resolve, sep } from "node:path"
45
49
  import { accessRefusal, type Landing, type RepoSnapshot } from "../step/index.js"
46
50
 
47
51
  export { UNATTRIBUTED_MODEL }
@@ -405,42 +409,59 @@ export const currentRun: Effect.Effect<ProcessRun, Error, ConfigRequirements> =
405
409
  },
406
410
  )
407
411
 
408
- /** The process HEAD closes or sits inside — `gtd summary`'s run. */
409
- export const summaryRun: Effect.Effect<ProcessRun, Error, ConfigRequirements> = Effect.gen(
410
- function* () {
411
- const git = yield* GitService
412
- const def = (yield* (yield* ConfigService).load).workflow
413
- // Cheap: just HEAD's own subject, to decide which of the two reads below
414
- // this run needs — never the commits before it. Its hash is also this
415
- // run's ONE resolution of literal HEAD: every further read below is
416
- // pinned to it, rather than re-resolving a `head` that could move.
417
- const headEntry = (yield* git.subjectHistory(1, 0))[0]
418
- if (headEntry === undefined) {
412
+ // `tip` is resolved to a hash once: every read below is pinned to it, so a
413
+ // moving HEAD cannot split one run across two commits.
414
+ const runAtTip = (
415
+ git: GitOperations,
416
+ def: WorkflowDefinition,
417
+ tip: { readonly hash: string; readonly subject: string } | undefined,
418
+ ): Effect.Effect<ProcessRun, Error> =>
419
+ Effect.gen(function* () {
420
+ if (tip === undefined) {
419
421
  // No commits at all — `historyUpTo` folds an empty repository to an
420
422
  // empty run on its own; there is no hash yet to pin anything to.
421
423
  const history = yield* historyUpTo(git, def, undefined)
422
424
  return yield* runOf(history, locateEpisode(def, history))
423
425
  }
424
- const closes = parseCommitMessage(headEntry.subject).parsed?.to === def.initial
426
+ const closes = parseCommitMessage(tip.subject).parsed?.to === def.initial
425
427
  if (!closes) {
426
- const history = yield* historyUpTo(git, def, headEntry.hash)
428
+ const history = yield* historyUpTo(git, def, tip.hash)
427
429
  return yield* runOf(history, locateEpisode(def, history))
428
430
  }
429
- // HEAD itself closes the process: its own subject would otherwise read as
430
- // a boundary to `findBoundaryBase` (landing the initial state), stopping
431
+ // The tip itself closes the process: its own subject would otherwise read
432
+ // as a boundary to `findBoundaryBase` (landing the initial state), stopping
431
433
  // the page one commit too soon. Search for the PRIOR episode's boundary
432
- // from HEAD's parent instead, then read the closing commit separately —
434
+ // from its parent instead, then read the closing commit separately —
433
435
  // `commitHistory` falls back to the whole history when that parent ref
434
- // doesn't resolve (HEAD is the repository's root commit).
435
- const parentRef = `${headEntry.hash}~1`
436
+ // doesn't resolve (the tip is the repository's root commit).
437
+ const parentRef = `${tip.hash}~1`
436
438
  const before = yield* historyUpTo(git, def, parentRef)
437
439
  const location = locateEpisode(def, before)
438
- let closing = yield* git.commitHistory(parentRef, headEntry.hash)
439
- if (closing.length === 0) closing = yield* git.commitHistory(undefined, headEntry.hash)
440
- return yield* runOf([...before, ...closing], { ...location }, headEntry.hash)
440
+ let closing = yield* git.commitHistory(parentRef, tip.hash)
441
+ if (closing.length === 0) closing = yield* git.commitHistory(undefined, tip.hash)
442
+ return yield* runOf([...before, ...closing], { ...location }, tip.hash)
443
+ })
444
+
445
+ /** The process HEAD closes or sits inside — `gtd summary`'s run. */
446
+ export const summaryRun: Effect.Effect<ProcessRun, Error, ConfigRequirements> = Effect.gen(
447
+ function* () {
448
+ const git = yield* GitService
449
+ const def = (yield* (yield* ConfigService).load).workflow
450
+ // Cheap: just HEAD's own subject, never the commits before it.
451
+ return yield* runAtTip(git, def, (yield* git.subjectHistory(1, 0))[0])
441
452
  },
442
453
  )
443
454
 
455
+ /** The process whose finishing commit is `tip` — reads from `tip`'s own ancestry, so it works when no branch reaches it. */
456
+ export const closedRunAt = (tip: string): Effect.Effect<ProcessRun, Error, ConfigRequirements> =>
457
+ Effect.gen(function* () {
458
+ const git = yield* GitService
459
+ const def = (yield* (yield* ConfigService).load).workflow
460
+ const hash = yield* git.resolveRef(tip)
461
+ const subject = yield* git.lastCommitSubject(hash)
462
+ return yield* runAtTip(git, def, { hash, subject })
463
+ })
464
+
444
465
  // ── Trees ───────────────────────────────────────────────────────────────────
445
466
 
446
467
  const commitTree = (workspace: WorkspaceOps, hash: string): TreeView => {
@@ -852,17 +873,14 @@ const costByModel = (entries: readonly CostEntry[]): ModelCost[] => {
852
873
 
853
874
  export type RestRequirements = ConfigRequirements
854
875
 
855
- /** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
856
- export const restAt = (
857
- ref: string | undefined,
876
+ const setupFor = (
877
+ config: ConfigOperations,
878
+ run: ProcessRun,
879
+ workspace: WorkspaceOps,
880
+ host: HostOps,
858
881
  startOverrides: Readonly<Record<string, string>> = {},
859
- ): Effect.Effect<Rest, Error, RestRequirements> =>
882
+ ) =>
860
883
  Effect.gen(function* () {
861
- const git = yield* GitService
862
- const config = yield* (yield* ConfigService).load
863
- const workspace = yield* Workspace
864
- const host = yield* Host
865
- const run = yield* computeProcessRun(git, config.workflow, ref)
866
884
  const resolved = yield* workflowOf(config, run)
867
885
  const def = resolved.def
868
886
  const { vars, env } = settingsFor(resolved, run.pinnedVars, host.env, startOverrides)
@@ -904,6 +922,27 @@ export const restAt = (
904
922
  episode,
905
923
  pendingWorktree: () => (pendingMemo ??= workspace.worktreeSync()),
906
924
  }
925
+ return { resolved, def, vars, env, setup }
926
+ })
927
+
928
+ /** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
929
+ export const restAt = (
930
+ ref: string | undefined,
931
+ startOverrides: Readonly<Record<string, string>> = {},
932
+ ): Effect.Effect<Rest, Error, RestRequirements> =>
933
+ Effect.gen(function* () {
934
+ const git = yield* GitService
935
+ const config = yield* (yield* ConfigService).load
936
+ const workspace = yield* Workspace
937
+ const host = yield* Host
938
+ const run = yield* computeProcessRun(git, config.workflow, ref)
939
+ const { resolved, def, vars, env, setup } = yield* setupFor(
940
+ config,
941
+ run,
942
+ workspace,
943
+ host,
944
+ startOverrides,
945
+ )
907
946
  // No `pending` tree: `restAt` resolves the LANDED process only, never a
908
947
  // pending working-tree edit (that's `decideLanding`'s own `replayFor`
909
948
  // call, below, deciding what landing right now WOULD do). `gtd lsp` caches
@@ -1121,15 +1160,26 @@ const initialStepRefusal = (
1121
1160
  }
1122
1161
  : undefined
1123
1162
 
1124
- const landingTo = (rest: Rest, to: StateName): Landing => {
1125
- if (rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state) {
1126
- return { kind: "noop", settled: true }
1127
- }
1163
+ const closingTrailers = (branch: Option.Option<string>) => ({
1164
+ version: GTD_VERSION,
1165
+ ...(Option.isSome(branch) ? { branch: branch.value } : {}),
1166
+ })
1167
+
1168
+ const isSettledNoop = (rest: Rest, to: StateName): boolean =>
1169
+ rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state
1170
+
1171
+ const landingTo = (rest: Rest, to: StateName, branch: Option.Option<string>): Landing => {
1172
+ if (isSettledNoop(rest, to)) return { kind: "noop", settled: true }
1128
1173
  // The landing that leaves the flow's first step starts the process: it
1129
1174
  // pins the process settings every later invocation reads back.
1130
- const starts = noProcessUnderway(rest) && to !== rest.def.initial
1175
+ const underway = !noProcessUnderway(rest)
1176
+ const starts = !underway && to !== rest.def.initial
1131
1177
  const refused = starts ? multilineRefusal(rest.vars) : undefined
1132
1178
  if (refused !== undefined) return refused
1179
+ // Landing on the first step while a process is underway ends it (flow
1180
+ // return and `restart()` alike); the script then points the history ref at
1181
+ // that very commit.
1182
+ const finishes = underway && to === rest.def.initial
1133
1183
  return {
1134
1184
  kind: "commit",
1135
1185
  to,
@@ -1139,7 +1189,9 @@ const landingTo = (rest: Rest, to: StateName): Landing => {
1139
1189
  to,
1140
1190
  step: rest.step.id,
1141
1191
  ...(starts ? { vars: rest.vars, config: rest.config } : {}),
1192
+ ...(finishes ? closingTrailers(branch) : {}),
1142
1193
  },
1194
+ ...(finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {}),
1143
1195
  }
1144
1196
  }
1145
1197
 
@@ -1151,6 +1203,7 @@ const landingTo = (rest: Rest, to: StateName): Landing => {
1151
1203
  const decideLanding = (
1152
1204
  rest: Rest,
1153
1205
  verdicts: readonly JudgeVerdict[] | undefined,
1206
+ branch: Option.Option<string>,
1154
1207
  ): Effect.Effect<Landing, Error> =>
1155
1208
  Effect.gen(function* () {
1156
1209
  const early = cleanLanding(rest)
@@ -1171,12 +1224,12 @@ const decideLanding = (
1171
1224
  )
1172
1225
  const to = yield* landingTarget(outcome, rest)
1173
1226
  if (typeof to !== "string") return to
1174
- return landingTo(rest, to)
1227
+ return landingTo(rest, to, branch)
1175
1228
  })
1176
1229
 
1177
1230
  /** Where landing the pending turn would leave the process, or `undefined` when it would land nothing. */
1178
1231
  export const previewLanding = (rest: Rest): Effect.Effect<StateName | undefined> =>
1179
- decideLanding(rest, undefined).pipe(
1232
+ decideLanding(rest, undefined, Option.none()).pipe(
1180
1233
  Effect.map((landing) => (landing.kind === "commit" ? landing.to : undefined)),
1181
1234
  Effect.catchAll(() => Effect.succeed(undefined)),
1182
1235
  )
@@ -1187,7 +1240,8 @@ export const snapshotFromRest = (
1187
1240
  verdicts?: readonly JudgeVerdict[],
1188
1241
  ): Effect.Effect<RepoSnapshot, Error, Workspace | GitService> =>
1189
1242
  Effect.gen(function* () {
1190
- const landing = yield* decideLanding(rest, verdicts)
1243
+ const branch = yield* (yield* GitService).currentBranch()
1244
+ const landing = yield* decideLanding(rest, verdicts, branch)
1191
1245
  return {
1192
1246
  stepDef: rest.stepDef,
1193
1247
  state: rest.state,
@@ -1223,3 +1277,127 @@ export const summaryFor = (
1223
1277
  ...settingsFor(resolved, run.pinnedVars, host.env),
1224
1278
  })
1225
1279
  })
1280
+
1281
+ export interface HistoryStep {
1282
+ readonly hash: string
1283
+ readonly subject: string
1284
+ readonly actor: string
1285
+ readonly from: StateName | undefined
1286
+ readonly to: StateName
1287
+ readonly verdicts: readonly JudgeVerdict[]
1288
+ }
1289
+
1290
+ export interface HistoryTrail {
1291
+ readonly tip: string
1292
+ readonly steps: readonly HistoryStep[]
1293
+ readonly ended: "return" | "restart"
1294
+ readonly version: {
1295
+ readonly recorded: string | undefined
1296
+ readonly running: string
1297
+ }
1298
+ /** Set when recorded !== running (a missing recorded version counts as different). */
1299
+ readonly versionWarning?: string
1300
+ }
1301
+
1302
+ /** Repository-relative paths of the config files today's config and the process's own record name; one outside the repository cannot be read at a ref, so it is left out. */
1303
+ const inRepoConfigPaths = (
1304
+ root: string,
1305
+ files: readonly string[],
1306
+ recorded: ConfigFingerprint,
1307
+ ): readonly string[] => {
1308
+ const paths = new Set<string>(Object.keys(recorded))
1309
+ for (const file of files) paths.add(relative(root, resolve(root, file)).split(sep).join("/"))
1310
+ return [...paths].filter((p) => p !== "" && !p.startsWith("..") && !isAbsolute(p)).sort()
1311
+ }
1312
+
1313
+ const configRefusal = (tip: string, paths: readonly string[]): Error =>
1314
+ new Error(
1315
+ `gtd history: refused — config differs at ${tip.slice(0, 7)}: ${paths.join(", ")}. ` +
1316
+ `A history ref replays under today's config, so it is unreadable after the config changes`,
1317
+ )
1318
+
1319
+ const differingConfigPaths = (
1320
+ git: GitService["Type"],
1321
+ workspace: Workspace["Type"],
1322
+ root: string,
1323
+ config: { readonly workflowFiles: readonly string[] },
1324
+ run: ProcessRun,
1325
+ hash: string,
1326
+ ) =>
1327
+ Effect.gen(function* () {
1328
+ const differing: string[] = []
1329
+ for (const path of inRepoConfigPaths(
1330
+ root,
1331
+ config.workflowFiles,
1332
+ run.pinnedConfig?.files ?? {},
1333
+ )) {
1334
+ const atTip = yield* Effect.orElseSucceed(git.readFileAtRef(hash, path), () => undefined)
1335
+ if (atTip !== workspace.readSync(path)) differing.push(path)
1336
+ }
1337
+ return differing
1338
+ })
1339
+
1340
+ const notClosedError = (
1341
+ hash: string,
1342
+ outcome: Exclude<Awaited<ReturnType<typeof replayFor>>, { kind: "ended" }>,
1343
+ ): Error =>
1344
+ outcome.kind === "rest"
1345
+ ? new Error(`gtd history: ${hash.slice(0, 7)} does not close a process under today's config`)
1346
+ : (replayError(outcome) ?? new Error("gtd history: replay failed"))
1347
+
1348
+ const historySteps = (run: ProcessRun): readonly HistoryStep[] => {
1349
+ const messages = new Map(run.episode.commits.map((c) => [c.hash, c.message]))
1350
+ return run.trace.map((entry): HistoryStep => {
1351
+ const message = parseCommitMessage(messages.get(entry.hash) ?? "")
1352
+ return {
1353
+ hash: entry.hash,
1354
+ subject: (messages.get(entry.hash) ?? "").split("\n")[0] ?? "",
1355
+ actor: entry.actor,
1356
+ from: message.parsed?.from,
1357
+ to: entry.state,
1358
+ verdicts: message.judge,
1359
+ }
1360
+ })
1361
+ }
1362
+
1363
+ const versionWarningFor = (recorded: string | undefined): string | undefined =>
1364
+ recorded === GTD_VERSION
1365
+ ? undefined
1366
+ : `gtd: warning: this history was recorded by ${recorded === undefined ? "an unknown gtd version" : `gtd ${recorded}`} and is replayed by gtd ${GTD_VERSION} — a workflow imported from @pmelab/gtd/workflows may have changed since`
1367
+
1368
+ /**
1369
+ * Replays the closed run at `tip` under today's config. The workflow is not
1370
+ * fingerprinted for a version change: one imported from `@pmelab/gtd/workflows`
1371
+ * moves with every upgrade, and the version warning is the only signal.
1372
+ */
1373
+ export const historyTrail = (tip: string): Effect.Effect<HistoryTrail, Error, RestRequirements> =>
1374
+ Effect.gen(function* () {
1375
+ const git = yield* GitService
1376
+ const config = yield* (yield* ConfigService).load
1377
+ const workspace = yield* Workspace
1378
+ const host = yield* Host
1379
+ const hash = yield* git.resolveRef(tip)
1380
+ const run = yield* closedRunAt(hash)
1381
+
1382
+ const differing = yield* differingConfigPaths(git, workspace, host.root, config, run, hash)
1383
+ if (differing.length > 0) return yield* Effect.fail(configRefusal(hash, differing))
1384
+
1385
+ const { setup } = yield* setupFor(config, run, workspace, host)
1386
+ yield* workspace.episodeTrees(
1387
+ run.episode.base,
1388
+ run.episode.commits.map((c) => c.hash),
1389
+ )
1390
+ const outcome = yield* Effect.promise(() => replayFor(setup))
1391
+ if (outcome.kind !== "ended") return yield* Effect.fail(notClosedError(hash, outcome))
1392
+
1393
+ const steps = historySteps(run)
1394
+ const recorded = parseCommitMessage(yield* git.lastCommitMessage(hash)).version
1395
+ const versionWarning = versionWarningFor(recorded)
1396
+ return {
1397
+ tip: hash,
1398
+ steps,
1399
+ ended: outcome.via,
1400
+ version: { recorded, running: GTD_VERSION },
1401
+ ...(versionWarning !== undefined ? { versionWarning } : {}),
1402
+ }
1403
+ })
package/src/edge/index.ts CHANGED
@@ -13,4 +13,5 @@ export {
13
13
  previewLanding,
14
14
  snapshotFromRest,
15
15
  summaryFor,
16
+ historyTrail,
16
17
  } from "./Edge.js"
@@ -9,6 +9,7 @@ import {
9
9
  commitAsIs,
10
10
  deleteRef,
11
11
  discardPending,
12
+ fetchHistoryRefs,
12
13
  hardResetTo,
13
14
  mixedResetTo,
14
15
  pathspec,
@@ -258,3 +259,29 @@ describe("ScriptSurface.render", () => {
258
259
  expect(runBashCheckSyntax(script)).toBe(0)
259
260
  })
260
261
  })
262
+
263
+ describe("ScriptSurface.render — updateRef", () => {
264
+ const steps: readonly LandStep[] = [
265
+ { kind: "gitWrite", write: { kind: "commitAll", message: "gtd(human): building → done" } },
266
+ { kind: "gitWrite", write: { kind: "updateRef", ref: "refs/gtd/history/abc", target: "HEAD" } },
267
+ ]
268
+
269
+ it("renders git update-ref after the commit, under set -eu", () => {
270
+ const script = ScriptSurface.render(steps)
271
+ expect(script.startsWith("set -eu")).toBe(true)
272
+ expect(script).toContain("git update-ref 'refs/gtd/history/abc' 'HEAD'")
273
+ expect(script.indexOf("git commit")).toBeLessThan(script.indexOf("git update-ref"))
274
+ expect(runBashCheckSyntax(script)).toBe(0)
275
+ })
276
+ })
277
+
278
+ describe("fetchHistoryRefs", () => {
279
+ it("quotes the remote and uses a non-forcing history refspec", () => {
280
+ const script = fetchHistoryRefs("o'rigin")
281
+ expect(script).toContain("'o'\\''rigin'")
282
+ expect(script).toContain("refs/gtd/history/*:refs/gtd/history/*")
283
+ expect(script).not.toContain("+refs/")
284
+ expect(script).toContain("history refs arrived")
285
+ expect(runBashCheckSyntax(script)).toBe(0)
286
+ })
287
+ })
@@ -24,8 +24,9 @@ const commitAllowEmpty = (message: string): string => {
24
24
  ].join("\n")
25
25
  }
26
26
 
27
- /** `git add -A` then `commitAllowEmpty`, joined with `&&` so a failing `git add` never reaches the commit. */
28
- export const commitAll = (message: string): string => `git add -A &&\n${commitAllowEmpty(message)}`
27
+ /** `git add -A || exit 1` (an `&&` left side would not abort under `set -e`) then `commitAllowEmpty`. */
28
+ export const commitAll = (message: string): string =>
29
+ `git add -A || exit 1\n${commitAllowEmpty(message)}`
29
30
 
30
31
  /** No current caller — see `Git.ts`'s `GitWriterOperations.commitAsIs` for why it's kept anyway. */
31
32
  export const commitAsIs = (message: string): string => commitAllowEmpty(message)
@@ -52,6 +53,19 @@ export const updateRef = (ref: string, hash: string): string =>
52
53
  /** `git update-ref -d <ref>` — idempotent: deleting a missing ref is already a no-op in real git. */
53
54
  export const deleteRef = (ref: string): string => `git update-ref -d ${shellQuote(ref)}`
54
55
 
56
+ /**
57
+ * No `+` on the refspec: a local ref the remote's tip does not descend from
58
+ * fails the fetch instead of being overwritten (a fast-forward still applies).
59
+ */
60
+ export const fetchHistoryRefs = (remote: string): string =>
61
+ [
62
+ `set -eu`,
63
+ `before=$(git for-each-ref --format='x' 'refs/gtd/history/' | wc -l)`,
64
+ `git fetch ${shellQuote(remote)} 'refs/gtd/history/*:refs/gtd/history/*'`,
65
+ `after=$(git for-each-ref --format='x' 'refs/gtd/history/' | wc -l)`,
66
+ `printf '%s history refs arrived\\n' "$((after - before))"`,
67
+ ].join("\n")
68
+
55
69
  // ── ScriptSurface: LandStep[] → shell ────────────────────────────────────────
56
70
  //
57
71
  // The sole place `src/step/`'s `LandStep` data becomes runnable shell text.
@@ -84,6 +98,8 @@ const renderGitWrite = (write: GitWrite): string => {
84
98
  switch (write.kind) {
85
99
  case "commitAll":
86
100
  return commitAll(write.message)
101
+ case "updateRef":
102
+ return updateRef(write.ref, write.target)
87
103
  }
88
104
  }
89
105
 
package/src/emit/index.ts CHANGED
@@ -20,6 +20,7 @@ export {
20
20
  discardPending,
21
21
  updateRef,
22
22
  deleteRef,
23
+ fetchHistoryRefs,
23
24
  type RunnableScript,
24
25
  ScriptSurface,
25
26
  } from "./GitScript.js"
@@ -4,14 +4,22 @@ import { Host } from "./Host.js"
4
4
 
5
5
  export interface GitReaderOperations {
6
6
  readonly lastCommitSubject: (ref?: string) => Effect.Effect<string, Error>
7
+ /** Full commit message (subject + body) of `ref` (default HEAD) — keeps the body, unlike `lastCommitSubject`. */
8
+ readonly lastCommitMessage: (ref?: string) => Effect.Effect<string, Error>
9
+ /** The checked-out branch's name; `Option.none` on a detached HEAD (never fails). */
10
+ readonly currentBranch: () => Effect.Effect<Option.Option<string>, Error>
7
11
  /**
8
- * Full commit message (subject + body) of HEAD — keeps the body, unlike
9
- * `lastCommitSubject`. No current caller: it read back a `Gtd-History:`
10
- * trailer for the now-removed squash finale's `restorability` check; kept
11
- * deliberately rather than deleted, as a plain, reusable git primitive a
12
- * future edge concern may need again.
12
+ * Refs under `prefix`, newest tip committer date first (`date` is ISO 8601
13
+ * strict); `[]` when none.
13
14
  */
14
- readonly lastCommitMessage: () => Effect.Effect<string, Error>
15
+ readonly listRefs: (prefix: string) => Effect.Effect<
16
+ ReadonlyArray<{
17
+ readonly ref: string
18
+ readonly hash: string
19
+ readonly date: string
20
+ }>,
21
+ Error
22
+ >
15
23
  readonly hasCommits: () => Effect.Effect<boolean, Error>
16
24
  readonly resolveRef: (ref: string) => Effect.Effect<string, Error>
17
25
  /** The ref's hash if it resolves, `Option.none` if it doesn't exist (never fails). Used to read the retained-history ref. */
@@ -331,10 +339,40 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
331
339
 
332
340
  return {
333
341
  lastCommitSubject: (ref = "HEAD") =>
334
- exec("git", "log", "-1", "--pretty=%s", ref).pipe(Effect.map((s) => s.trim())),
342
+ exec("git", "log", "-1", "--pretty=%s", "--end-of-options", ref).pipe(
343
+ Effect.map((s) => s.trim()),
344
+ ),
335
345
 
336
- lastCommitMessage: () =>
337
- exec("git", "log", "-1", "--pretty=%B").pipe(Effect.map((s) => s.trim())),
346
+ lastCommitMessage: (ref = "HEAD") =>
347
+ exec("git", "log", "-1", "--pretty=%B", "--end-of-options", ref).pipe(
348
+ Effect.map((s) => s.trim()),
349
+ ),
350
+
351
+ currentBranch: () =>
352
+ exec("git", "symbolic-ref", "--quiet", "--short", "HEAD").pipe(
353
+ Effect.map((s) => s.trim()),
354
+ Effect.map((name) => Option.some(name)),
355
+ Effect.catchAll(() => Effect.succeed(Option.none<string>())),
356
+ ),
357
+
358
+ listRefs: (prefix: string) =>
359
+ exec(
360
+ "git",
361
+ "for-each-ref",
362
+ "--sort=-committerdate",
363
+ "--format=%(refname)%09%(objectname)%09%(committerdate:iso-strict)",
364
+ prefix,
365
+ ).pipe(
366
+ Effect.map((out) =>
367
+ out
368
+ .split("\n")
369
+ .filter((line) => line.length > 0)
370
+ .map((line) => {
371
+ const [ref = "", hash = "", date = ""] = line.split("\t")
372
+ return { ref, hash, date }
373
+ }),
374
+ ),
375
+ ),
338
376
 
339
377
  hasCommits: () =>
340
378
  exec("git", "rev-parse", "--verify", "HEAD").pipe(
@@ -382,7 +420,7 @@ const makeGitImpl = (executor: CommandExecutor.CommandExecutor, root: string): G
382
420
  }),
383
421
 
384
422
  resolveRef: (ref: string) =>
385
- exec("git", "rev-parse", "--verify", ref).pipe(
423
+ exec("git", "rev-parse", "--verify", "--end-of-options", ref).pipe(
386
424
  Effect.map((s) => s.trim()),
387
425
  Effect.flatMap((hash) => resolvedRefOrCorrupted(ref, hash)),
388
426
  ),
@@ -4,6 +4,8 @@ import { strictGitOperations as stubGit } from "../testkit/index.js"
4
4
  import {
5
5
  clearRetainedHistory,
6
6
  HISTORY_REF,
7
+ PROCESS_HISTORY_PREFIX,
8
+ processHistoryRef,
7
9
  readRetainedHistory,
8
10
  restorability,
9
11
  } from "./RetainedHistory.js"
@@ -63,3 +65,15 @@ describe("clearRetainedHistory", () => {
63
65
  expect(deleteRef).toHaveBeenCalledWith(HISTORY_REF)
64
66
  })
65
67
  })
68
+
69
+ describe("processHistoryRef", () => {
70
+ it("is refs/gtd/history/<hash>, under PROCESS_HISTORY_PREFIX", () => {
71
+ expect(PROCESS_HISTORY_PREFIX).toBe("refs/gtd/history/")
72
+ expect(processHistoryRef("abc123")).toBe("refs/gtd/history/abc123")
73
+ })
74
+
75
+ it("never equals the per-worktree HISTORY_REF", () => {
76
+ expect(processHistoryRef("abc123")).not.toBe(HISTORY_REF)
77
+ expect(HISTORY_REF.startsWith(PROCESS_HISTORY_PREFIX)).toBe(false)
78
+ })
79
+ })
@@ -4,6 +4,11 @@ import type { GitOperations } from "./Git.js"
4
4
  /** Per-worktree (`refs/worktree/gtd/*`) so linked worktrees sharing one `.git` don't clobber each other's retained history. */
5
5
  export const HISTORY_REF = "refs/worktree/gtd/history"
6
6
 
7
+ /** One ref per finished process, kept past the branch's deletion; distinct from the per-worktree `HISTORY_REF`. */
8
+ export const PROCESS_HISTORY_PREFIX = "refs/gtd/history/"
9
+
10
+ export const processHistoryRef = (hash: string): string => `${PROCESS_HISTORY_PREFIX}${hash}`
11
+
7
12
  export const readRetainedHistory = (
8
13
  git: GitOperations,
9
14
  ): Effect.Effect<Option.Option<string>, Error> => git.readRefOption(HISTORY_REF)
@@ -19,4 +19,10 @@ export {
19
19
  export { Narrator, GtdError, GtdUsageError, renderFailure } from "./Commentary.js"
20
20
  export { CommandRunner } from "./CommandRunner.js"
21
21
  export { loopLogPath } from "./WorktreeState.js"
22
- export { HISTORY_REF, readRetainedHistory, restorability } from "./RetainedHistory.js"
22
+ export {
23
+ HISTORY_REF,
24
+ processHistoryRef,
25
+ PROCESS_HISTORY_PREFIX,
26
+ readRetainedHistory,
27
+ restorability,
28
+ } from "./RetainedHistory.js"
@@ -92,6 +92,38 @@ describe("the commit-message codec", () => {
92
92
  expect(read('deadbeefdeadbeef "a\\u001b.ts"')).toEqual({})
93
93
  })
94
94
 
95
+ it("round-trips Gtd-Branch and Gtd-Version, after Gtd-Judge and before Gtd-Format", () => {
96
+ const message = formatCommitMessage({
97
+ actor: "agent",
98
+ to: "idle",
99
+ judge: [{ id: "q1", answer: true, p: 0.9 }],
100
+ branch: "feat/x",
101
+ version: "1.2.3",
102
+ })
103
+ const lines = message.split("\n")
104
+ const at = (prefix: string) => lines.findIndex((l) => l.startsWith(prefix))
105
+ expect(at("Gtd-Judge:")).toBeLessThan(at("Gtd-Branch:"))
106
+ expect(at("Gtd-Branch:")).toBeLessThan(at("Gtd-Version:"))
107
+ expect(at("Gtd-Version:")).toBeLessThan(at("Gtd-Format:"))
108
+ const parsed = parseCommitMessage(message)
109
+ expect(parsed.branch).toBe("feat/x")
110
+ expect(parsed.version).toBe("1.2.3")
111
+ })
112
+
113
+ it("omits Gtd-Branch and Gtd-Version when unset, and reads them as undefined", () => {
114
+ const message = formatCommitMessage({ actor: "agent", to: "idle" })
115
+ expect(message).not.toContain("Gtd-Branch")
116
+ expect(message).not.toContain("Gtd-Version")
117
+ const parsed = parseCommitMessage(message)
118
+ expect(parsed.branch).toBeUndefined()
119
+ expect(parsed.version).toBeUndefined()
120
+ })
121
+
122
+ it("keeps Gtd-Format at 1 when branch and version are written", () => {
123
+ const message = formatCommitMessage({ actor: "a", to: "b", branch: "m", version: "1" })
124
+ expect(message.endsWith("Gtd-Format: 1")).toBe(true)
125
+ })
126
+
95
127
  it("writes Gtd-Format last on every message, even one with no other trailer", () => {
96
128
  const message = formatCommitMessage({ actor: "agent", to: "building" })
97
129
  expect(message).toBe("gtd(agent): building\n\nGtd-Format: 1")