@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/dist/testkit.mjs CHANGED
@@ -1,12 +1,12 @@
1
1
  import { configProblems, configure, installContext, isFactoryWorkflow, protocolMismatch, t as flows_exports } from "./flows.mjs";
2
2
  import { O as renderFormat, T as ACCESS_REFUSAL, _ as demandOf, a as landFields, c as renderLandJson, d as renderBeatPlain, f as beatDocument, g as beatKindOf, i as judgeJson, l as renderLandPlain, m as statusOf, n as resolveSession, p as renderBeatJson, r as selectPath, s as noopText, t as globMatches } from "./chunks/wire-DA7q2ikx.mjs";
3
3
  import { C as headingSections, S as headingSectionBodies, a as unansweredQuestions, b as parseThreadsWithFindings, n as openThreads, r as steeringFormatFor, s as parseCodeThreads, t as BUILT_IN_MODE_NAMES } from "./chunks/steering-D-PvRC5W.mjs";
4
- import { $ as makeSemaphore, $i as equals, $n as match$3, $r as get$4, $t as scoped$3, A as catchTag, Ai as omit, An as _await$2, Ar as interruptAsFiber, At as tap, B as flatten$4, Bi as map$8, Bn as modify$1, Br as uninterruptible, Bt as when, C as all$1, Ci as unsafeGet, Cn as map$6, Cr as exitSucceed, Ct as scopeWith, D as async, Di as add, Dn as forEachConcurrentDiscard, Dr as fiberRefLocally, Dt as succeed$6, E as asVoid$1, Ei as Tag, En as ensuring$3, Er as fiberId, Et as serviceOption, F as exit, Fi as flatMap$5, Fn as bind, Fr as scopeClose, Ft as try_, G as forkScoped, Gi as left, Gn as spanTag, Gr as zipRight$1, Gt as zipWith, H as forEach$2, Hi as none, Hn as update$1, Hr as void_$2, Ht as zip$1, I as fail$5, Ii as fromNullable, In as bindTo, Ir as succeed$4, It as uninterruptible$1, J as interrupt$4, Ji as pipeArguments, Jn as fail$4, Jr as combine, Jt as tagged, K as gen, Ki as match$2, Kn as all, Kr as DeferredTypeId$1, Kt as spaced$1, L as failCause$4, Li as getOrElse, Ln as sleep, Lr as suspend$4, Lt as uninterruptibleMask, M as dieMessage, Mi as fromIterable, Mn as interruptAllAs$1, Mr as interruptible$1, Mt as timeout, N as either, Ni as splitAt, Nn as join$3, Nr as map$3, Nt as tryMap, O as catchAll$1, Oi as get$5, On as scopeMake, Or as flatMap$3, Ot as suspend$5, P as ensuring$2, Pi as filter$1, Pn as sequential, Pr as onInterrupt, Pt as tryPromise, Q as logWarning, Qi as toJSON, Qn as mapBoth, Qr as none$1, Qt as provideMerge$1, R as fiberIdWith, Ri as isNone, Rn as get$3, Rr as sync$1, Rt as unsafeMakeLatch, S as addFinalizer$1, Si as unsafeFromArray, Sn as isInterruptedOnly, Sr as exitFailCause, St as scope, T as as, Ti as GenericTag, Tn as squash, Tr as exitZipRight, Tt as scopedWith$1, U as forkDaemon, Ui as some, Un as TypeId$10, Ur as whenEffect, Ut as zipLeft, V as fnUntraced, Vi as match$4, Vn as set$1, Vr as uninterruptibleMask$1, Vt as withFiberRuntime$1, W as forkIn, Wi as isLeft, Wn as Class, Wr as withFiberRuntime, Wt as zipRight$3, X as intoDeferred, Xi as NodeInspectSymbol, Xn as isFailure, Xr as ids, Xt as mergeAll$2, Y as interruptible, Yi as BaseProto, Yn as failCause$3, Yr as combineAll, Yt as fromEffect$5, Z as isEffect, Zi as format, Zn as map$5, Zr as make$19, Zt as provide$1, _ as decodeSync, _i as reduce, _n as flipCauseOption, _r as deferredSucceed, _t as retry, a as Literal, aa as dual, ai as isFinite, an as unsafeMakeSemaphore, ar as asVoid, at as matchCauseEffect, b as acquireRelease, bi as takeRight, bn as isDieType, br as exitAs, bt as runtime, c as String$, ci as drop, cn as addFinalizerExit, cr as currentContext$1, ct as onError, d as Union, di as fromIterable$1, dn as fork, dr as deferredFail, dt as promise, ea as hasProperty, ei as make$17, en as succeed$3, er as succeed$5, et as map$4, f as between, fi as isChunk, fn as make$18, fr as deferredFailCause, ft as provide$2, gi as prepend, gn as failureOrCause, gr as deferredMake, gt as repeat, hi as of, hn as fail$3, hr as deferredIsDone, ht as raceWith, i as Defect, ia as constVoid, ii as decode, in as forkIn$1, ir as as$1, it as matchCause, j as die$2, ji as empty$5, jn as inheritAll$1, jr as interruptFiber, jt as tapErrorCause, k as catchAllCause, ki as getOption, kn as scopeTag, kr as interrupt$2, kt as sync$2, l as Struct, li as empty$4, ln as close, lr as deferredAwait, lt as orDie, m as optional$1, mi as map$7, mn as die$1, mr as deferredInterruptWith, mt as race, n as Boolean$, na as constTrue, ni as has, nn as unsafeRunPromiseExit, nr as zip$2, nt as mapError, o as Never, oa as identity, oi as append$1, on as Scope, or as asyncInterrupt, ot as matchEffect, p as decodeUnknown, pi as isEmpty$2, pn as NoSuchElementException, pr as deferredInterrupt, pt as provideService, q as ignore, qi as right, qn as die, qr as empty$6, qt as TaggedError$1, r as Cause, ra as constUndefined, ri as size$2, rn as updateContext$1, rr as zipRight$2, rt as match$5, s as Number$, sa as pipe, si as appendAll, sn as addFinalizer, sr as catchAll, st as negate, t as Array$, ta as constFalse, ti as set$2, tn as unsafeFork, tr as void_$3, tt as mapBoth$1, u as TaggedError, ui as filter, un as extend, ur as deferredComplete, ut as orElse, v as encodeSync, vi as reverse, vn as interrupt$3, vr as deferredUnsafeDone, vt as runPromise, w as annotateLogs, wi as unsafeHead, wn as reduceWithContext, wr as exitVoid, wt as scoped$4, x as acquireUseRelease$2, xi as toArray, xn as isInterrupted, xr as exitFail, xt as sandbox, y as _void, yi as take$2, yn as interruptors, yr as deferredUnsafeMake, yt as runPromiseExit$1, z as flatMap$4, zi as isSome, zn as make$16, zr as tap$1, zt as unsafeMakeSemaphore$1 } from "./chunks/Schema-Dh-mkwkq.mjs";
4
+ import { $ as makeSemaphore, $i as toJSON, $n as mapBoth, $r as none$1, $t as provideMerge$1, A as catchTag, Ai as getOption, An as scopeTag, Ar as interrupt$2, At as sync$2, B as flatten$4, Bi as isSome, Bn as make$16, Br as tap$1, Bt as unsafeMakeSemaphore$1, C as all$1, Ci as unsafeFromArray, Cn as isInterruptedOnly, Cr as exitFailCause, Ct as scope, D as async, Di as Tag, Dn as ensuring$3, Dr as fiberId, Dt as serviceOption, E as asVoid$1, Ei as GenericTag, En as squash, Er as exitZipRight, Et as scopedWith$1, F as exit, Fi as filter$1, Fn as sequential, Fr as onInterrupt, Ft as tryPromise, G as forkScoped, Gi as isLeft, Gn as Class, Gr as withFiberRuntime, Gt as zipRight$3, H as forEach$2, Hi as match$4, Hn as set$1, Hr as uninterruptibleMask$1, Ht as withFiberRuntime$1, I as fail$5, Ii as flatMap$5, In as bind, Ir as scopeClose, It as try_, J as interrupt$4, Ji as right, Jn as die, Jr as empty$6, Jt as TaggedError$1, K as gen, Ki as left, Kn as spanTag, Kr as zipRight$1, Kt as zipWith, L as failCause$4, Li as fromNullable, Ln as bindTo, Lr as succeed$4, Lt as uninterruptible$1, M as dieMessage, Mi as empty$5, Mn as inheritAll$1, Mr as interruptFiber, Mt as tapErrorCause, N as either, Ni as fromIterable, Nn as interruptAllAs$1, Nr as interruptible$1, Nt as timeout, O as catchAll$1, Oi as add, On as forEachConcurrentDiscard, Or as fiberRefLocally, Ot as succeed$6, P as ensuring$2, Pi as splitAt, Pn as join$3, Pr as map$3, Pt as tryMap, Q as logWarning, Qi as format, Qn as map$5, Qr as make$19, Qt as provide$1, R as fiberIdWith, Ri as getOrElse, Rn as sleep, Rr as suspend$4, Rt as uninterruptibleMask, S as addFinalizer$1, Si as toArray, Sn as isInterrupted, Sr as exitFail, St as sandbox, T as as, Ti as unsafeHead, Tn as reduceWithContext, Tr as exitVoid, Tt as scoped$4, U as forkDaemon, Ui as none, Un as update$1, Ur as void_$2, Ut as zip$1, V as fnUntraced, Vi as map$8, Vn as modify$1, Vr as uninterruptible, Vt as when, W as forkIn, Wi as some, Wn as TypeId$10, Wr as whenEffect, Wt as zipLeft, X as intoDeferred, Xi as BaseProto, Xn as failCause$3, Xr as combineAll, Xt as fromEffect$5, Y as interruptible, Yi as pipeArguments, Yn as fail$4, Yr as combine, Yt as tagged, Z as isEffect, Zi as NodeInspectSymbol, Zn as isFailure, Zr as ids, Zt as mergeAll$2, _ as decodeSync, _i as prepend, _n as failureOrCause, _r as deferredMake, _t as repeat, a as Literal, aa as constVoid, ai as decode, an as forkIn$1, ar as as$1, at as matchCauseEffect, b as acquireRelease, bi as take$2, bn as interruptors, br as deferredUnsafeMake, bt as runPromiseExit$1, c as String$, ca as pipe, ci as appendAll, cn as addFinalizer, cr as catchAll, ct as onError, d as Union, di as filter, dn as extend, dr as deferredComplete, dt as orElseSucceed, ea as equals, ei as get$4, en as scoped$3, er as match$3, et as map$4, f as between, fi as fromIterable$1, fn as fork, fr as deferredFail, ft as promise, gi as of, gn as fail$3, gr as deferredIsDone, gt as raceWith, hi as map$7, hn as die$1, hr as deferredInterruptWith, ht as race, i as Defect, ia as constUndefined, ii as size$2, in as updateContext$1, ir as zipRight$2, it as matchCause, j as die$2, ji as omit, jn as _await$2, jr as interruptAsFiber, jt as tap, k as catchAllCause, ki as get$5, kn as scopeMake, kr as flatMap$3, kt as suspend$5, l as Struct, li as drop, ln as addFinalizerExit, lr as currentContext$1, lt as orDie, m as optional$1, mi as isEmpty$2, mn as NoSuchElementException, mr as deferredInterrupt, mt as provideService, n as Boolean$, na as constFalse, ni as set$2, nn as unsafeFork, nr as void_$3, nt as mapError, o as Never, oa as dual, oi as isFinite, on as unsafeMakeSemaphore, or as asVoid, ot as matchEffect, p as decodeUnknown, pi as isChunk, pn as make$18, pr as deferredFailCause, pt as provide$2, q as ignore, qi as match$2, qn as all, qr as DeferredTypeId$1, qt as spaced$1, r as Cause, ra as constTrue, ri as has, rn as unsafeRunPromiseExit, rr as zip$2, rt as match$5, s as Number$, sa as identity, si as append$1, sn as Scope, sr as asyncInterrupt, st as negate, t as Array$, ta as hasProperty, ti as make$17, tn as succeed$3, tr as succeed$5, tt as mapBoth$1, u as TaggedError, ui as empty$4, un as close, ur as deferredAwait, ut as orElse, v as encodeSync, vi as reduce, vn as flipCauseOption, vr as deferredSucceed, vt as retry, w as annotateLogs, wi as unsafeGet, wn as map$6, wr as exitSucceed, wt as scopeWith, x as acquireUseRelease$2, xi as takeRight, xn as isDieType, xr as exitAs, xt as runtime, y as _void, yi as reverse, yn as interrupt$3, yr as deferredUnsafeDone, yt as runPromise, z as flatMap$4, zi as isNone, zn as get$3, zr as sync$1, zt as unsafeMakeLatch } from "./chunks/Schema-B5yK7QrT.mjs";
5
5
  import { createRequire } from "node:module";
6
6
  import * as Crypto from "node:crypto";
7
7
  import { createHash } from "node:crypto";
8
8
  import * as Path from "node:path";
9
- import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
9
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
10
10
  import { PassThrough } from "node:stream";
11
11
  import * as NFS from "node:fs";
12
12
  import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -5388,8 +5388,17 @@ const resolvedRefOrCorrupted = (ref, hash) => /^[0-9a-f]{40}$/.test(hash) ? succ
5388
5388
  const makeGitImpl = (executor, root) => {
5389
5389
  const exec = (...args) => run(root, ...args).pipe(provide$2(succeed$1(CommandExecutor, executor)));
5390
5390
  return {
5391
- lastCommitSubject: (ref = "HEAD") => exec("git", "log", "-1", "--pretty=%s", ref).pipe(map$4((s) => s.trim())),
5392
- lastCommitMessage: () => exec("git", "log", "-1", "--pretty=%B").pipe(map$4((s) => s.trim())),
5391
+ lastCommitSubject: (ref = "HEAD") => exec("git", "log", "-1", "--pretty=%s", "--end-of-options", ref).pipe(map$4((s) => s.trim())),
5392
+ lastCommitMessage: (ref = "HEAD") => exec("git", "log", "-1", "--pretty=%B", "--end-of-options", ref).pipe(map$4((s) => s.trim())),
5393
+ currentBranch: () => exec("git", "symbolic-ref", "--quiet", "--short", "HEAD").pipe(map$4((s) => s.trim()), map$4((name) => some(name)), catchAll$1(() => succeed$6(none()))),
5394
+ listRefs: (prefix) => exec("git", "for-each-ref", "--sort=-committerdate", "--format=%(refname)%09%(objectname)%09%(committerdate:iso-strict)", prefix).pipe(map$4((out) => out.split("\n").filter((line) => line.length > 0).map((line) => {
5395
+ const [ref = "", hash = "", date = ""] = line.split(" ");
5396
+ return {
5397
+ ref,
5398
+ hash,
5399
+ date
5400
+ };
5401
+ }))),
5393
5402
  hasCommits: () => exec("git", "rev-parse", "--verify", "HEAD").pipe(map$4(() => true), catchAll$1(() => succeed$6(false))),
5394
5403
  readFileAtRef: (ref, path) => exec("git", "show", `${ref}:${path}`),
5395
5404
  changedPaths: (base) => gen(function* () {
@@ -5409,7 +5418,7 @@ const makeGitImpl = (executor, root) => {
5409
5418
  }
5410
5419
  return all;
5411
5420
  }),
5412
- resolveRef: (ref) => exec("git", "rev-parse", "--verify", ref).pipe(map$4((s) => s.trim()), flatMap$4((hash) => resolvedRefOrCorrupted(ref, hash))),
5421
+ resolveRef: (ref) => exec("git", "rev-parse", "--verify", "--end-of-options", ref).pipe(map$4((s) => s.trim()), flatMap$4((hash) => resolvedRefOrCorrupted(ref, hash))),
5413
5422
  readRefOption: (ref) => exec("git", "rev-parse", "--verify", "--quiet", ref).pipe(map$4((s) => some(s.trim())), catchAll$1(() => succeed$6(none()))),
5414
5423
  isAncestor: (a, b) => run(root, "git", "merge-base", "--is-ancestor", a, b).pipe(provide$2(succeed$1(CommandExecutor, executor)), map$4(() => true), catchAll$1(() => succeed$6(false))),
5415
5424
  mergeBase: (a, b) => exec("git", "merge-base", a, b).pipe(map$4((s) => some(s.trim())), catchAll$1(() => succeed$6(none()))),
@@ -6059,6 +6068,9 @@ const loopLogPath = gen(function* () {
6059
6068
  //#region src/platform/RetainedHistory.ts
6060
6069
  /** Per-worktree (`refs/worktree/gtd/*`) so linked worktrees sharing one `.git` don't clobber each other's retained history. */
6061
6070
  const HISTORY_REF = "refs/worktree/gtd/history";
6071
+ /** One ref per finished process, kept past the branch's deletion; distinct from the per-worktree `HISTORY_REF`. */
6072
+ const PROCESS_HISTORY_PREFIX = "refs/gtd/history/";
6073
+ const processHistoryRef = (hash) => `${PROCESS_HISTORY_PREFIX}${hash}`;
6062
6074
  const readRetainedHistory = (git) => git.readRefOption(HISTORY_REF);
6063
6075
  /**
6064
6076
  * Whether resetting to `tipHash` is safe: HEAD must be an ancestor of
@@ -6855,6 +6867,12 @@ const TRAILER_READERS = {
6855
6867
  "Gtd-Review-Base": (value, into) => {
6856
6868
  into.reviewBase ??= value.split(/\s/)[0];
6857
6869
  },
6870
+ "Gtd-Branch": (value, into) => {
6871
+ if (value !== "") into.branch ??= value;
6872
+ },
6873
+ "Gtd-Version": (value, into) => {
6874
+ if (value !== "") into.version ??= value;
6875
+ },
6858
6876
  "Gtd-Format": (value, into) => {
6859
6877
  if (FORMAT_RE.test(value)) into.format ??= Number(value);
6860
6878
  },
@@ -6873,6 +6891,8 @@ const parseCommitMessage = (message) => {
6873
6891
  step: void 0,
6874
6892
  workflow: void 0,
6875
6893
  reviewBase: void 0,
6894
+ branch: void 0,
6895
+ version: void 0,
6876
6896
  judge: [],
6877
6897
  vars: {},
6878
6898
  config: {},
@@ -6898,6 +6918,8 @@ const formatCommitMessage = (spec) => {
6898
6918
  for (const [path, hash] of sortedEntries(spec.config)) lines.push(`Gtd-Config: ${hash} ${encodePath(path)}`);
6899
6919
  if (spec.cost !== void 0) lines.push(`Gtd-Cost: ${spec.cost.cost}${spec.cost.model !== void 0 ? ` ${spec.cost.model}` : ""}`);
6900
6920
  for (const verdict of spec.judge ?? []) lines.push(`Gtd-Judge: ${JSON.stringify(verdict)}`);
6921
+ if (spec.branch !== void 0) lines.push(`Gtd-Branch: ${spec.branch}`);
6922
+ if (spec.version !== void 0) lines.push(`Gtd-Version: ${spec.version}`);
6901
6923
  lines.push(`Gtd-Format: 1`);
6902
6924
  return `${formatSubject(spec.actor, spec.to, spec.from)}\n\n${lines.join("\n")}`;
6903
6925
  };
@@ -8206,6 +8228,14 @@ const planStep = (snapshot, opts = {}) => {
8206
8228
  message
8207
8229
  }
8208
8230
  },
8231
+ ...landing.keepHistory !== void 0 ? [{
8232
+ kind: "gitWrite",
8233
+ write: {
8234
+ kind: "updateRef",
8235
+ ref: landing.keepHistory,
8236
+ target: "HEAD"
8237
+ }
8238
+ }] : [],
8209
8239
  outcome
8210
8240
  ]
8211
8241
  };
@@ -8513,25 +8543,38 @@ const currentRun = gen(function* () {
8513
8543
  const config = yield* (yield* ConfigService).load;
8514
8544
  return yield* computeProcessRun(git, config.workflow);
8515
8545
  });
8516
- /** The process HEAD closes or sits inside — `gtd summary`'s run. */
8517
- const summaryRun = gen(function* () {
8518
- const git = yield* GitService;
8519
- const def = (yield* (yield* ConfigService).load).workflow;
8520
- const headEntry = (yield* git.subjectHistory(1, 0))[0];
8521
- if (headEntry === void 0) {
8546
+ const runAtTip = (git, def, tip) => gen(function* () {
8547
+ if (tip === void 0) {
8522
8548
  const history = yield* historyUpTo(git, def, void 0);
8523
8549
  return yield* runOf(history, locateEpisode(def, history));
8524
8550
  }
8525
- if (!(parseCommitMessage(headEntry.subject).parsed?.to === def.initial)) {
8526
- const history = yield* historyUpTo(git, def, headEntry.hash);
8551
+ if (!(parseCommitMessage(tip.subject).parsed?.to === def.initial)) {
8552
+ const history = yield* historyUpTo(git, def, tip.hash);
8527
8553
  return yield* runOf(history, locateEpisode(def, history));
8528
8554
  }
8529
- const parentRef = `${headEntry.hash}~1`;
8555
+ const parentRef = `${tip.hash}~1`;
8530
8556
  const before = yield* historyUpTo(git, def, parentRef);
8531
8557
  const location = locateEpisode(def, before);
8532
- let closing = yield* git.commitHistory(parentRef, headEntry.hash);
8533
- if (closing.length === 0) closing = yield* git.commitHistory(void 0, headEntry.hash);
8534
- return yield* runOf([...before, ...closing], { ...location }, headEntry.hash);
8558
+ let closing = yield* git.commitHistory(parentRef, tip.hash);
8559
+ if (closing.length === 0) closing = yield* git.commitHistory(void 0, tip.hash);
8560
+ return yield* runOf([...before, ...closing], { ...location }, tip.hash);
8561
+ });
8562
+ /** The process HEAD closes or sits inside — `gtd summary`'s run. */
8563
+ const summaryRun = gen(function* () {
8564
+ const git = yield* GitService;
8565
+ const def = (yield* (yield* ConfigService).load).workflow;
8566
+ return yield* runAtTip(git, def, (yield* git.subjectHistory(1, 0))[0]);
8567
+ });
8568
+ /** The process whose finishing commit is `tip` — reads from `tip`'s own ancestry, so it works when no branch reaches it. */
8569
+ const closedRunAt = (tip) => gen(function* () {
8570
+ const git = yield* GitService;
8571
+ const def = (yield* (yield* ConfigService).load).workflow;
8572
+ const hash = yield* git.resolveRef(tip);
8573
+ const subject = yield* git.lastCommitSubject(hash);
8574
+ return yield* runAtTip(git, def, {
8575
+ hash,
8576
+ subject
8577
+ });
8535
8578
  });
8536
8579
  const commitTree = (workspace, hash) => {
8537
8580
  let entries;
@@ -8751,13 +8794,7 @@ const costByModel = (entries) => {
8751
8794
  cost
8752
8795
  })).sort((a, b) => b.cost - a.cost || a.model.localeCompare(b.model));
8753
8796
  };
8754
- /** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
8755
- const restAt = (ref, startOverrides = {}) => gen(function* () {
8756
- const git = yield* GitService;
8757
- const config = yield* (yield* ConfigService).load;
8758
- const workspace = yield* Workspace;
8759
- const host = yield* Host;
8760
- const run = yield* computeProcessRun(git, config.workflow, ref);
8797
+ const setupFor = (config, run, workspace, host, startOverrides = {}) => gen(function* () {
8761
8798
  const resolved = yield* workflowOf(config, run);
8762
8799
  const def = resolved.def;
8763
8800
  const { vars, env } = settingsFor(resolved, run.pinnedVars, host.env, startOverrides);
@@ -8781,19 +8818,34 @@ const restAt = (ref, startOverrides = {}) => gen(function* () {
8781
8818
  tree: commitTree(workspace, c.hash)
8782
8819
  }));
8783
8820
  let pendingMemo;
8784
- const setup = {
8821
+ return {
8822
+ resolved,
8785
8823
  def,
8786
- run,
8787
8824
  vars,
8788
8825
  env,
8789
- budget,
8790
- skills,
8791
- access,
8792
- workspace,
8793
- base,
8794
- episode,
8795
- pendingWorktree: () => pendingMemo ??= workspace.worktreeSync()
8826
+ setup: {
8827
+ def,
8828
+ run,
8829
+ vars,
8830
+ env,
8831
+ budget,
8832
+ skills,
8833
+ access,
8834
+ workspace,
8835
+ base,
8836
+ episode,
8837
+ pendingWorktree: () => pendingMemo ??= workspace.worktreeSync()
8838
+ }
8796
8839
  };
8840
+ });
8841
+ /** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
8842
+ const restAt = (ref, startOverrides = {}) => gen(function* () {
8843
+ const git = yield* GitService;
8844
+ const config = yield* (yield* ConfigService).load;
8845
+ const workspace = yield* Workspace;
8846
+ const host = yield* Host;
8847
+ const run = yield* computeProcessRun(git, config.workflow, ref);
8848
+ const { resolved, def, vars, env, setup } = yield* setupFor(config, run, workspace, host, startOverrides);
8797
8849
  const outcome = yield* promise(() => replayFor(setup));
8798
8850
  const error = replayError(outcome);
8799
8851
  if (error !== void 0 || outcome.kind !== "rest") return yield* fail$5(error ?? /* @__PURE__ */ new Error("gtd: replay found no rest"));
@@ -8935,14 +8987,21 @@ const initialStepRefusal = (workflow, step, initial) => workflow !== void 0 && s
8935
8987
  kind: "refusal",
8936
8988
  message: `gtd: workflow "${workflow}" reaches step "${step}", the default workflow's first step — that is where a finished process waits, so a started process must never land there; rename the step`
8937
8989
  } : void 0;
8938
- const landingTo = (rest, to) => {
8939
- if (rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state) return {
8990
+ const closingTrailers = (branch) => ({
8991
+ version: GTD_VERSION,
8992
+ ...isSome(branch) ? { branch: branch.value } : {}
8993
+ });
8994
+ const isSettledNoop = (rest, to) => rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state;
8995
+ const landingTo = (rest, to, branch) => {
8996
+ if (isSettledNoop(rest, to)) return {
8940
8997
  kind: "noop",
8941
8998
  settled: true
8942
8999
  };
8943
- const starts = noProcessUnderway(rest) && to !== rest.def.initial;
9000
+ const underway = !noProcessUnderway(rest);
9001
+ const starts = !underway && to !== rest.def.initial;
8944
9002
  const refused = starts ? multilineRefusal(rest.vars) : void 0;
8945
9003
  if (refused !== void 0) return refused;
9004
+ const finishes = underway && to === rest.def.initial;
8946
9005
  return {
8947
9006
  kind: "commit",
8948
9007
  to,
@@ -8954,8 +9013,10 @@ const landingTo = (rest, to) => {
8954
9013
  ...starts ? {
8955
9014
  vars: rest.vars,
8956
9015
  config: rest.config
8957
- } : {}
8958
- }
9016
+ } : {},
9017
+ ...finishes ? closingTrailers(branch) : {}
9018
+ },
9019
+ ...finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {}
8959
9020
  };
8960
9021
  };
8961
9022
  /**
@@ -8963,7 +9024,7 @@ const landingTo = (rest, to) => {
8963
9024
  * replay with the working tree as the pending turn. The target step is
8964
9025
  * computed by replaying, never matched.
8965
9026
  */
8966
- const decideLanding = (rest, verdicts) => gen(function* () {
9027
+ const decideLanding = (rest, verdicts, branch) => gen(function* () {
8967
9028
  const early = cleanLanding(rest);
8968
9029
  if (early !== void 0) return early;
8969
9030
  if (rest.stepDef.kind === "prompt" && rest.stepDef.access?.write != null) {
@@ -8979,13 +9040,14 @@ const decideLanding = (rest, verdicts) => gen(function* () {
8979
9040
  }));
8980
9041
  const to = yield* landingTarget(outcome, rest);
8981
9042
  if (typeof to !== "string") return to;
8982
- return landingTo(rest, to);
9043
+ return landingTo(rest, to, branch);
8983
9044
  });
8984
9045
  /** Where landing the pending turn would leave the process, or `undefined` when it would land nothing. */
8985
- const previewLanding = (rest) => decideLanding(rest, void 0).pipe(map$4((landing) => landing.kind === "commit" ? landing.to : void 0), catchAll$1(() => succeed$6(void 0)));
9046
+ const previewLanding = (rest) => decideLanding(rest, void 0, none()).pipe(map$4((landing) => landing.kind === "commit" ? landing.to : void 0), catchAll$1(() => succeed$6(void 0)));
8986
9047
  /** Every fact the pure landing planner needs, read once: the rest, its steering file, and the landing decision. */
8987
9048
  const snapshotFromRest = (rest, verdicts) => gen(function* () {
8988
- const landing = yield* decideLanding(rest, verdicts);
9049
+ const branch = yield* (yield* GitService).currentBranch();
9050
+ const landing = yield* decideLanding(rest, verdicts, branch);
8989
9051
  return {
8990
9052
  stepDef: rest.stepDef,
8991
9053
  state: rest.state,
@@ -9016,6 +9078,66 @@ const summaryFor = (run) => gen(function* () {
9016
9078
  ...settingsFor(resolved, run.pinnedVars, host.env)
9017
9079
  });
9018
9080
  });
9081
+ /** 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. */
9082
+ const inRepoConfigPaths = (root, files, recorded) => {
9083
+ const paths = new Set(Object.keys(recorded));
9084
+ for (const file of files) paths.add(relative(root, resolve(root, file)).split(sep).join("/"));
9085
+ return [...paths].filter((p) => p !== "" && !p.startsWith("..") && !isAbsolute(p)).sort();
9086
+ };
9087
+ const configRefusal = (tip, paths) => /* @__PURE__ */ new Error(`gtd history: refused — config differs at ${tip.slice(0, 7)}: ${paths.join(", ")}. A history ref replays under today's config, so it is unreadable after the config changes`);
9088
+ const differingConfigPaths = (git, workspace, root, config, run, hash) => gen(function* () {
9089
+ const differing = [];
9090
+ for (const path of inRepoConfigPaths(root, config.workflowFiles, run.pinnedConfig?.files ?? {})) if ((yield* orElseSucceed(git.readFileAtRef(hash, path), () => void 0)) !== workspace.readSync(path)) differing.push(path);
9091
+ return differing;
9092
+ });
9093
+ const notClosedError = (hash, outcome) => outcome.kind === "rest" ? /* @__PURE__ */ new Error(`gtd history: ${hash.slice(0, 7)} does not close a process under today's config`) : replayError(outcome) ?? /* @__PURE__ */ new Error("gtd history: replay failed");
9094
+ const historySteps = (run) => {
9095
+ const messages = new Map(run.episode.commits.map((c) => [c.hash, c.message]));
9096
+ return run.trace.map((entry) => {
9097
+ const message = parseCommitMessage(messages.get(entry.hash) ?? "");
9098
+ return {
9099
+ hash: entry.hash,
9100
+ subject: (messages.get(entry.hash) ?? "").split("\n")[0] ?? "",
9101
+ actor: entry.actor,
9102
+ from: message.parsed?.from,
9103
+ to: entry.state,
9104
+ verdicts: message.judge
9105
+ };
9106
+ });
9107
+ };
9108
+ const versionWarningFor = (recorded) => recorded === GTD_VERSION ? void 0 : `gtd: warning: this history was recorded by ${recorded === void 0 ? "an unknown gtd version" : `gtd ${recorded}`} and is replayed by gtd ${GTD_VERSION} — a workflow imported from @pmelab/gtd/workflows may have changed since`;
9109
+ /**
9110
+ * Replays the closed run at `tip` under today's config. The workflow is not
9111
+ * fingerprinted for a version change: one imported from `@pmelab/gtd/workflows`
9112
+ * moves with every upgrade, and the version warning is the only signal.
9113
+ */
9114
+ const historyTrail = (tip) => gen(function* () {
9115
+ const git = yield* GitService;
9116
+ const config = yield* (yield* ConfigService).load;
9117
+ const workspace = yield* Workspace;
9118
+ const host = yield* Host;
9119
+ const hash = yield* git.resolveRef(tip);
9120
+ const run = yield* closedRunAt(hash);
9121
+ const differing = yield* differingConfigPaths(git, workspace, host.root, config, run, hash);
9122
+ if (differing.length > 0) return yield* fail$5(configRefusal(hash, differing));
9123
+ const { setup } = yield* setupFor(config, run, workspace, host);
9124
+ yield* workspace.episodeTrees(run.episode.base, run.episode.commits.map((c) => c.hash));
9125
+ const outcome = yield* promise(() => replayFor(setup));
9126
+ if (outcome.kind !== "ended") return yield* fail$5(notClosedError(hash, outcome));
9127
+ const steps = historySteps(run);
9128
+ const recorded = parseCommitMessage(yield* git.lastCommitMessage(hash)).version;
9129
+ const versionWarning = versionWarningFor(recorded);
9130
+ return {
9131
+ tip: hash,
9132
+ steps,
9133
+ ended: outcome.via,
9134
+ version: {
9135
+ recorded,
9136
+ running: GTD_VERSION
9137
+ },
9138
+ ...versionWarning !== void 0 ? { versionWarning } : {}
9139
+ };
9140
+ });
9019
9141
  //#endregion
9020
9142
  //#region src/emit/GitScript.ts
9021
9143
  const shellQuote = (value) => `'${value.replace(/'/g, "'\\''")}'`;
@@ -9037,8 +9159,8 @@ const commitAllowEmpty = (message) => {
9037
9159
  `fi`
9038
9160
  ].join("\n");
9039
9161
  };
9040
- /** `git add -A` then `commitAllowEmpty`, joined with `&&` so a failing `git add` never reaches the commit. */
9041
- const commitAll = (message) => `git add -A &&\n${commitAllowEmpty(message)}`;
9162
+ /** `git add -A || exit 1` (an `&&` left side would not abort under `set -e`) then `commitAllowEmpty`. */
9163
+ const commitAll = (message) => `git add -A || exit 1\n${commitAllowEmpty(message)}`;
9042
9164
  /** No current caller — see `Git.ts`'s `GitWriterOperations.commitAsIs` for why it's kept anyway. */
9043
9165
  const commitAsIs = (message) => commitAllowEmpty(message);
9044
9166
  /** No current caller — see `Git.ts`'s `GitWriterOperations.softResetTo` for why it's kept anyway. */
@@ -9056,6 +9178,17 @@ const discardPending = () => `git add -A && git reset --hard HEAD`;
9056
9178
  const updateRef = (ref, hash) => `git update-ref ${shellQuote(ref)} ${shellQuote(hash)}`;
9057
9179
  /** `git update-ref -d <ref>` — idempotent: deleting a missing ref is already a no-op in real git. */
9058
9180
  const deleteRef = (ref) => `git update-ref -d ${shellQuote(ref)}`;
9181
+ /**
9182
+ * No `+` on the refspec: a local ref the remote's tip does not descend from
9183
+ * fails the fetch instead of being overwritten (a fast-forward still applies).
9184
+ */
9185
+ const fetchHistoryRefs = (remote) => [
9186
+ `set -eu`,
9187
+ `before=$(git for-each-ref --format='x' 'refs/gtd/history/' | wc -l)`,
9188
+ `git fetch ${shellQuote(remote)} 'refs/gtd/history/*:refs/gtd/history/*'`,
9189
+ `after=$(git for-each-ref --format='x' 'refs/gtd/history/' | wc -l)`,
9190
+ `printf '%s history refs arrived\\n' "$((after - before))"`
9191
+ ].join("\n");
9059
9192
  /** Marks an emitted block as print-only — mirrors `OutcomeScript.ts`'s `OUTCOME_MARKER` verbatim, so `src/testkit/EmittedScriptRecognizer.ts` recognizes either path's output identically. */
9060
9193
  const OUTCOME_MARKER$1 = "# gtd: outcome (print-only)";
9061
9194
  const printfLine$1 = (fmt, args) => `${OUTCOME_MARKER$1}\nprintf ${shellQuote(fmt.replace(/\n/g, "\\n"))} ${args.join(" ")}`;
@@ -9073,6 +9206,7 @@ const renderOutcome = (outcome) => {
9073
9206
  const renderGitWrite = (write) => {
9074
9207
  switch (write.kind) {
9075
9208
  case "commitAll": return commitAll(write.message);
9209
+ case "updateRef": return updateRef(write.ref, write.target);
9076
9210
  }
9077
9211
  };
9078
9212
  /**
@@ -9440,7 +9574,7 @@ const validateScriptFor = (resolved, file) => gen(function* () {
9440
9574
  */
9441
9575
  var ToolCommands = class extends Tag("ToolCommands")() {};
9442
9576
  //#endregion
9443
- //#region src/cli/Program.ts
9577
+ //#region src/cli/Output.ts
9444
9578
  /**
9445
9579
  * A command-level failure that should exit like a CLI usage error (2), not a
9446
9580
  * generic runtime failure (1): an unknown `--json=<path>` selector, or a
@@ -9450,12 +9584,92 @@ var ToolCommands = class extends Tag("ToolCommands")() {};
9450
9584
  * about the resolved rest's own state.
9451
9585
  */
9452
9586
  var SelectorUsageError = class extends Error {};
9453
- /** The `--json=<path>` select branch, shared by all three emitting commands so none drift on the unknown-selector message. `absent` writes nothing and lets the caller's success path continue. */
9587
+ /** The `--json=<path>` select branch, shared by the emitting commands so none drift on the unknown-selector message. `absent` writes nothing and lets the caller's success path continue. */
9454
9588
  const writeSelection = (out, fields, path) => gen(function* () {
9455
9589
  const selection = selectPath(fields, path);
9456
9590
  if (selection.kind === "value") out.write(`${selection.text}\n`);
9457
9591
  else if (selection.kind === "unknown") return yield* fail$5(new SelectorUsageError(`gtd: unknown --json selector "${selection.path}" — see \`gtd --help\``));
9458
9592
  });
9593
+ //#endregion
9594
+ //#region src/cli/History.ts
9595
+ /** Newest tip first. */
9596
+ const historyEntries = gen(function* () {
9597
+ const git = yield* GitService;
9598
+ return yield* forEach$2((yield* git.listRefs(PROCESS_HISTORY_PREFIX)).filter(({ ref }) => ENTRY_HASH.test(ref.slice(17))), ({ ref, hash, date }) => gen(function* () {
9599
+ const entry = ref.slice(17);
9600
+ return {
9601
+ ref,
9602
+ entry,
9603
+ branch: parseCommitMessage(yield* git.lastCommitMessage(hash)).branch ?? null,
9604
+ tip: hash,
9605
+ date,
9606
+ subject: yield* git.lastCommitSubject(entry)
9607
+ };
9608
+ }));
9609
+ });
9610
+ const ENTRY_HASH = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
9611
+ const HEX = /^[0-9a-f]+$/i;
9612
+ /**
9613
+ * Lookup order: full ref; then entry hash or hex prefix (unique → it, several
9614
+ * → fail listing them); then branch name (newest tip first, as `entries` is
9615
+ * ordered). A hex-looking name that matches no entry still resolves as a
9616
+ * branch. No match → fail.
9617
+ */
9618
+ const resolveHistoryRef = (arg, entries) => {
9619
+ const byRef = entries.find((e) => e.ref === arg);
9620
+ if (byRef !== void 0) return succeed$6(byRef);
9621
+ if (HEX.test(arg)) {
9622
+ const lower = arg.toLowerCase();
9623
+ const matches = entries.filter((e) => e.entry.toLowerCase().startsWith(lower));
9624
+ if (matches.length === 1) return succeed$6(matches[0]);
9625
+ if (matches.length > 1) return fail$5(/* @__PURE__ */ new Error(`gtd history: "${arg}" is ambiguous — it matches:\n${matches.map((e) => ` ${e.entry} ${e.branch ?? "-"}`).join("\n")}`));
9626
+ }
9627
+ const byBranch = entries.find((e) => e.branch === arg);
9628
+ if (byBranch !== void 0) return succeed$6(byBranch);
9629
+ return fail$5(/* @__PURE__ */ new Error(`gtd history: no history ref matches "${arg}"`));
9630
+ };
9631
+ const seven = (hash) => hash.slice(0, 7);
9632
+ /** Writes `document` per `json` (bare → whole, `=path` → one value); returns false for `off`. */
9633
+ const writeJson = (json, out, document) => gen(function* () {
9634
+ if (json.kind === "off") return false;
9635
+ if (json.kind === "document") out.write(`${JSON.stringify(document)}\n`);
9636
+ else yield* writeSelection(out, document, json.path);
9637
+ return true;
9638
+ });
9639
+ const runHistoryListCommand = (json, out) => gen(function* () {
9640
+ const entries = yield* historyEntries;
9641
+ if (yield* writeJson(json, out, entries)) return;
9642
+ if (entries.length === 0) {
9643
+ out.write("no history refs; run gtd history fetch | sh to pull them\n");
9644
+ return;
9645
+ }
9646
+ out.write(entries.map((e) => `${seven(e.entry)} ${e.branch ?? "-"} ${seven(e.tip)} ${e.date} ${e.subject}\n`).join(""));
9647
+ });
9648
+ const runHistoryShowCommand = (ref, json, out) => gen(function* () {
9649
+ const entry = yield* resolveHistoryRef(ref, yield* historyEntries);
9650
+ const trail = yield* historyTrail(entry.tip);
9651
+ const document = {
9652
+ ref: entry.ref,
9653
+ entry: entry.entry,
9654
+ branch: entry.branch,
9655
+ ...trail
9656
+ };
9657
+ if (yield* writeJson(json, out, document)) return;
9658
+ if (trail.versionWarning !== void 0) yield* (yield* Narrator).warn(trail.versionWarning);
9659
+ const lines = [`${entry.ref} (branch ${entry.branch ?? "-"}, tip ${seven(entry.tip)})`];
9660
+ for (const step of trail.steps) {
9661
+ lines.push(`${seven(step.hash)} ${step.subject}`);
9662
+ for (const v of step.verdicts) lines.push(` judge ${v.id} = ${v.answer} (p=${v.p})`);
9663
+ }
9664
+ lines.push(`ended (${trail.ended})`);
9665
+ out.write(`${lines.join("\n")}\n`);
9666
+ });
9667
+ /** Prints the script and never runs git: gtd's promise that it only decides and prints. */
9668
+ const runHistoryFetchCommand = (remote, out) => sync$2(() => {
9669
+ out.write(`${combinedScript(fetchHistoryRefs(remote), "")}\n`);
9670
+ });
9671
+ //#endregion
9672
+ //#region src/cli/Program.ts
9459
9673
  const selfGtd = () => {
9460
9674
  const script = process.argv[1];
9461
9675
  return script === void 0 ? ["gtd"] : [process.execPath, script];
@@ -10074,6 +10288,9 @@ const dispatchVoidCommand = (command, json, out) => {
10074
10288
  case "judge": return runJudgeCommand(json, out);
10075
10289
  case "judgeAnswer": return runJudgeAnswerCommand(json, out);
10076
10290
  case "judgeRun": return runJudgeRunCommand(command, out);
10291
+ case "historyList": return runHistoryListCommand(json, out);
10292
+ case "historyShow": return runHistoryShowCommand(command.ref, json, out);
10293
+ case "historyFetch": return runHistoryFetchCommand(command.remote, out);
10077
10294
  }
10078
10295
  };
10079
10296
  /**
@@ -10112,13 +10329,13 @@ const FLAGS = [
10112
10329
  name: "--json",
10113
10330
  arity: "optional",
10114
10331
  repeatable: false,
10115
- scope: (kind) => kind === "next" || kind === "land" || kind === "judge" || kind === "judgeAnswer" || kind === "doors",
10332
+ scope: (kind) => kind === "next" || kind === "land" || kind === "judge" || kind === "judgeAnswer" || kind === "doors" || kind === "historyList" || kind === "historyShow",
10116
10333
  decode: ([raw]) => right(raw ?? ""),
10117
- scopeError: "gtd: --json is only valid for `gtd next`/`gtd land`/`gtd judge`/`gtd judge answer`/`gtd doors` — every other command prints plain text; see `gtd install` for the driver protocol briefing",
10334
+ scopeError: "gtd: --json is only valid for `gtd next`/`gtd land`/`gtd judge`/`gtd judge answer`/`gtd doors`/`gtd history list`/`gtd history show` — every other command prints plain text; see `gtd install` for the driver protocol briefing",
10118
10335
  valueHint: "<path>",
10119
10336
  help: [
10120
- "(gtd next/gtd land/gtd judge/gtd judge answer/gtd doors only) output",
10121
- "structured JSON. Bare --json prints the whole document;",
10337
+ "(gtd next/gtd land/gtd judge/gtd judge answer/gtd doors/gtd history",
10338
+ "list/gtd history show only) output structured JSON. Bare --json prints the whole document;",
10122
10339
  "--json=<path> (a dotted key path into that document, e.g.",
10123
10340
  "kind, content, session.id) prints just that value: a",
10124
10341
  "scalar raw and unquoted, a boolean as true/false, a list",
@@ -10626,6 +10843,44 @@ const COMMAND_ROWS = [
10626
10843
  "repository. jev and llm exit 1 when they cannot answer",
10627
10844
  "every question, with nothing on stdout"
10628
10845
  ]
10846
+ },
10847
+ {
10848
+ token: "history list",
10849
+ kind: "historyList",
10850
+ arity: "none",
10851
+ details: [
10852
+ "List the finished processes kept behind refs/gtd/history/*,",
10853
+ "newest first: entry, branch, tip, date and subject per line.",
10854
+ "--json prints one array of { ref, entry, branch, tip, date,",
10855
+ "subject }. Writes nothing and never replays; with no refs it",
10856
+ "hints at `gtd history fetch | sh` and still exits 0"
10857
+ ]
10858
+ },
10859
+ {
10860
+ token: "history show",
10861
+ kind: "historyShow",
10862
+ arity: { name: "ref" },
10863
+ details: [
10864
+ "Replay one kept process under today's config and print its",
10865
+ "steps and judge verdicts. <ref> is a full ref, an entry hash",
10866
+ "(or unique prefix) or a branch name. Refuses (exit 1) when a",
10867
+ "config file differs from its content at the ref's tip. A",
10868
+ "version mismatch only warns on stderr. --json prints the",
10869
+ "trail as one document; writes nothing"
10870
+ ]
10871
+ },
10872
+ {
10873
+ token: "history fetch",
10874
+ kind: "historyFetch",
10875
+ arity: {
10876
+ name: "remote",
10877
+ optional: true
10878
+ },
10879
+ details: [
10880
+ "Print a script that fetches every refs/gtd/history/* from",
10881
+ "<remote> (default origin) and reports how many arrived — run",
10882
+ "it with `gtd history fetch | sh`. Never runs git itself"
10883
+ ]
10629
10884
  }
10630
10885
  ];
10631
10886
  const commandByToken = (token) => COMMAND_ROWS.find((r) => r.token === token);
@@ -10645,7 +10900,7 @@ const flagHeader = (row) => {
10645
10900
  };
10646
10901
  const renderHelp = () => {
10647
10902
  const commandBlocks = COMMAND_ROWS.map((row) => {
10648
- const header = row.arity === "none" ? row.token : "names" in row.arity ? `${row.token} <${row.arity.names[0]}> <${row.arity.names[1]}>` : "variadic" in row.arity ? `${row.token} <${row.arity.name}> [${row.arity.variadic}...]` : `${row.token} <${row.arity.name}>`;
10903
+ const header = row.arity === "none" ? row.token : "names" in row.arity ? `${row.token} <${row.arity.names[0]}> <${row.arity.names[1]}>` : "variadic" in row.arity ? `${row.token} <${row.arity.name}> [${row.arity.variadic}...]` : "optional" in row.arity ? `${row.token} [${row.arity.name}]` : `${row.token} <${row.arity.name}>`;
10649
10904
  return renderBlock(header, row.details);
10650
10905
  });
10651
10906
  return `Usage: gtd [command] [options]\n\nCommands:\n${[
@@ -10756,6 +11011,7 @@ const conflictViolation = (present) => {
10756
11011
  const arityError = (cmd, rest, arity) => {
10757
11012
  if (arity === "none") return rest.length > 0 ? `gtd ${cmd}: too many arguments — expected none, got: ${rest.join(", ")}` : void 0;
10758
11013
  if ("names" in arity) return pairArityError(cmd, rest, arity.names);
11014
+ if ("optional" in arity) return rest.length > 1 ? `gtd ${cmd}: too many arguments — expected at most one ${arity.name}, got: ${rest.join(", ")}` : void 0;
10759
11015
  if (rest.length === 0) return `gtd ${cmd}: missing ${arity.name} argument`;
10760
11016
  if (!("variadic" in arity) && rest.length > 1) return `gtd ${cmd}: too many arguments — expected one ${arity.name}, got: ${rest.join(", ")}`;
10761
11017
  };
@@ -10806,7 +11062,9 @@ const parseArgv = (argv) => {
10806
11062
  kind: "output",
10807
11063
  stdout: renderHelp()
10808
11064
  };
10809
- const row = first === void 0 ? void 0 : commandByToken(first);
11065
+ const isHistory = first === "history";
11066
+ const row = first === void 0 ? void 0 : commandByToken(isHistory ? `history ${positionals[1] ?? ""}` : first);
11067
+ if (isHistory && row === void 0) return usagePlan("gtd history: expected a subcommand — list, show <ref> or fetch [remote]", jsonSeen);
10810
11068
  const judgeAnswer = row?.kind === "judge" && positionals[1] === "answer";
10811
11069
  const selectsStart = workflowPresent && first === void 0;
10812
11070
  const judgeRun = row?.kind === "judge" && positionals[1] === "run";
@@ -10817,11 +11075,11 @@ const parseArgv = (argv) => {
10817
11075
  if (first === void 0) return usagePlan("gtd: missing command — gtd decides and prints, a driver executes; run `gtd install`, or copy one from https://github.com/pmelab/gtd/blob/main/docs/driver.md's \"A complete minimal driver\" section and run that, or see usage above (`gtd --help`)", jsonSeen, renderHelp());
10818
11076
  return usagePlan(`unknown command '${first}'`, jsonSeen);
10819
11077
  }
10820
- const restPositionals = first === void 0 ? positionals : positionals.slice(judgeAnswer || judgeRun ? 2 : 1);
11078
+ const restPositionals = first === void 0 ? positionals : positionals.slice(judgeAnswer || judgeRun || isHistory ? 2 : 1);
10821
11079
  if (kind === "start" && first === void 0) {
10822
11080
  if (restPositionals.length > 0) return usagePlan(`gtd: too many arguments — expected none, got: ${restPositionals.join(", ")}`, jsonSeen);
10823
11081
  } else {
10824
- const arityMsg = kind === "check" && restPositionals.length === 0 && present.has("--open-threads") ? void 0 : arityError(first, restPositionals, row.arity);
11082
+ const arityMsg = kind === "check" && restPositionals.length === 0 && present.has("--open-threads") ? void 0 : arityError(isHistory ? row.token : first, restPositionals, row.arity);
10825
11083
  if (arityMsg !== void 0) return usagePlan(arityMsg, jsonSeen);
10826
11084
  }
10827
11085
  const violation = scopeViolation(kind, present);
@@ -10910,6 +11168,24 @@ const parseArgv = (argv) => {
10910
11168
  verbose
10911
11169
  };
10912
11170
  }
11171
+ if (kind === "historyShow") return {
11172
+ kind: "command",
11173
+ command: {
11174
+ kind: "historyShow",
11175
+ ref: restPositionals[0]
11176
+ },
11177
+ json,
11178
+ verbose
11179
+ };
11180
+ if (kind === "historyFetch") return {
11181
+ kind: "command",
11182
+ command: {
11183
+ kind: "historyFetch",
11184
+ remote: restPositionals[0] ?? "origin"
11185
+ },
11186
+ json,
11187
+ verbose
11188
+ };
10913
11189
  if (kind === "uncheck") return {
10914
11190
  kind: "command",
10915
11191
  command: {
@@ -11010,6 +11286,7 @@ var InMemRepo = class InMemRepo {
11010
11286
  currentBranch = "main";
11011
11287
  worktree = /* @__PURE__ */ new Map();
11012
11288
  index = /* @__PURE__ */ new Map();
11289
+ nextSeq = 1;
11013
11290
  getCommit(hash) {
11014
11291
  return this.commits.get(hash) ?? null;
11015
11292
  }
@@ -11084,10 +11361,25 @@ var InMemRepo = class InMemRepo {
11084
11361
  if (!c) return null;
11085
11362
  return c.message.split("\n")[0] ?? null;
11086
11363
  }
11087
- lastCommitMessage() {
11088
- const c = this.headCommit();
11089
- if (!c) return null;
11090
- return c.message;
11364
+ lastCommitMessage(ref) {
11365
+ const hash = ref === void 0 ? this.head : this.resolveRef(ref);
11366
+ const c = hash === null ? null : this.getCommit(hash);
11367
+ return c === null ? null : c.message;
11368
+ }
11369
+ branchName() {
11370
+ return this.currentBranch;
11371
+ }
11372
+ /** Refs under `prefix`, newest tip first; the commit sequence number stands in for the committer date. */
11373
+ listRefs(prefix) {
11374
+ return [...this.refs.entries()].filter(([ref]) => ref.startsWith(prefix)).map(([ref, hash]) => ({
11375
+ ref,
11376
+ hash,
11377
+ seq: this.getCommit(hash)?.seq ?? 0
11378
+ })).sort((a, b) => b.seq - a.seq).map(({ ref, hash, seq }) => ({
11379
+ ref,
11380
+ hash,
11381
+ date: (/* @__PURE__ */ new Date(seq * 1e3)).toISOString().replace(/\.\d{3}Z$/, "Z")
11382
+ }));
11091
11383
  }
11092
11384
  /**
11093
11385
  * `head` (default `this.head`, mirroring production's `"HEAD"` default) is
@@ -11201,7 +11493,8 @@ var InMemRepo = class InMemRepo {
11201
11493
  hash,
11202
11494
  message,
11203
11495
  files: new Map(tree),
11204
- parent
11496
+ parent,
11497
+ seq: this.nextSeq++
11205
11498
  };
11206
11499
  this.commits.set(hash, commit);
11207
11500
  this.head = hash;
@@ -11222,7 +11515,8 @@ var InMemRepo = class InMemRepo {
11222
11515
  hash,
11223
11516
  message,
11224
11517
  files: new Map(tree),
11225
- parent
11518
+ parent,
11519
+ seq: this.nextSeq++
11226
11520
  };
11227
11521
  this.commits.set(hash, commit);
11228
11522
  this.head = hash;
@@ -11364,10 +11658,12 @@ const makeGitReaderOps = (repo, root) => ({
11364
11658
  const subject = repo.lastCommitSubject(ref);
11365
11659
  return subject !== null ? succeed$6(subject) : fail$5(/* @__PURE__ */ new Error("No commits"));
11366
11660
  },
11367
- lastCommitMessage: () => {
11368
- const message = repo.lastCommitMessage();
11661
+ lastCommitMessage: (ref) => {
11662
+ const message = repo.lastCommitMessage(ref);
11369
11663
  return message !== null ? succeed$6(message) : fail$5(/* @__PURE__ */ new Error("No commits"));
11370
11664
  },
11665
+ currentBranch: () => succeed$6(some(repo.branchName())),
11666
+ listRefs: (prefix) => succeed$6(repo.listRefs(prefix)),
11371
11667
  resolveRef: (ref) => {
11372
11668
  const hash = repo.resolveRef(ref);
11373
11669
  return hash !== null ? succeed$6(hash) : fail$5(/* @__PURE__ */ new Error(`Cannot resolve ref: ${ref}`));
@@ -11647,7 +11943,7 @@ const recognizeGitBuilders = (repo, block) => {
11647
11943
  return { kind: "applied" };
11648
11944
  }
11649
11945
  const conditionLine = conditionStatement(block);
11650
- if (block.startsWith("git add -A &&\n")) {
11946
+ if (block.startsWith("git add -A || exit 1\n")) {
11651
11947
  const [message] = extractQuotedTokens(conditionLine);
11652
11948
  if (message !== void 0 && commitAll(message) === block) {
11653
11949
  repo.commitAllWithPrefix(message);
@@ -12212,6 +12508,21 @@ const gitExecIn = (dir, ...args) => execSync(`git ${args.join(" ")}`, {
12212
12508
  const makeLiveTier = (initialCommit = true) => {
12213
12509
  const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-git-tier-")));
12214
12510
  const gitExec = (...args) => gitExecIn(root, ...args);
12511
+ let tick = 0;
12512
+ const commitEnv = () => ({
12513
+ ...process.env,
12514
+ GIT_COMMITTER_DATE: new Date(Date.UTC(2024, 0, 1, 0, 0, ++tick)).toISOString()
12515
+ });
12516
+ const gitCommit = (message, allowEmpty = false) => execFileSync("git", [
12517
+ "commit",
12518
+ ...allowEmpty ? ["--allow-empty"] : [],
12519
+ "-m",
12520
+ message
12521
+ ], {
12522
+ cwd: root,
12523
+ env: commitEnv(),
12524
+ stdio: "pipe"
12525
+ });
12215
12526
  gitExec("init");
12216
12527
  gitExec(`config user.email "test@test.com"`);
12217
12528
  gitExec(`config user.name "Test"`);
@@ -12219,7 +12530,7 @@ const makeLiveTier = (initialCommit = true) => {
12219
12530
  if (initialCommit) {
12220
12531
  writeFileSync(join(root, "readme.txt"), "hello");
12221
12532
  gitExec("add", "-A");
12222
- gitExec(`commit -m "init: first commit"`);
12533
+ gitCommit("init: first commit");
12223
12534
  }
12224
12535
  const provide = (eff) => runPromise(eff.pipe(provide$2(GitService.Live), provide$2(Host.layer({
12225
12536
  root,
@@ -12250,7 +12561,7 @@ const makeLiveTier = (initialCommit = true) => {
12250
12561
  writeFileSync(join(root, path), content);
12251
12562
  }
12252
12563
  gitExec("add", "-A");
12253
- gitExec(`commit --allow-empty -m "${message}"`);
12564
+ gitCommit(message, true);
12254
12565
  },
12255
12566
  writeFile: (path, content) => {
12256
12567
  mkdirSync(dirname(join(root, path)), { recursive: true });
@@ -12414,6 +12725,54 @@ const runGitServiceContract = (makeTier) => {
12414
12725
  expect(await runGit(t, (g) => g.lastCommitMessage())).toBe("feat: subject only");
12415
12726
  });
12416
12727
  });
12728
+ describe("lastCommitMessage with a ref", () => {
12729
+ it("reads the given ref's full message, not HEAD's", async () => {
12730
+ t.seed.commit("feat: first\n\nGtd-Branch: one");
12731
+ t.seed.commit("feat: second", { "extra.txt": "extra" });
12732
+ expect(await runGit(t, (g) => g.lastCommitMessage("HEAD~1"))).toBe("feat: first\n\nGtd-Branch: one");
12733
+ });
12734
+ });
12735
+ describe("currentBranch", () => {
12736
+ it("is the checked-out branch's name", async () => {
12737
+ expect(isSome(await runGit(t, (g) => g.currentBranch()))).toBe(true);
12738
+ });
12739
+ it.skipIf(!capabilities.onDiskContent)("is none on a detached HEAD, never a failure", async () => {
12740
+ gitExecIn(t.root, "checkout", "--detach", "HEAD");
12741
+ expect(isNone(await runGit(t, (g) => g.currentBranch()))).toBe(true);
12742
+ });
12743
+ });
12744
+ describe("listRefs", () => {
12745
+ it("is [] when no ref lives under the prefix", async () => {
12746
+ expect(await runGit(t, (g) => g.listRefs("refs/gtd/history/"))).toEqual([]);
12747
+ });
12748
+ it("lists {ref, hash, date} for refs under the prefix only, newest tip first", async () => {
12749
+ const first = t.observe.resolveRef("HEAD");
12750
+ t.seed.commit("feat: second", { "second.txt": "2" });
12751
+ const second = t.observe.resolveRef("HEAD");
12752
+ t.seed.updateRef("refs/gtd/history/old", first);
12753
+ t.seed.updateRef("refs/gtd/history/new", second);
12754
+ t.seed.updateRef("refs/gtd/other/elsewhere", second);
12755
+ const refs = await runGit(t, (g) => g.listRefs("refs/gtd/history/"));
12756
+ expect(refs.map((r) => [r.ref, r.hash])).toEqual([["refs/gtd/history/new", second], ["refs/gtd/history/old", first]]);
12757
+ for (const r of refs) expect(r.date.length).toBeGreaterThan(0);
12758
+ });
12759
+ it("reports the date as ISO 8601 strict", async () => {
12760
+ t.seed.updateRef("refs/gtd/history/x", t.observe.resolveRef("HEAD"));
12761
+ const [entry] = await runGit(t, (g) => g.listRefs("refs/gtd/history/"));
12762
+ expect(entry?.date).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/);
12763
+ });
12764
+ });
12765
+ describe("history reachable only from a retained ref", () => {
12766
+ it("commitHistory and readFileAtRef read it after HEAD moved off those commits", async () => {
12767
+ t.seed.commit("feat: second", { "kept.txt": "kept" });
12768
+ const tip = t.observe.resolveRef("HEAD");
12769
+ t.seed.updateRef("refs/gtd/history/tip", tip);
12770
+ t.seed.mixedReset("HEAD~1");
12771
+ expect(t.observe.resolveRef("HEAD")).not.toBe(tip);
12772
+ expect((await runGit(t, (g) => g.commitHistory(void 0, "refs/gtd/history/tip"))).map((c) => c.message)).toEqual(["init: first commit", "feat: second"]);
12773
+ expect(await runGit(t, (g) => g.readFileAtRef("refs/gtd/history/tip", "kept.txt"))).toBe("kept");
12774
+ });
12775
+ });
12417
12776
  describe("hasCommits", () => {
12418
12777
  it("returns false in an empty repo", async () => {
12419
12778
  const empty = t.emptyRepo();
@@ -12974,6 +13333,11 @@ const runGitScriptContract = () => {
12974
13333
  expect(t.observe.refExists("refs/gtd/marker")).toBe(true);
12975
13334
  expect(t.observe.resolveRef("refs/gtd/marker")).toBe(head);
12976
13335
  });
13336
+ it("updateRef with HEAD points the ref at the commit just made", () => {
13337
+ execScript(t, commitAll("feat: just made"));
13338
+ execScript(t, updateRef("refs/gtd/history/x", "HEAD"));
13339
+ expect(t.observe.resolveRef("refs/gtd/history/x")).toBe(t.observe.resolveRef("HEAD"));
13340
+ });
12977
13341
  it("deleteRef removes an existing ref, and tolerates deleting it again", () => {
12978
13342
  const head = t.observe.resolveRef("HEAD");
12979
13343
  execScript(t, updateRef("refs/gtd/marker", head));
@@ -12986,6 +13350,54 @@ const runGitScriptContract = () => {
12986
13350
  expect(t.observe.refExists("refs/gtd/never-created")).toBe(false);
12987
13351
  });
12988
13352
  });
13353
+ describe("fetchHistoryRefs", () => {
13354
+ it("pulls every refs/gtd/history/* from a bare remote and prints the count", () => {
13355
+ const bare = realpathSync(mkdtempSync(join(tmpdir(), "gtd-git-tier-bare-")));
13356
+ try {
13357
+ gitExecIn(bare, "init", "-q", "--bare");
13358
+ gitExecIn(t.root, "remote", "add", "origin", bare);
13359
+ const head = t.observe.resolveRef("HEAD");
13360
+ gitExecIn(t.root, "push", "-q", "origin", `${head}:refs/gtd/history/one`);
13361
+ gitExecIn(t.root, "push", "-q", "origin", `${head}:refs/gtd/history/two`);
13362
+ gitExecIn(t.root, "push", "-q", "origin", `${head}:refs/gtd/other/skip`);
13363
+ const out = execFileSync("bash", ["-c", fetchHistoryRefs("origin")], {
13364
+ cwd: t.root,
13365
+ encoding: "utf8"
13366
+ });
13367
+ expect(t.observe.refExists("refs/gtd/history/one")).toBe(true);
13368
+ expect(t.observe.refExists("refs/gtd/history/two")).toBe(true);
13369
+ expect(t.observe.refExists("refs/gtd/other/skip")).toBe(false);
13370
+ expect(out.trim()).toBe("2 history refs arrived");
13371
+ } finally {
13372
+ rmSync(bare, {
13373
+ recursive: true,
13374
+ force: true
13375
+ });
13376
+ }
13377
+ });
13378
+ it("fails loudly when a local history ref conflicts with the remote's", () => {
13379
+ const bare = realpathSync(mkdtempSync(join(tmpdir(), "gtd-git-tier-bare-")));
13380
+ try {
13381
+ gitExecIn(bare, "init", "-q", "--bare");
13382
+ gitExecIn(t.root, "remote", "add", "origin", bare);
13383
+ const first = t.observe.resolveRef("HEAD");
13384
+ t.seed.commit("feat: second", { "second.txt": "x" });
13385
+ const second = t.observe.resolveRef("HEAD");
13386
+ gitExecIn(t.root, "push", "-q", "origin", `${first}:refs/gtd/history/one`);
13387
+ t.seed.updateRef("refs/gtd/history/one", second);
13388
+ expect(() => execFileSync("bash", ["-c", fetchHistoryRefs("origin")], {
13389
+ cwd: t.root,
13390
+ stdio: "pipe"
13391
+ })).toThrow();
13392
+ expect(t.observe.resolveRef("refs/gtd/history/one")).toBe(second);
13393
+ } finally {
13394
+ rmSync(bare, {
13395
+ recursive: true,
13396
+ force: true
13397
+ });
13398
+ }
13399
+ });
13400
+ });
12989
13401
  };
12990
13402
  //#endregion
12991
13403
  //#region src/testkit/StrictGitOperations.ts