@pmelab/gtd 24.2.0 → 24.4.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 (81) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +41 -6
  3. package/dist/chunks/{Schema-Dh-mkwkq.mjs → Schema-B5yK7QrT.mjs} +47 -1
  4. package/dist/chunks/{reserved-ejn-LVYO.mjs → reserved-B9pG93Fc.mjs} +53 -13
  5. package/dist/chunks/{steering-D-PvRC5W.mjs → steering-DVILanVf.mjs} +16 -5
  6. package/dist/flows.mjs +9 -1
  7. package/dist/gtd.bundle.mjs +1114 -865
  8. package/dist/steering.mjs +2 -2
  9. package/dist/testkit.mjs +1135 -218
  10. package/dist/workflow-kit.mjs +9 -7
  11. package/dist/workflows/feature.mjs +152 -35
  12. package/dist/workflows/fix.mjs +2 -2
  13. package/dist/workflows/review.mjs +1 -1
  14. package/dist/workflows.mjs +223 -54
  15. package/package.json +1 -1
  16. package/src/cli/Cli.test.ts +57 -0
  17. package/src/cli/Cli.ts +89 -9
  18. package/src/cli/History.test.ts +225 -0
  19. package/src/cli/History.ts +143 -0
  20. package/src/cli/Output.ts +68 -0
  21. package/src/cli/Program.test.ts +10 -9
  22. package/src/cli/Program.ts +24 -72
  23. package/src/edge/Edge.test.ts +261 -3
  24. package/src/edge/Edge.ts +218 -41
  25. package/src/edge/index.ts +1 -0
  26. package/src/emit/GitScript.test.ts +27 -0
  27. package/src/emit/GitScript.ts +18 -2
  28. package/src/emit/index.ts +2 -0
  29. package/src/flows/scripts.ts +6 -0
  30. package/src/platform/Git.test.ts +6 -9
  31. package/src/platform/Git.ts +48 -10
  32. package/src/platform/RetainedHistory.test.ts +14 -0
  33. package/src/platform/RetainedHistory.ts +5 -0
  34. package/src/platform/index.ts +7 -1
  35. package/src/replay/Trailers.test.ts +32 -0
  36. package/src/replay/Trailers.ts +17 -0
  37. package/src/steering/FORMATS.md +1 -1
  38. package/src/steering/SteeringFormat.test.ts +9 -1
  39. package/src/steering/SteeringFormat.ts +7 -0
  40. package/src/steering/contract.test.ts +3 -0
  41. package/src/steering/index.ts +1 -0
  42. package/src/steering/review.test.ts +30 -0
  43. package/src/steering/review.ts +8 -2
  44. package/src/step/LandStep.ts +3 -1
  45. package/src/step/RepoSnapshot.ts +7 -1
  46. package/src/step/planStart.test.ts +6 -3
  47. package/src/step/planStep.test.ts +29 -1
  48. package/src/step/planStep.ts +8 -0
  49. package/src/testkit/EmittedScriptRecognizer.test.ts +29 -30
  50. package/src/testkit/EmittedScriptRecognizer.ts +149 -52
  51. package/src/testkit/EmittedScripts.test.ts +166 -0
  52. package/src/testkit/FakeGitOperations.ts +6 -2
  53. package/src/testkit/FlowScripts.test.ts +242 -0
  54. package/src/testkit/FlowScripts.ts +326 -0
  55. package/src/testkit/GitTiers.ts +269 -47
  56. package/src/testkit/InMemRepo.ts +61 -8
  57. package/src/testkit/Layers.ts +10 -2
  58. package/src/testkit/TestRepo.ts +4 -1
  59. package/src/testkit/index.ts +1 -1
  60. package/src/workflow/discovery.test.ts +147 -0
  61. package/src/workflow/discovery.ts +5 -0
  62. package/src/workflow/fingerprint.test.ts +20 -4
  63. package/src/workflow/fingerprint.ts +8 -9
  64. package/src/workflow/index.ts +1 -6
  65. package/src/workflow/load.test.ts +5 -19
  66. package/src/workflow/load.ts +94 -10
  67. package/src/workflow-kit/review.test.ts +84 -0
  68. package/src/workflow-kit/review.ts +9 -6
  69. package/src/workflows/feature/review.test.ts +238 -12
  70. package/src/workflows/feature/review.ts +172 -31
  71. package/src/workflows/feature/steps.test.ts +12 -0
  72. package/src/workflows/feature/steps.ts +8 -1
  73. package/src/workflows/shared/access.test.ts +4 -2
  74. package/src/workflows/shared/access.ts +1 -0
  75. package/src/workflows/shared/options.test.ts +8 -0
  76. package/src/workflows/shared/options.ts +1 -0
  77. package/src/workflows/shared/steps.ts +2 -0
  78. package/src/workflows/shared/tail.ts +3 -11
  79. package/src/workflows/shared/text.test.ts +106 -0
  80. package/src/workflows/shared/text.ts +57 -1
  81. package/src/workflows/shared/vars.ts +1 -0
package/dist/testkit.mjs CHANGED
@@ -1,19 +1,19 @@
1
- import { configProblems, configure, installContext, isFactoryWorkflow, protocolMismatch, t as flows_exports } from "./flows.mjs";
1
+ import { checkScript, configProblems, configure, installContext, isFactoryWorkflow, moveScript, protocolMismatch, removeScript, restoreScript, revertScript, showScript, 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
- 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";
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, w as formatFinding } from "./chunks/steering-DVILanVf.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
- import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
12
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
13
13
  import * as OS from "node:os";
14
14
  import { homedir, tmpdir } from "node:os";
15
15
  import * as ChildProcess from "node:child_process";
16
- import { execFileSync, execSync } from "node:child_process";
16
+ import { execFileSync, execSync, spawnSync } from "node:child_process";
17
17
  import * as NodeUrl from "node:url";
18
18
  import { fileURLToPath, pathToFileURL } from "node:url";
19
19
  import * as readline from "node:readline";
@@ -2438,9 +2438,9 @@ const pipeToOrFail = /*#__PURE__*/ dual(2, (self, that) => suspend$3(() => {
2438
2438
  return pipeTo$1(pipeTo$1(pipeTo$1(self, reader), that), writer);
2439
2439
  }));
2440
2440
  /** @internal */
2441
- const run$3 = (self) => scopedWith$1((scope) => runIn(self, scope));
2441
+ const run$4 = (self) => scopedWith$1((scope) => runIn(self, scope));
2442
2442
  /** @internal */
2443
- const runDrain = (self) => run$3(drain$2(self));
2443
+ const runDrain = (self) => run$4(drain$2(self));
2444
2444
  /** @internal */
2445
2445
  const runScoped = (self) => scopeWith((scope) => runIn(self, scope));
2446
2446
  /** @internal */
@@ -3013,7 +3013,7 @@ const map = /*#__PURE__*/ dual(2, (self, f) => new StreamImpl(pipe(toChannel$2(s
3013
3013
  /** @internal */
3014
3014
  const mapChunks$1 = /*#__PURE__*/ dual(2, (self, f) => new StreamImpl(pipe(toChannel$2(self), mapOut(f))));
3015
3015
  /** @internal */
3016
- const mkString$1 = (self) => run$2(self, mkString$2);
3016
+ const mkString$1 = (self) => run$3(self, mkString$2);
3017
3017
  /** @internal */
3018
3018
  const pipeThroughChannel = /*#__PURE__*/ dual(2, (self, channel) => new StreamImpl(pipeTo$1(toChannel$2(self), channel)));
3019
3019
  /** @internal */
@@ -3022,9 +3022,9 @@ const repeatEffectChunkOption = (effect) => unfoldChunkEffect(effect, (effect) =
3022
3022
  onSome: fail$5
3023
3023
  }))));
3024
3024
  /** @internal */
3025
- const run$2 = /*#__PURE__*/ dual(2, (self, sink) => toChannel$2(self).pipe(pipeToOrFail(toChannel$3(sink)), runDrain));
3025
+ const run$3 = /*#__PURE__*/ dual(2, (self, sink) => toChannel$2(self).pipe(pipeToOrFail(toChannel$3(sink)), runDrain));
3026
3026
  /** @internal */
3027
- const runCollect$1 = (self) => run$2(self, collectAll());
3027
+ const runCollect$1 = (self) => run$3(self, collectAll());
3028
3028
  /** @internal */
3029
3029
  const runIntoQueueScoped = /*#__PURE__*/ dual(2, (self, queue) => {
3030
3030
  const writer = readWithCause({
@@ -3764,7 +3764,7 @@ const mkString = mkString$1;
3764
3764
  * @since 2.0.0
3765
3765
  * @category destructors
3766
3766
  */
3767
- const run$1 = run$2;
3767
+ const run$2 = run$3;
3768
3768
  /**
3769
3769
  * Runs the stream and collects all of its elements to a chunk.
3770
3770
  *
@@ -4726,7 +4726,7 @@ const makeExecutor$1 = (start) => {
4726
4726
  stream,
4727
4727
  string: (command, encoding = "utf-8") => {
4728
4728
  const decoder = new TextDecoder(encoding);
4729
- return pipe(start(command), flatMap$4((process) => run$1(process.stdout, collectUint8Array)), map$4((bytes) => decoder.decode(bytes)), scoped$4);
4729
+ return pipe(start(command), flatMap$4((process) => run$2(process.stdout, collectUint8Array)), map$4((bytes) => decoder.decode(bytes)), scoped$4);
4730
4730
  },
4731
4731
  lines: (command, encoding = "utf-8") => {
4732
4732
  return pipe(streamLines(command, encoding), runCollect, map$4(toArray));
@@ -4959,7 +4959,7 @@ const ProcessProto = {
4959
4959
  };
4960
4960
  }
4961
4961
  };
4962
- const runCommand$1 = (fileSystem) => (command) => {
4962
+ const runCommand$2 = (fileSystem) => (command) => {
4963
4963
  switch (command._tag) {
4964
4964
  case "StandardCommand": {
4965
4965
  const spawn = flatMap$4(make$15(), (exitCode) => async((resume) => {
@@ -5031,17 +5031,17 @@ const runCommand$1 = (fileSystem) => (command) => {
5031
5031
  stderr,
5032
5032
  stdout
5033
5033
  });
5034
- }), typeof command.stdin === "string" ? identity : tap((process) => forkDaemon(run$1(command.stdin, process.stdin))));
5034
+ }), typeof command.stdin === "string" ? identity : tap((process) => forkDaemon(run$2(command.stdin, process.stdin))));
5035
5035
  }
5036
5036
  case "PipedCommand": {
5037
5037
  const flattened = flatten(command);
5038
- if (flattened.length === 1) return pipe(flattened[0], runCommand$1(fileSystem));
5038
+ if (flattened.length === 1) return pipe(flattened[0], runCommand$2(fileSystem));
5039
5039
  const head = flattened[0];
5040
5040
  const tail = flattened.slice(1);
5041
5041
  const initial = tail.slice(0, tail.length - 1);
5042
5042
  const last = tail[tail.length - 1];
5043
- const stream = initial.reduce((stdin$2, command) => pipe(stdin(command, stdin$2), runCommand$1(fileSystem), map$4((process) => process.stdout), unwrapScoped), pipe(runCommand$1(fileSystem)(head), map$4((process) => process.stdout), unwrapScoped));
5044
- return pipe(stdin(last, stream), runCommand$1(fileSystem));
5043
+ const stream = initial.reduce((stdin$2, command) => pipe(stdin(command, stdin$2), runCommand$2(fileSystem), map$4((process) => process.stdout), unwrapScoped), pipe(runCommand$2(fileSystem)(head), map$4((process) => process.stdout), unwrapScoped));
5044
+ return pipe(stdin(last, stream), runCommand$2(fileSystem));
5045
5045
  }
5046
5046
  }
5047
5047
  };
@@ -5051,7 +5051,7 @@ const runCommand$1 = (fileSystem) => (command) => {
5051
5051
  * @since 1.0.0
5052
5052
  * @category layer
5053
5053
  */
5054
- const layer$5 = /* @__PURE__ */ effect$1(CommandExecutor, /*#__PURE__*/ pipe(FileSystem, /*#__PURE__*/ map$4((fileSystem) => makeExecutor(runCommand$1(fileSystem)))));
5054
+ const layer$5 = /* @__PURE__ */ effect$1(CommandExecutor, /*#__PURE__*/ pipe(FileSystem, /*#__PURE__*/ map$4((fileSystem) => makeExecutor(runCommand$2(fileSystem)))));
5055
5055
  //#endregion
5056
5056
  //#region node_modules/@effect/platform-node-shared/dist/esm/internal/path.js
5057
5057
  const fromFileUrl = (url) => try_({
@@ -5352,7 +5352,7 @@ const classifyUntracked = (exec, ref, untrackedPaths) => gen(function* () {
5352
5352
  * `git add`. Callers that expect a probe to fail handle it with an explicit
5353
5353
  * `catchAll`.
5354
5354
  */
5355
- const run = (root, ...args) => scoped$4(gen(function* () {
5355
+ const run$1 = (root, ...args) => scoped$4(gen(function* () {
5356
5356
  const process = yield* (yield* CommandExecutor).start(make$1(...args).pipe(workingDirectory(root), stdout("pipe"), stderr("pipe")));
5357
5357
  const collect = (stream) => stream.pipe(decodeText(), mkString);
5358
5358
  const [stdout$3, stderr$3, exitCode] = yield* all$1([
@@ -5386,10 +5386,19 @@ var InvalidRefError = class extends Error {
5386
5386
  */
5387
5387
  const resolvedRefOrCorrupted = (ref, hash) => /^[0-9a-f]{40}$/.test(hash) ? succeed$6(hash) : fail$5(new InvalidRefError(`Invalid ref: ${ref}`, [`ref: ${ref}`]));
5388
5388
  const makeGitImpl = (executor, root) => {
5389
- const exec = (...args) => run(root, ...args).pipe(provide$2(succeed$1(CommandExecutor, executor)));
5389
+ const exec = (...args) => run$1(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,9 +5418,9 @@ 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
- 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))),
5423
+ isAncestor: (a, b) => run$1(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()))),
5416
5425
  defaultBranch: () => exec("git", "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD").pipe(map$4((s) => s.trim()), catchAll$1(() => succeed$6("")), map$4((name) => name === "" ? "main" : name)),
5417
5426
  topLevel: () => exec("git", "rev-parse", "--show-toplevel").pipe(map$4((s) => s.trim())),
@@ -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
  };
@@ -7481,6 +7503,30 @@ var ConfigDiscovery = class ConfigDiscovery extends Tag("ConfigDiscovery")() {
7481
7503
  });
7482
7504
  };
7483
7505
  //#endregion
7506
+ //#region src/workflow/fingerprint.ts
7507
+ const HASH_LENGTH = 16;
7508
+ const CONTROL_CHARACTER = /[\x00-\x1f\x7f-\x9f]/;
7509
+ const contentHash = (content) => createHash("sha256").update(content).digest("hex").slice(0, HASH_LENGTH);
7510
+ const realOr = (path) => {
7511
+ try {
7512
+ return realpathSync(path);
7513
+ } catch {
7514
+ return path;
7515
+ }
7516
+ };
7517
+ const configFingerprint = (files, root, read) => {
7518
+ const fingerprint = {};
7519
+ const realRoot = realOr(root);
7520
+ for (const file of files) {
7521
+ const path = relative(realRoot, realOr(file)).split(sep).join("/");
7522
+ if (CONTROL_CHARACTER.test(path)) continue;
7523
+ const content = read(file);
7524
+ if (content !== void 0) fingerprint[path] = contentHash(content);
7525
+ }
7526
+ return fingerprint;
7527
+ };
7528
+ const changedConfigFiles = (recorded, current) => [.../* @__PURE__ */ new Set([...Object.keys(recorded), ...Object.keys(current)])].filter((path) => recorded[path] !== current[path]).sort();
7529
+ //#endregion
7484
7530
  //#region src/workflow/Diagnostic.ts
7485
7531
  const compareSegment = (a, b) => {
7486
7532
  if (typeof a === "number" && typeof b === "number") return a - b;
@@ -7615,9 +7661,16 @@ const load = gen(function* () {
7615
7661
  },
7616
7662
  ...compiled.ui !== void 0 ? { ui: compiled.ui } : {},
7617
7663
  warnings: diagnostics.filter((d) => d.severity === "warning"),
7618
- workflowFiles: loaded.file.dependencyFiles
7664
+ fingerprint: configFingerprint(loaded.file.dependencyFiles, host.root, module.read ?? readFromDisk)
7619
7665
  };
7620
7666
  });
7667
+ const readFromDisk = (path) => {
7668
+ try {
7669
+ return readFileSync(path, "utf8");
7670
+ } catch {
7671
+ return;
7672
+ }
7673
+ };
7621
7674
  const TRANSFORM_MEMO_SIZE = 512;
7622
7675
  const memoized = (transform) => {
7623
7676
  const memo = /* @__PURE__ */ new Map();
@@ -7634,12 +7687,18 @@ const memoized = (transform) => {
7634
7687
  };
7635
7688
  let jitiModule;
7636
7689
  let transform;
7637
- const jiti = (shipped) => {
7690
+ const jiti = (shipped, local) => {
7638
7691
  const require = createRequire(import.meta.url);
7639
7692
  jitiModule ??= require("jiti");
7640
7693
  transform ??= memoized(require(join(dirname(require.resolve("jiti/package.json")), "dist", "babel.cjs")));
7641
7694
  return jitiModule.createJiti(import.meta.url, {
7642
- virtualModules: shipped.modules,
7695
+ virtualModules: new Proxy(shipped.modules, {
7696
+ has: (modules, id) => id in modules || typeof id === "string" && local?.resolve(id) !== void 0,
7697
+ get: (modules, id) => {
7698
+ const path = typeof id === "string" ? local?.resolve(id) : void 0;
7699
+ return path === void 0 ? modules[id] : local.load(path);
7700
+ }
7701
+ }),
7643
7702
  alias: shipped.missing,
7644
7703
  fsCache: false,
7645
7704
  moduleCache: false,
@@ -7836,12 +7895,9 @@ const namespaceOf = (exported) => typeof exported === "object" && exported !== n
7836
7895
  * pulled in, transitively.
7837
7896
  */
7838
7897
  const loadWorkflows = (module, shipped) => {
7839
- const cache = {};
7840
- const exports = namespaceOf(jiti(shipped).evalModule(module.source, {
7841
- filename: module.filepath,
7842
- cache
7843
- }));
7844
- const file = fileFrom(exports, module.filepath, Object.keys(cache));
7898
+ const { exports: evaluated, files } = module.read === void 0 ? evaluateFromDisk(module, shipped) : evaluateRead(module, module.read, shipped);
7899
+ const exports = namespaceOf(evaluated);
7900
+ const file = fileFrom(exports, module.filepath, files);
7845
7901
  const exported = Object.entries(exports).filter(([name, value]) => !RESERVED_EXPORTS.has(name) && isWorkflow(value));
7846
7902
  const value = defaultExport(exports);
7847
7903
  const defaultName = exported.find(([, other]) => other === value)?.[0] ?? "default";
@@ -7865,6 +7921,49 @@ const loadWorkflows = (module, shipped) => {
7865
7921
  diagnostics: nameRejections(rejections)
7866
7922
  };
7867
7923
  };
7924
+ const evaluateFromDisk = (module, shipped) => {
7925
+ const cache = {};
7926
+ return {
7927
+ exports: jiti(shipped).evalModule(module.source, {
7928
+ filename: module.filepath,
7929
+ cache
7930
+ }),
7931
+ files: Object.keys(cache)
7932
+ };
7933
+ };
7934
+ const moduleCandidates = (path) => [
7935
+ path,
7936
+ path.replace(/\.js$/, ".ts"),
7937
+ path.replace(/\.mjs$/, ".mts"),
7938
+ `${path}.ts`,
7939
+ join(path, "index.ts")
7940
+ ];
7941
+ /**
7942
+ * Evaluates the config with every local module it imports taken from `read`
7943
+ * instead of the disk. jiti matches a virtual module on the specifier as
7944
+ * written, so each module gets its own instance resolving against its own
7945
+ * directory. A specifier `read` cannot serve falls through to the disk, and
7946
+ * fails the way a missing file does.
7947
+ */
7948
+ const evaluateRead = (module, read, shipped) => {
7949
+ const files = [module.filepath];
7950
+ const evaluated = /* @__PURE__ */ new Map();
7951
+ const evaluate = (filepath, source) => jiti(shipped, {
7952
+ resolve: (specifier) => /^\.\.?\//.test(specifier) ? moduleCandidates(join(dirname(filepath), specifier)).find((candidate) => read(candidate) !== void 0) : void 0,
7953
+ load: (path) => {
7954
+ if (!evaluated.has(path)) {
7955
+ evaluated.set(path, {});
7956
+ files.push(path);
7957
+ evaluated.set(path, evaluate(path, read(path)));
7958
+ }
7959
+ return evaluated.get(path);
7960
+ }
7961
+ }).evalModule(source, { filename: filepath });
7962
+ return {
7963
+ exports: evaluate(module.filepath, module.source),
7964
+ files
7965
+ };
7966
+ };
7868
7967
  const isWorkflow = (value) => typeof value === "function" || isFactoryWorkflow(value);
7869
7968
  const defaultExport = (exports) => {
7870
7969
  const value = exports.default;
@@ -8092,35 +8191,6 @@ const resolveScopeAccess = (def, skills, vars) => gen(function* () {
8092
8191
  //#region src/workflow/Workflow.ts
8093
8192
  const knownModes = (def) => Object.keys(def.modes);
8094
8193
  //#endregion
8095
- //#region src/workflow/fingerprint.ts
8096
- const HASH_LENGTH = 16;
8097
- const CONTROL_CHARACTER = /[\x00-\x1f\x7f-\x9f]/;
8098
- const contentHash = (content) => createHash("sha256").update(content).digest("hex").slice(0, HASH_LENGTH);
8099
- const realOr = (path) => {
8100
- try {
8101
- return realpathSync(path);
8102
- } catch {
8103
- return path;
8104
- }
8105
- };
8106
- const configFingerprint = (files, root) => {
8107
- const fingerprint = {};
8108
- const realRoot = realOr(root);
8109
- for (const file of files) {
8110
- const path = relative(realRoot, realOr(file)).split(sep).join("/");
8111
- if (CONTROL_CHARACTER.test(path)) continue;
8112
- let content;
8113
- try {
8114
- content = readFileSync(file);
8115
- } catch {
8116
- continue;
8117
- }
8118
- fingerprint[path] = contentHash(content);
8119
- }
8120
- return fingerprint;
8121
- };
8122
- const changedConfigFiles = (recorded, current) => [.../* @__PURE__ */ new Set([...Object.keys(recorded), ...Object.keys(current)])].filter((path) => recorded[path] !== current[path]).sort();
8123
- //#endregion
8124
8194
  //#region src/step/planStep.ts
8125
8195
  /**
8126
8196
  * At the human review gate an unconditional `gtd uncheck '<file>'` runs
@@ -8206,6 +8276,14 @@ const planStep = (snapshot, opts = {}) => {
8206
8276
  message
8207
8277
  }
8208
8278
  },
8279
+ ...landing.keepHistory !== void 0 ? [{
8280
+ kind: "gitWrite",
8281
+ write: {
8282
+ kind: "updateRef",
8283
+ ref: landing.keepHistory,
8284
+ target: "HEAD"
8285
+ }
8286
+ }] : [],
8209
8287
  outcome
8210
8288
  ]
8211
8289
  };
@@ -8513,25 +8591,38 @@ const currentRun = gen(function* () {
8513
8591
  const config = yield* (yield* ConfigService).load;
8514
8592
  return yield* computeProcessRun(git, config.workflow);
8515
8593
  });
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) {
8594
+ const runAtTip = (git, def, tip) => gen(function* () {
8595
+ if (tip === void 0) {
8522
8596
  const history = yield* historyUpTo(git, def, void 0);
8523
8597
  return yield* runOf(history, locateEpisode(def, history));
8524
8598
  }
8525
- if (!(parseCommitMessage(headEntry.subject).parsed?.to === def.initial)) {
8526
- const history = yield* historyUpTo(git, def, headEntry.hash);
8599
+ if (!(parseCommitMessage(tip.subject).parsed?.to === def.initial)) {
8600
+ const history = yield* historyUpTo(git, def, tip.hash);
8527
8601
  return yield* runOf(history, locateEpisode(def, history));
8528
8602
  }
8529
- const parentRef = `${headEntry.hash}~1`;
8603
+ const parentRef = `${tip.hash}~1`;
8530
8604
  const before = yield* historyUpTo(git, def, parentRef);
8531
8605
  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);
8606
+ let closing = yield* git.commitHistory(parentRef, tip.hash);
8607
+ if (closing.length === 0) closing = yield* git.commitHistory(void 0, tip.hash);
8608
+ return yield* runOf([...before, ...closing], { ...location }, tip.hash);
8609
+ });
8610
+ /** The process HEAD closes or sits inside — `gtd summary`'s run. */
8611
+ const summaryRun = gen(function* () {
8612
+ const git = yield* GitService;
8613
+ const def = (yield* (yield* ConfigService).load).workflow;
8614
+ return yield* runAtTip(git, def, (yield* git.subjectHistory(1, 0))[0]);
8615
+ });
8616
+ /** The process whose finishing commit is `tip` — reads from `tip`'s own ancestry, so it works when no branch reaches it. */
8617
+ const closedRunAt = (tip) => gen(function* () {
8618
+ const git = yield* GitService;
8619
+ const def = (yield* (yield* ConfigService).load).workflow;
8620
+ const hash = yield* git.resolveRef(tip);
8621
+ const subject = yield* git.lastCommitSubject(hash);
8622
+ return yield* runAtTip(git, def, {
8623
+ hash,
8624
+ subject
8625
+ });
8535
8626
  });
8536
8627
  const commitTree = (workspace, hash) => {
8537
8628
  let entries;
@@ -8751,13 +8842,7 @@ const costByModel = (entries) => {
8751
8842
  cost
8752
8843
  })).sort((a, b) => b.cost - a.cost || a.model.localeCompare(b.model));
8753
8844
  };
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);
8845
+ const setupFor = (config, run, workspace, host, startOverrides = {}) => gen(function* () {
8761
8846
  const resolved = yield* workflowOf(config, run);
8762
8847
  const def = resolved.def;
8763
8848
  const { vars, env } = settingsFor(resolved, run.pinnedVars, host.env, startOverrides);
@@ -8781,19 +8866,34 @@ const restAt = (ref, startOverrides = {}) => gen(function* () {
8781
8866
  tree: commitTree(workspace, c.hash)
8782
8867
  }));
8783
8868
  let pendingMemo;
8784
- const setup = {
8869
+ return {
8870
+ resolved,
8785
8871
  def,
8786
- run,
8787
8872
  vars,
8788
8873
  env,
8789
- budget,
8790
- skills,
8791
- access,
8792
- workspace,
8793
- base,
8794
- episode,
8795
- pendingWorktree: () => pendingMemo ??= workspace.worktreeSync()
8874
+ setup: {
8875
+ def,
8876
+ run,
8877
+ vars,
8878
+ env,
8879
+ budget,
8880
+ skills,
8881
+ access,
8882
+ workspace,
8883
+ base,
8884
+ episode,
8885
+ pendingWorktree: () => pendingMemo ??= workspace.worktreeSync()
8886
+ }
8796
8887
  };
8888
+ });
8889
+ /** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
8890
+ const restAt = (ref, startOverrides = {}) => gen(function* () {
8891
+ const git = yield* GitService;
8892
+ const config = yield* (yield* ConfigService).load;
8893
+ const workspace = yield* Workspace;
8894
+ const host = yield* Host;
8895
+ const run = yield* computeProcessRun(git, config.workflow, ref);
8896
+ const { resolved, def, vars, env, setup } = yield* setupFor(config, run, workspace, host, startOverrides);
8797
8897
  const outcome = yield* promise(() => replayFor(setup));
8798
8898
  const error = replayError(outcome);
8799
8899
  if (error !== void 0 || outcome.kind !== "rest") return yield* fail$5(error ?? /* @__PURE__ */ new Error("gtd: replay found no rest"));
@@ -8806,7 +8906,7 @@ const restAt = (ref, startOverrides = {}) => gen(function* () {
8806
8906
  path: e.path
8807
8907
  })) : [];
8808
8908
  const memory = memoryOf(outcome.trace, run);
8809
- const configNow = configFingerprint(config.workflowFiles, host.root);
8909
+ const configNow = config.fingerprint;
8810
8910
  if (ref === void 0) yield* warnConfigChanges(run, configNow, workspace);
8811
8911
  return {
8812
8912
  def,
@@ -8935,14 +9035,21 @@ const initialStepRefusal = (workflow, step, initial) => workflow !== void 0 && s
8935
9035
  kind: "refusal",
8936
9036
  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
9037
  } : void 0;
8938
- const landingTo = (rest, to) => {
8939
- if (rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state) return {
9038
+ const closingTrailers = (branch) => ({
9039
+ version: GTD_VERSION,
9040
+ ...isSome(branch) ? { branch: branch.value } : {}
9041
+ });
9042
+ const isSettledNoop = (rest, to) => rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state;
9043
+ const landingTo = (rest, to, branch) => {
9044
+ if (isSettledNoop(rest, to)) return {
8940
9045
  kind: "noop",
8941
9046
  settled: true
8942
9047
  };
8943
- const starts = noProcessUnderway(rest) && to !== rest.def.initial;
9048
+ const underway = !noProcessUnderway(rest);
9049
+ const starts = !underway && to !== rest.def.initial;
8944
9050
  const refused = starts ? multilineRefusal(rest.vars) : void 0;
8945
9051
  if (refused !== void 0) return refused;
9052
+ const finishes = underway && to === rest.def.initial;
8946
9053
  return {
8947
9054
  kind: "commit",
8948
9055
  to,
@@ -8954,8 +9061,10 @@ const landingTo = (rest, to) => {
8954
9061
  ...starts ? {
8955
9062
  vars: rest.vars,
8956
9063
  config: rest.config
8957
- } : {}
8958
- }
9064
+ } : {},
9065
+ ...finishes ? closingTrailers(branch) : {}
9066
+ },
9067
+ ...finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {}
8959
9068
  };
8960
9069
  };
8961
9070
  /**
@@ -8963,7 +9072,7 @@ const landingTo = (rest, to) => {
8963
9072
  * replay with the working tree as the pending turn. The target step is
8964
9073
  * computed by replaying, never matched.
8965
9074
  */
8966
- const decideLanding = (rest, verdicts) => gen(function* () {
9075
+ const decideLanding = (rest, verdicts, branch) => gen(function* () {
8967
9076
  const early = cleanLanding(rest);
8968
9077
  if (early !== void 0) return early;
8969
9078
  if (rest.stepDef.kind === "prompt" && rest.stepDef.access?.write != null) {
@@ -8979,13 +9088,14 @@ const decideLanding = (rest, verdicts) => gen(function* () {
8979
9088
  }));
8980
9089
  const to = yield* landingTarget(outcome, rest);
8981
9090
  if (typeof to !== "string") return to;
8982
- return landingTo(rest, to);
9091
+ return landingTo(rest, to, branch);
8983
9092
  });
8984
9093
  /** 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)));
9094
+ 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
9095
  /** Every fact the pure landing planner needs, read once: the rest, its steering file, and the landing decision. */
8987
9096
  const snapshotFromRest = (rest, verdicts) => gen(function* () {
8988
- const landing = yield* decideLanding(rest, verdicts);
9097
+ const branch = yield* (yield* GitService).currentBranch();
9098
+ const landing = yield* decideLanding(rest, verdicts, branch);
8989
9099
  return {
8990
9100
  stepDef: rest.stepDef,
8991
9101
  state: rest.state,
@@ -9016,6 +9126,66 @@ const summaryFor = (run) => gen(function* () {
9016
9126
  ...settingsFor(resolved, run.pinnedVars, host.env)
9017
9127
  });
9018
9128
  });
9129
+ /** 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. */
9130
+ const inRepoConfigPaths = (root, files, recorded) => {
9131
+ const paths = new Set(Object.keys(recorded));
9132
+ for (const file of files) paths.add(relative(root, resolve(root, file)).split(sep).join("/"));
9133
+ return [...paths].filter((p) => p !== "" && !p.startsWith("..") && !isAbsolute(p)).sort();
9134
+ };
9135
+ 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`);
9136
+ const differingConfigPaths = (git, workspace, root, config, run, hash) => gen(function* () {
9137
+ const differing = [];
9138
+ for (const path of inRepoConfigPaths(root, Object.keys(config.fingerprint), run.pinnedConfig?.files ?? {})) if ((yield* orElseSucceed(git.readFileAtRef(hash, path), () => void 0)) !== workspace.readSync(path)) differing.push(path);
9139
+ return differing;
9140
+ });
9141
+ 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");
9142
+ const historySteps = (run) => {
9143
+ const messages = new Map(run.episode.commits.map((c) => [c.hash, c.message]));
9144
+ return run.trace.map((entry) => {
9145
+ const message = parseCommitMessage(messages.get(entry.hash) ?? "");
9146
+ return {
9147
+ hash: entry.hash,
9148
+ subject: (messages.get(entry.hash) ?? "").split("\n")[0] ?? "",
9149
+ actor: entry.actor,
9150
+ from: message.parsed?.from,
9151
+ to: entry.state,
9152
+ verdicts: message.judge
9153
+ };
9154
+ });
9155
+ };
9156
+ 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`;
9157
+ /**
9158
+ * Replays the closed run at `tip` under today's config. The workflow is not
9159
+ * fingerprinted for a version change: one imported from `@pmelab/gtd/workflows`
9160
+ * moves with every upgrade, and the version warning is the only signal.
9161
+ */
9162
+ const historyTrail = (tip) => gen(function* () {
9163
+ const git = yield* GitService;
9164
+ const config = yield* (yield* ConfigService).load;
9165
+ const workspace = yield* Workspace;
9166
+ const host = yield* Host;
9167
+ const hash = yield* git.resolveRef(tip);
9168
+ const run = yield* closedRunAt(hash);
9169
+ const differing = yield* differingConfigPaths(git, workspace, host.root, config, run, hash);
9170
+ if (differing.length > 0) return yield* fail$5(configRefusal(hash, differing));
9171
+ const { setup } = yield* setupFor(config, run, workspace, host);
9172
+ yield* workspace.episodeTrees(run.episode.base, run.episode.commits.map((c) => c.hash));
9173
+ const outcome = yield* promise(() => replayFor(setup));
9174
+ if (outcome.kind !== "ended") return yield* fail$5(notClosedError(hash, outcome));
9175
+ const steps = historySteps(run);
9176
+ const recorded = parseCommitMessage(yield* git.lastCommitMessage(hash)).version;
9177
+ const versionWarning = versionWarningFor(recorded);
9178
+ return {
9179
+ tip: hash,
9180
+ steps,
9181
+ ended: outcome.via,
9182
+ version: {
9183
+ recorded,
9184
+ running: GTD_VERSION
9185
+ },
9186
+ ...versionWarning !== void 0 ? { versionWarning } : {}
9187
+ };
9188
+ });
9019
9189
  //#endregion
9020
9190
  //#region src/emit/GitScript.ts
9021
9191
  const shellQuote = (value) => `'${value.replace(/'/g, "'\\''")}'`;
@@ -9037,8 +9207,8 @@ const commitAllowEmpty = (message) => {
9037
9207
  `fi`
9038
9208
  ].join("\n");
9039
9209
  };
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)}`;
9210
+ /** `git add -A || exit 1` (an `&&` left side would not abort under `set -e`) then `commitAllowEmpty`. */
9211
+ const commitAll = (message) => `git add -A || exit 1\n${commitAllowEmpty(message)}`;
9042
9212
  /** No current caller — see `Git.ts`'s `GitWriterOperations.commitAsIs` for why it's kept anyway. */
9043
9213
  const commitAsIs = (message) => commitAllowEmpty(message);
9044
9214
  /** No current caller — see `Git.ts`'s `GitWriterOperations.softResetTo` for why it's kept anyway. */
@@ -9056,6 +9226,17 @@ const discardPending = () => `git add -A && git reset --hard HEAD`;
9056
9226
  const updateRef = (ref, hash) => `git update-ref ${shellQuote(ref)} ${shellQuote(hash)}`;
9057
9227
  /** `git update-ref -d <ref>` — idempotent: deleting a missing ref is already a no-op in real git. */
9058
9228
  const deleteRef = (ref) => `git update-ref -d ${shellQuote(ref)}`;
9229
+ /**
9230
+ * No `+` on the refspec: a local ref the remote's tip does not descend from
9231
+ * fails the fetch instead of being overwritten (a fast-forward still applies).
9232
+ */
9233
+ const fetchHistoryRefs = (remote) => [
9234
+ `set -eu`,
9235
+ `before=$(git for-each-ref --format='x' 'refs/gtd/history/' | wc -l)`,
9236
+ `git fetch ${shellQuote(remote)} 'refs/gtd/history/*:refs/gtd/history/*'`,
9237
+ `after=$(git for-each-ref --format='x' 'refs/gtd/history/' | wc -l)`,
9238
+ `printf '%s history refs arrived\\n' "$((after - before))"`
9239
+ ].join("\n");
9059
9240
  /** 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
9241
  const OUTCOME_MARKER$1 = "# gtd: outcome (print-only)";
9061
9242
  const printfLine$1 = (fmt, args) => `${OUTCOME_MARKER$1}\nprintf ${shellQuote(fmt.replace(/\n/g, "\\n"))} ${args.join(" ")}`;
@@ -9073,6 +9254,7 @@ const renderOutcome = (outcome) => {
9073
9254
  const renderGitWrite = (write) => {
9074
9255
  switch (write.kind) {
9075
9256
  case "commitAll": return commitAll(write.message);
9257
+ case "updateRef": return updateRef(write.ref, write.target);
9076
9258
  }
9077
9259
  };
9078
9260
  /**
@@ -9440,7 +9622,7 @@ const validateScriptFor = (resolved, file) => gen(function* () {
9440
9622
  */
9441
9623
  var ToolCommands = class extends Tag("ToolCommands")() {};
9442
9624
  //#endregion
9443
- //#region src/cli/Program.ts
9625
+ //#region src/cli/Output.ts
9444
9626
  /**
9445
9627
  * A command-level failure that should exit like a CLI usage error (2), not a
9446
9628
  * generic runtime failure (1): an unknown `--json=<path>` selector, or a
@@ -9450,12 +9632,92 @@ var ToolCommands = class extends Tag("ToolCommands")() {};
9450
9632
  * about the resolved rest's own state.
9451
9633
  */
9452
9634
  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. */
9635
+ /** 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
9636
  const writeSelection = (out, fields, path) => gen(function* () {
9455
9637
  const selection = selectPath(fields, path);
9456
9638
  if (selection.kind === "value") out.write(`${selection.text}\n`);
9457
9639
  else if (selection.kind === "unknown") return yield* fail$5(new SelectorUsageError(`gtd: unknown --json selector "${selection.path}" — see \`gtd --help\``));
9458
9640
  });
9641
+ //#endregion
9642
+ //#region src/cli/History.ts
9643
+ /** Newest tip first. */
9644
+ const historyEntries = gen(function* () {
9645
+ const git = yield* GitService;
9646
+ return yield* forEach$2((yield* git.listRefs(PROCESS_HISTORY_PREFIX)).filter(({ ref }) => ENTRY_HASH.test(ref.slice(17))), ({ ref, hash, date }) => gen(function* () {
9647
+ const entry = ref.slice(17);
9648
+ return {
9649
+ ref,
9650
+ entry,
9651
+ branch: parseCommitMessage(yield* git.lastCommitMessage(hash)).branch ?? null,
9652
+ tip: hash,
9653
+ date,
9654
+ subject: yield* git.lastCommitSubject(entry)
9655
+ };
9656
+ }));
9657
+ });
9658
+ const ENTRY_HASH = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
9659
+ const HEX = /^[0-9a-f]+$/i;
9660
+ /**
9661
+ * Lookup order: full ref; then entry hash or hex prefix (unique → it, several
9662
+ * → fail listing them); then branch name (newest tip first, as `entries` is
9663
+ * ordered). A hex-looking name that matches no entry still resolves as a
9664
+ * branch. No match → fail.
9665
+ */
9666
+ const resolveHistoryRef = (arg, entries) => {
9667
+ const byRef = entries.find((e) => e.ref === arg);
9668
+ if (byRef !== void 0) return succeed$6(byRef);
9669
+ if (HEX.test(arg)) {
9670
+ const lower = arg.toLowerCase();
9671
+ const matches = entries.filter((e) => e.entry.toLowerCase().startsWith(lower));
9672
+ if (matches.length === 1) return succeed$6(matches[0]);
9673
+ 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")}`));
9674
+ }
9675
+ const byBranch = entries.find((e) => e.branch === arg);
9676
+ if (byBranch !== void 0) return succeed$6(byBranch);
9677
+ return fail$5(/* @__PURE__ */ new Error(`gtd history: no history ref matches "${arg}"`));
9678
+ };
9679
+ const seven = (hash) => hash.slice(0, 7);
9680
+ /** Writes `document` per `json` (bare → whole, `=path` → one value); returns false for `off`. */
9681
+ const writeJson = (json, out, document) => gen(function* () {
9682
+ if (json.kind === "off") return false;
9683
+ if (json.kind === "document") out.write(`${JSON.stringify(document)}\n`);
9684
+ else yield* writeSelection(out, document, json.path);
9685
+ return true;
9686
+ });
9687
+ const runHistoryListCommand = (json, out) => gen(function* () {
9688
+ const entries = yield* historyEntries;
9689
+ if (yield* writeJson(json, out, entries)) return;
9690
+ if (entries.length === 0) {
9691
+ out.write("no history refs; run gtd history fetch | sh to pull them\n");
9692
+ return;
9693
+ }
9694
+ out.write(entries.map((e) => `${seven(e.entry)} ${e.branch ?? "-"} ${seven(e.tip)} ${e.date} ${e.subject}\n`).join(""));
9695
+ });
9696
+ const runHistoryShowCommand = (ref, json, out) => gen(function* () {
9697
+ const entry = yield* resolveHistoryRef(ref, yield* historyEntries);
9698
+ const trail = yield* historyTrail(entry.tip);
9699
+ const document = {
9700
+ ref: entry.ref,
9701
+ entry: entry.entry,
9702
+ branch: entry.branch,
9703
+ ...trail
9704
+ };
9705
+ if (yield* writeJson(json, out, document)) return;
9706
+ if (trail.versionWarning !== void 0) yield* (yield* Narrator).warn(trail.versionWarning);
9707
+ const lines = [`${entry.ref} (branch ${entry.branch ?? "-"}, tip ${seven(entry.tip)})`];
9708
+ for (const step of trail.steps) {
9709
+ lines.push(`${seven(step.hash)} ${step.subject}`);
9710
+ for (const v of step.verdicts) lines.push(` judge ${v.id} = ${v.answer} (p=${v.p})`);
9711
+ }
9712
+ lines.push(`ended (${trail.ended})`);
9713
+ out.write(`${lines.join("\n")}\n`);
9714
+ });
9715
+ /** Prints the script and never runs git: gtd's promise that it only decides and prints. */
9716
+ const runHistoryFetchCommand = (remote, out) => sync$2(() => {
9717
+ out.write(`${combinedScript(fetchHistoryRefs(remote), "")}\n`);
9718
+ });
9719
+ //#endregion
9720
+ //#region src/cli/Program.ts
9459
9721
  const selfGtd = () => {
9460
9722
  const script = process.argv[1];
9461
9723
  return script === void 0 ? ["gtd"] : [process.execPath, script];
@@ -9865,12 +10127,6 @@ const runValidateCommand = (out) => gen(function* () {
9865
10127
  const resolved = yield* resolveValidateScript(rest);
9866
10128
  out.write(resolved !== void 0 && resolved.script.length > 0 ? `${resolved.script}\n` : `nothing to validate at "${rest.state}"\n`);
9867
10129
  });
9868
- /** `file:line:col: message` — the shape editors and grep already jump on. Stored 0-based, printed 1-based. A finding with a `line` but no `range`, or none at all, degrades to the shorter forms. */
9869
- const formatFinding = (file, finding) => {
9870
- if (finding.line === void 0) return finding.message;
9871
- const col = finding.range !== void 0 ? `:${finding.range.start.character + 1}` : "";
9872
- return `${file}:${finding.line + 1}${col}: ${finding.message}`;
9873
- };
9874
10130
  /**
9875
10131
  * Runs `<mode>`'s parser over `<file>`, exiting non-zero on any finding.
9876
10132
  * Resolves no workflow state and reads no config — this is what a seeded
@@ -10074,6 +10330,9 @@ const dispatchVoidCommand = (command, json, out) => {
10074
10330
  case "judge": return runJudgeCommand(json, out);
10075
10331
  case "judgeAnswer": return runJudgeAnswerCommand(json, out);
10076
10332
  case "judgeRun": return runJudgeRunCommand(command, out);
10333
+ case "historyList": return runHistoryListCommand(json, out);
10334
+ case "historyShow": return runHistoryShowCommand(command.ref, json, out);
10335
+ case "historyFetch": return runHistoryFetchCommand(command.remote, out);
10077
10336
  }
10078
10337
  };
10079
10338
  /**
@@ -10083,7 +10342,7 @@ const dispatchVoidCommand = (command, json, out) => {
10083
10342
  * (the `standaloneKinds` run bare). Both guard checks run before `dispatch`,
10084
10343
  * so a refusal writes nothing and emits no script by construction.
10085
10344
  */
10086
- const runCommand = (command, json, out) => {
10345
+ const runCommand$1 = (command, json, out) => {
10087
10346
  const dispatch = dispatchVoidCommand(command, json, out);
10088
10347
  if (needsOf(command.kind) !== "state") return dispatch;
10089
10348
  return gen(function* () {
@@ -10112,13 +10371,13 @@ const FLAGS = [
10112
10371
  name: "--json",
10113
10372
  arity: "optional",
10114
10373
  repeatable: false,
10115
- scope: (kind) => kind === "next" || kind === "land" || kind === "judge" || kind === "judgeAnswer" || kind === "doors",
10374
+ scope: (kind) => kind === "next" || kind === "land" || kind === "judge" || kind === "judgeAnswer" || kind === "doors" || kind === "historyList" || kind === "historyShow",
10116
10375
  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",
10376
+ 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
10377
  valueHint: "<path>",
10119
10378
  help: [
10120
- "(gtd next/gtd land/gtd judge/gtd judge answer/gtd doors only) output",
10121
- "structured JSON. Bare --json prints the whole document;",
10379
+ "(gtd next/gtd land/gtd judge/gtd judge answer/gtd doors/gtd history",
10380
+ "list/gtd history show only) output structured JSON. Bare --json prints the whole document;",
10122
10381
  "--json=<path> (a dotted key path into that document, e.g.",
10123
10382
  "kind, content, session.id) prints just that value: a",
10124
10383
  "scalar raw and unquoted, a boolean as true/false, a list",
@@ -10626,6 +10885,44 @@ const COMMAND_ROWS = [
10626
10885
  "repository. jev and llm exit 1 when they cannot answer",
10627
10886
  "every question, with nothing on stdout"
10628
10887
  ]
10888
+ },
10889
+ {
10890
+ token: "history list",
10891
+ kind: "historyList",
10892
+ arity: "none",
10893
+ details: [
10894
+ "List the finished processes kept behind refs/gtd/history/*,",
10895
+ "newest first: entry, branch, tip, date and subject per line.",
10896
+ "--json prints one array of { ref, entry, branch, tip, date,",
10897
+ "subject }. Writes nothing and never replays; with no refs it",
10898
+ "hints at `gtd history fetch | sh` and still exits 0"
10899
+ ]
10900
+ },
10901
+ {
10902
+ token: "history show",
10903
+ kind: "historyShow",
10904
+ arity: { name: "ref" },
10905
+ details: [
10906
+ "Replay one kept process under today's config and print its",
10907
+ "steps and judge verdicts. <ref> is a full ref, an entry hash",
10908
+ "(or unique prefix) or a branch name. Refuses (exit 1) when a",
10909
+ "config file differs from its content at the ref's tip. A",
10910
+ "version mismatch only warns on stderr. --json prints the",
10911
+ "trail as one document; writes nothing"
10912
+ ]
10913
+ },
10914
+ {
10915
+ token: "history fetch",
10916
+ kind: "historyFetch",
10917
+ arity: {
10918
+ name: "remote",
10919
+ optional: true
10920
+ },
10921
+ details: [
10922
+ "Print a script that fetches every refs/gtd/history/* from",
10923
+ "<remote> (default origin) and reports how many arrived — run",
10924
+ "it with `gtd history fetch | sh`. Never runs git itself"
10925
+ ]
10629
10926
  }
10630
10927
  ];
10631
10928
  const commandByToken = (token) => COMMAND_ROWS.find((r) => r.token === token);
@@ -10645,7 +10942,7 @@ const flagHeader = (row) => {
10645
10942
  };
10646
10943
  const renderHelp = () => {
10647
10944
  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}>`;
10945
+ 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
10946
  return renderBlock(header, row.details);
10650
10947
  });
10651
10948
  return `Usage: gtd [command] [options]\n\nCommands:\n${[
@@ -10756,6 +11053,7 @@ const conflictViolation = (present) => {
10756
11053
  const arityError = (cmd, rest, arity) => {
10757
11054
  if (arity === "none") return rest.length > 0 ? `gtd ${cmd}: too many arguments — expected none, got: ${rest.join(", ")}` : void 0;
10758
11055
  if ("names" in arity) return pairArityError(cmd, rest, arity.names);
11056
+ 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
11057
  if (rest.length === 0) return `gtd ${cmd}: missing ${arity.name} argument`;
10760
11058
  if (!("variadic" in arity) && rest.length > 1) return `gtd ${cmd}: too many arguments — expected one ${arity.name}, got: ${rest.join(", ")}`;
10761
11059
  };
@@ -10806,7 +11104,9 @@ const parseArgv = (argv) => {
10806
11104
  kind: "output",
10807
11105
  stdout: renderHelp()
10808
11106
  };
10809
- const row = first === void 0 ? void 0 : commandByToken(first);
11107
+ const isHistory = first === "history";
11108
+ const row = first === void 0 ? void 0 : commandByToken(isHistory ? `history ${positionals[1] ?? ""}` : first);
11109
+ if (isHistory && row === void 0) return usagePlan("gtd history: expected a subcommand — list, show <ref> or fetch [remote]", jsonSeen);
10810
11110
  const judgeAnswer = row?.kind === "judge" && positionals[1] === "answer";
10811
11111
  const selectsStart = workflowPresent && first === void 0;
10812
11112
  const judgeRun = row?.kind === "judge" && positionals[1] === "run";
@@ -10817,11 +11117,11 @@ const parseArgv = (argv) => {
10817
11117
  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
11118
  return usagePlan(`unknown command '${first}'`, jsonSeen);
10819
11119
  }
10820
- const restPositionals = first === void 0 ? positionals : positionals.slice(judgeAnswer || judgeRun ? 2 : 1);
11120
+ const restPositionals = first === void 0 ? positionals : positionals.slice(judgeAnswer || judgeRun || isHistory ? 2 : 1);
10821
11121
  if (kind === "start" && first === void 0) {
10822
11122
  if (restPositionals.length > 0) return usagePlan(`gtd: too many arguments — expected none, got: ${restPositionals.join(", ")}`, jsonSeen);
10823
11123
  } else {
10824
- const arityMsg = kind === "check" && restPositionals.length === 0 && present.has("--open-threads") ? void 0 : arityError(first, restPositionals, row.arity);
11124
+ const arityMsg = kind === "check" && restPositionals.length === 0 && present.has("--open-threads") ? void 0 : arityError(isHistory ? row.token : first, restPositionals, row.arity);
10825
11125
  if (arityMsg !== void 0) return usagePlan(arityMsg, jsonSeen);
10826
11126
  }
10827
11127
  const violation = scopeViolation(kind, present);
@@ -10910,6 +11210,24 @@ const parseArgv = (argv) => {
10910
11210
  verbose
10911
11211
  };
10912
11212
  }
11213
+ if (kind === "historyShow") return {
11214
+ kind: "command",
11215
+ command: {
11216
+ kind: "historyShow",
11217
+ ref: restPositionals[0]
11218
+ },
11219
+ json,
11220
+ verbose
11221
+ };
11222
+ if (kind === "historyFetch") return {
11223
+ kind: "command",
11224
+ command: {
11225
+ kind: "historyFetch",
11226
+ remote: restPositionals[0] ?? "origin"
11227
+ },
11228
+ json,
11229
+ verbose
11230
+ };
10913
11231
  if (kind === "uncheck") return {
10914
11232
  kind: "command",
10915
11233
  command: {
@@ -10985,7 +11303,7 @@ const runCli = (argv, io) => {
10985
11303
  io.exit(2);
10986
11304
  });
10987
11305
  const out = bufferedArtifactOut(io);
10988
- return runCommand(plan.command, plan.json, out).pipe(map$4(() => {
11306
+ return runCommand$1(plan.command, plan.json, out).pipe(map$4(() => {
10989
11307
  out.flush();
10990
11308
  io.exit(0);
10991
11309
  }), provide$2(io.layers(plan.verbose)), sandbox, catchAll$1((cause) => isInterruptedOnly(cause) ? failCause$4(cause) : report(io, plan.json.kind !== "off")(cause)));
@@ -11010,6 +11328,7 @@ var InMemRepo = class InMemRepo {
11010
11328
  currentBranch = "main";
11011
11329
  worktree = /* @__PURE__ */ new Map();
11012
11330
  index = /* @__PURE__ */ new Map();
11331
+ nextSeq = 1;
11013
11332
  getCommit(hash) {
11014
11333
  return this.commits.get(hash) ?? null;
11015
11334
  }
@@ -11063,10 +11382,10 @@ var InMemRepo = class InMemRepo {
11063
11382
  }
11064
11383
  resolveRef(ref) {
11065
11384
  if (/^[0-9a-f]{40}$/.test(ref)) return this.commits.has(ref) ? ref : null;
11066
- const tildeMatch = /^(HEAD|[0-9a-f]{40})(~(\d+))?$/.exec(ref);
11385
+ const tildeMatch = /^(HEAD|[0-9a-f]{40})(~(\d+)|\^)?$/.exec(ref);
11067
11386
  if (tildeMatch) {
11068
11387
  const base = tildeMatch[1];
11069
- const steps = tildeMatch[3] !== void 0 ? parseInt(tildeMatch[3], 10) : 0;
11388
+ const steps = tildeMatch[3] !== void 0 ? parseInt(tildeMatch[3], 10) : tildeMatch[2] === "^" ? 1 : 0;
11070
11389
  let cur = base === "HEAD" ? this.head : this.commits.has(base) ? base : null;
11071
11390
  for (let i = 0; i < steps; i++) {
11072
11391
  if (cur === null) return null;
@@ -11084,10 +11403,25 @@ var InMemRepo = class InMemRepo {
11084
11403
  if (!c) return null;
11085
11404
  return c.message.split("\n")[0] ?? null;
11086
11405
  }
11087
- lastCommitMessage() {
11088
- const c = this.headCommit();
11089
- if (!c) return null;
11090
- return c.message;
11406
+ lastCommitMessage(ref) {
11407
+ const hash = ref === void 0 ? this.head : this.resolveRef(ref);
11408
+ const c = hash === null ? null : this.getCommit(hash);
11409
+ return c === null ? null : c.message;
11410
+ }
11411
+ branchName() {
11412
+ return this.currentBranch;
11413
+ }
11414
+ /** Refs under `prefix`, newest tip first; the commit sequence number stands in for the committer date. */
11415
+ listRefs(prefix) {
11416
+ return [...this.refs.entries()].filter(([ref]) => ref.startsWith(prefix)).map(([ref, hash]) => ({
11417
+ ref,
11418
+ hash,
11419
+ seq: this.getCommit(hash)?.seq ?? 0
11420
+ })).sort((a, b) => b.seq - a.seq).map(({ ref, hash, seq }) => ({
11421
+ ref,
11422
+ hash,
11423
+ date: (/* @__PURE__ */ new Date(seq * 1e3)).toISOString().replace(/\.\d{3}Z$/, "Z")
11424
+ }));
11091
11425
  }
11092
11426
  /**
11093
11427
  * `head` (default `this.head`, mirroring production's `"HEAD"` default) is
@@ -11188,6 +11522,33 @@ var InMemRepo = class InMemRepo {
11188
11522
  const dirPrefix = prefix.endsWith("/") ? prefix : `${prefix}/`;
11189
11523
  return [...this.worktree.keys()].filter((key) => key === prefix || key.startsWith(dirPrefix)).sort();
11190
11524
  }
11525
+ /** Everything git would keep on disk, serialized — two snapshots compare equal exactly when nothing was written. */
11526
+ snapshot() {
11527
+ const sorted = (m) => [...m.entries()].sort(([a], [b]) => a.localeCompare(b));
11528
+ return JSON.stringify({
11529
+ head: this.head,
11530
+ branch: this.currentBranch,
11531
+ commits: [...this.commits.keys()].sort(),
11532
+ branches: sorted(this.branches),
11533
+ refs: sorted(this.refs),
11534
+ index: sorted(this.index),
11535
+ worktree: sorted(this.worktree)
11536
+ });
11537
+ }
11538
+ /** Every ref name under `prefix` (`refs/…`), sorted — `git for-each-ref <prefix>`. */
11539
+ refNamesUnder(prefix) {
11540
+ return [...this.refs.keys()].filter((ref) => ref.startsWith(prefix)).sort();
11541
+ }
11542
+ /** Whether `path` is in the index — what `git diff <commit> -- <path>` compares against the worktree. */
11543
+ isTracked(path) {
11544
+ return this.index.has(path);
11545
+ }
11546
+ /** `git add <path>` — the index takes `path` as the worktree has it, or drops it. */
11547
+ stage(path) {
11548
+ const content = this.worktree.get(path);
11549
+ if (content === void 0) this.index.delete(path);
11550
+ else this.index.set(path, content);
11551
+ }
11191
11552
  /** `git add -A` — the index becomes exactly the current worktree. */
11192
11553
  stageAll() {
11193
11554
  this.index = new Map(this.worktree);
@@ -11201,7 +11562,8 @@ var InMemRepo = class InMemRepo {
11201
11562
  hash,
11202
11563
  message,
11203
11564
  files: new Map(tree),
11204
- parent
11565
+ parent,
11566
+ seq: this.nextSeq++
11205
11567
  };
11206
11568
  this.commits.set(hash, commit);
11207
11569
  this.head = hash;
@@ -11222,7 +11584,8 @@ var InMemRepo = class InMemRepo {
11222
11584
  hash,
11223
11585
  message,
11224
11586
  files: new Map(tree),
11225
- parent
11587
+ parent,
11588
+ seq: this.nextSeq++
11226
11589
  };
11227
11590
  this.commits.set(hash, commit);
11228
11591
  this.head = hash;
@@ -11364,10 +11727,12 @@ const makeGitReaderOps = (repo, root) => ({
11364
11727
  const subject = repo.lastCommitSubject(ref);
11365
11728
  return subject !== null ? succeed$6(subject) : fail$5(/* @__PURE__ */ new Error("No commits"));
11366
11729
  },
11367
- lastCommitMessage: () => {
11368
- const message = repo.lastCommitMessage();
11730
+ lastCommitMessage: (ref) => {
11731
+ const message = repo.lastCommitMessage(ref);
11369
11732
  return message !== null ? succeed$6(message) : fail$5(/* @__PURE__ */ new Error("No commits"));
11370
11733
  },
11734
+ currentBranch: () => succeed$6(some(repo.branchName())),
11735
+ listRefs: (prefix) => succeed$6(repo.listRefs(prefix)),
11371
11736
  resolveRef: (ref) => {
11372
11737
  const hash = repo.resolveRef(ref);
11373
11738
  return hash !== null ? succeed$6(hash) : fail$5(/* @__PURE__ */ new Error(`Cannot resolve ref: ${ref}`));
@@ -11513,7 +11878,8 @@ const makeInMemoryConfigDiscovery = (repo, root) => {
11513
11878
  const source = workspace.atPath(filepath);
11514
11879
  if (source !== void 0) return {
11515
11880
  filepath,
11516
- source
11881
+ source,
11882
+ read: workspace.atPath
11517
11883
  };
11518
11884
  }
11519
11885
  },
@@ -11576,6 +11942,269 @@ const makeCapturingCliIo = (repo, env = {}, tools = {}) => {
11576
11942
  };
11577
11943
  };
11578
11944
  //#endregion
11945
+ //#region src/testkit/FlowScripts.ts
11946
+ const SHEBANG = "#!/usr/bin/env sh";
11947
+ const words$1 = (text) => [...text.matchAll(/'((?:[^']|'\\'')*)'/g)].map((m) => m[1].replaceAll("'\\''", "'"));
11948
+ const ok = (output = "") => ({
11949
+ ok: true,
11950
+ output
11951
+ });
11952
+ /** `rm -rf -- <paths>`. `InMemRepo.pathsUnder("")` is the whole tree, where a real `rm -- ''` removes nothing. */
11953
+ const removeAll = (repo, paths) => {
11954
+ if (paths.includes("")) throw new Error("testkit: rm: refusing an empty path");
11955
+ for (const path of paths) for (const file of repo.pathsUnder(path)) repo.deleteFile(file);
11956
+ };
11957
+ const deleteIfPresent = (repo, path) => {
11958
+ if (repo.readFile(path) !== void 0) repo.deleteFile(path);
11959
+ };
11960
+ const recognizeRemove = (repo, script) => {
11961
+ const line = script.split("\n").find((l) => l.startsWith("rm -rf -- ")) ?? "";
11962
+ const paths = words$1(line);
11963
+ if (removeScript(paths) !== script) return void 0;
11964
+ removeAll(repo, paths);
11965
+ return ok();
11966
+ };
11967
+ const recognizeMove = (repo, script) => {
11968
+ const line = script.split("\n").find((l) => l.startsWith("mv -- ")) ?? "";
11969
+ const [from, to] = words$1(line);
11970
+ if (from === void 0 || to === void 0 || moveScript(from, to) !== script) return void 0;
11971
+ const content = repo.readFile(from);
11972
+ if (content === void 0) return {
11973
+ ok: false,
11974
+ error: `mv: ${from}: No such file`,
11975
+ output: ""
11976
+ };
11977
+ repo.deleteFile(from);
11978
+ repo.writeFile(to, content);
11979
+ return ok();
11980
+ };
11981
+ const recognizeShow = (repo, script) => {
11982
+ const line = script.split("\n").find((l) => l.startsWith("git show ")) ?? "";
11983
+ const [source, to] = words$1(line);
11984
+ const at = source?.indexOf(":") ?? -1;
11985
+ if (source === void 0 || to === void 0 || at === -1) return void 0;
11986
+ const ref = source.slice(0, at);
11987
+ const path = source.slice(at + 1);
11988
+ if (showScript(ref, path, to) !== script) return void 0;
11989
+ const content = repo.fileAtRef(ref, path);
11990
+ if (content === null) return {
11991
+ ok: false,
11992
+ error: `git show: ${source} does not exist`,
11993
+ output: ""
11994
+ };
11995
+ repo.writeFile(to, content);
11996
+ return ok();
11997
+ };
11998
+ /** `git diff --quiet <commit> -- <path>`: the worktree copy of a tracked path, or nothing for an untracked one, against `commit`'s. */
11999
+ const unchangedSince = (repo, commit, path) => {
12000
+ return (repo.isTracked(path) ? repo.readFile(path) ?? null : null) === repo.fileAtRef(commit, path);
12001
+ };
12002
+ const RESTORE_LINE = / && git restore --source=/;
12003
+ const REMOVE_LINE = / && rm -f -- /;
12004
+ const parseRestoreLine = (line) => {
12005
+ const [commit, path, source] = words$1(line);
12006
+ if (commit === void 0 || path === void 0) return void 0;
12007
+ if (RESTORE_LINE.test(line) && source !== void 0) return {
12008
+ commit,
12009
+ path,
12010
+ source
12011
+ };
12012
+ return REMOVE_LINE.test(line) ? {
12013
+ commit,
12014
+ path
12015
+ } : void 0;
12016
+ };
12017
+ /** Applies one line, returning its exit status as `sh` would see it. */
12018
+ const applyRestoreLine = (repo, { commit, path, source }) => {
12019
+ if (!unchangedSince(repo, commit, path)) return false;
12020
+ if (source !== void 0 && repo.resolveRef(source) === null) return false;
12021
+ const content = source === void 0 ? null : repo.fileAtRef(source, path);
12022
+ if (content !== null) repo.writeFile(path, content);
12023
+ else if (source === void 0 || repo.isTracked(path)) deleteIfPresent(repo, path);
12024
+ return true;
12025
+ };
12026
+ const recognizeRestore = (repo, script) => {
12027
+ const parsed = script.split("\n").slice(2, -1).map(parseRestoreLine);
12028
+ if (parsed.includes(void 0)) return void 0;
12029
+ const lines = parsed;
12030
+ const restoring = lines.filter((l) => l.source !== void 0);
12031
+ const paths = {
12032
+ restore: restoring.map((l) => l.path),
12033
+ remove: lines.filter((l) => l.source === void 0).map((l) => l.path)
12034
+ };
12035
+ if (restoreScript(lines[0]?.commit ?? "", paths, restoring[0]?.source) !== script) return void 0;
12036
+ if (lines.map((line) => applyRestoreLine(repo, line)).at(-1) === false) return {
12037
+ ok: false,
12038
+ error: "the last path changed since the commit",
12039
+ output: ""
12040
+ };
12041
+ return ok();
12042
+ };
12043
+ const PATCH_FAILED = "error: patch does not apply";
12044
+ /**
12045
+ * `git diff C^ C | git apply -R`, which is all or nothing. A file still
12046
+ * exactly as `C` left it reverts; a file gone, or one `C` added and that
12047
+ * changed since, fails the whole patch. A file `C` modified and that changed
12048
+ * since may still apply through its context — that needs real git, so it
12049
+ * throws rather than guess.
12050
+ */
12051
+ const reverseApply = (repo, commit) => {
12052
+ const parent = `${commit}^`;
12053
+ const changed = [.../* @__PURE__ */ new Set([...repo.pathsAtRef(parent), ...repo.pathsAtRef(commit)])].filter((p) => repo.fileAtRef(parent, p) !== repo.fileAtRef(commit, p));
12054
+ for (const path of changed) {
12055
+ const after = repo.fileAtRef(commit, path);
12056
+ const current = repo.readFile(path) ?? null;
12057
+ if (current === after) continue;
12058
+ if (current === null || after === null || repo.fileAtRef(parent, path) === null) return `${PATCH_FAILED}: ${path}`;
12059
+ throw new Error(`testkit: cannot tell in memory whether reverting ${commit} applies to the hand-edited ${path} — run this @live`);
12060
+ }
12061
+ for (const path of changed) {
12062
+ const before = repo.fileAtRef(parent, path);
12063
+ if (before === null) repo.deleteFile(path);
12064
+ else repo.writeFile(path, before);
12065
+ }
12066
+ };
12067
+ const recognizeRevert = (repo, script) => {
12068
+ const lines = script.split("\n");
12069
+ const [, commit] = words$1(lines.find((l) => l.startsWith("if ! git diff --quiet ")) ?? "");
12070
+ const [, failure] = words$1(lines.find((l) => l.includes("printf '%s\\n\\n' ")) ?? "");
12071
+ const [report] = words$1(lines.find((l) => l.startsWith(" } > ")) ?? "");
12072
+ if (commit === void 0 || failure === void 0 || report === void 0) return void 0;
12073
+ if (revertScript(commit, report, failure) !== script) return void 0;
12074
+ const error = reverseApply(repo, commit);
12075
+ if (error !== void 0) repo.writeFile(report, `${failure}\n\n${error}\n`);
12076
+ return ok();
12077
+ };
12078
+ /** One preamble line — the closed vocabulary the bundled workflows write there. */
12079
+ const runPreambleLine = (repo, line) => {
12080
+ const quoted = words$1(line);
12081
+ if (line === "exit 0") return {
12082
+ exit: true,
12083
+ output: ""
12084
+ };
12085
+ if (line.startsWith("mkdir -p ")) return {
12086
+ exit: false,
12087
+ output: ""
12088
+ };
12089
+ if (line.startsWith("rm -f ")) {
12090
+ for (const path of quoted) deleteIfPresent(repo, path);
12091
+ return {
12092
+ exit: false,
12093
+ output: ""
12094
+ };
12095
+ }
12096
+ if (line.startsWith("rm -rf -- ")) {
12097
+ removeAll(repo, quoted);
12098
+ return {
12099
+ exit: false,
12100
+ output: ""
12101
+ };
12102
+ }
12103
+ const printf = /^printf '%s\\n' '(?:[^']|'\\'')*' (>&2|> '(?:[^']|'\\'')*')$/.exec(line);
12104
+ if (printf !== null) {
12105
+ const [, text, file] = quoted;
12106
+ if (printf[1] === ">&2") return {
12107
+ exit: false,
12108
+ output: `${text}\n`
12109
+ };
12110
+ repo.writeFile(file, `${text}\n`);
12111
+ return {
12112
+ exit: false,
12113
+ output: ""
12114
+ };
12115
+ }
12116
+ throw new Error(`testkit: cannot run the preamble line \`${line}\` in memory`);
12117
+ };
12118
+ /** Rejoins lines a single-quoted word spans, so a preamble entry carrying a newline stays one line. */
12119
+ const shellLines = (lines) => {
12120
+ const joined = [];
12121
+ let open = false;
12122
+ for (const line of lines) {
12123
+ if (open) joined[joined.length - 1] += `\n${line}`;
12124
+ else joined.push(line);
12125
+ for (let i = 0; i < line.length; i++) if (line[i] === "'" && (open || line[i - 1] !== "\\")) open = !open;
12126
+ }
12127
+ return joined;
12128
+ };
12129
+ const outsideCommand = (lines, mkdir) => {
12130
+ const before = shellLines(lines.slice(2, mkdir));
12131
+ const swept = before.at(-1)?.startsWith("rm -rf -- ") === true;
12132
+ const green = lines.find((l) => l.startsWith(" rm -rf -- "));
12133
+ return {
12134
+ sweep: swept ? words$1(before.at(-1)) : [],
12135
+ sweepOnGreen: green === void 0 ? [] : words$1(green),
12136
+ preamble: swept ? before.slice(0, -1) : before
12137
+ };
12138
+ };
12139
+ const parseCheck = (script) => {
12140
+ const lines = script.split("\n");
12141
+ const mkdir = lines.findIndex((l) => l.startsWith("mkdir -p \"$(dirname "));
12142
+ const close = lines.findIndex((l, i) => i > mkdir && /^\) > '.*' 2>&1$/.test(l));
12143
+ if (mkdir === -1 || lines[mkdir + 1] !== "(" || close === -1) return void 0;
12144
+ const [report] = words$1(lines[mkdir]);
12145
+ const [, stamp] = words$1(lines.find((l) => l.includes("<!-- gtd check %s -->")) ?? "");
12146
+ if (report === void 0 || stamp === void 0) return void 0;
12147
+ return {
12148
+ command: lines.slice(mkdir + 2, close).join("\n"),
12149
+ report,
12150
+ stamp,
12151
+ ...outsideCommand(lines, mkdir)
12152
+ };
12153
+ };
12154
+ const runCommand = (repo, commands, command) => {
12155
+ const scripted = commands.get(command);
12156
+ if (scripted === void 0) throw new Error(`testkit: cannot run \`${command}\` in memory — script it ("the shell command … exits …")`);
12157
+ if (scripted.kind === "exit") return scripted;
12158
+ if (scripted.kind === "missing") return {
12159
+ status: 127,
12160
+ output: `sh: ${command}: not found\n`
12161
+ };
12162
+ repo.writeFile(scripted.file, rewritten(repo, scripted));
12163
+ return {
12164
+ status: 0,
12165
+ output: ""
12166
+ };
12167
+ };
12168
+ /** What a scripted rewrite leaves in its file. */
12169
+ const rewritten = (repo, command) => command.append === true ? `${repo.readFile(command.file) ?? ""}${command.content}` : command.content;
12170
+ const recognizeCheck = (repo, commands, script) => {
12171
+ const parsed = parseCheck(script);
12172
+ if (parsed === void 0) return void 0;
12173
+ const { command, report, stamp, sweep, sweepOnGreen, preamble } = parsed;
12174
+ if (checkScript(command, {
12175
+ report,
12176
+ stamp,
12177
+ sweep,
12178
+ sweepOnGreen,
12179
+ preamble
12180
+ }) !== script) return;
12181
+ let printed = "";
12182
+ for (const line of preamble) {
12183
+ const outcome = runPreambleLine(repo, line);
12184
+ printed += outcome.output;
12185
+ if (outcome.exit) return ok(printed);
12186
+ }
12187
+ removeAll(repo, sweep);
12188
+ const { status, output } = runCommand(repo, commands, command);
12189
+ if (status === 0) {
12190
+ deleteIfPresent(repo, report);
12191
+ removeAll(repo, sweepOnGreen);
12192
+ return ok(printed);
12193
+ }
12194
+ const body = output.length > 0 ? output : `the test command failed with exit code ${status} and produced no output.`;
12195
+ repo.writeFile(report, `${body}\n<!-- gtd check ${stamp} -->\n`);
12196
+ return ok(printed);
12197
+ };
12198
+ /**
12199
+ * Applies a `src/flows/scripts.ts` script to `repo`, or `undefined` when
12200
+ * `script` is none of them. `commands` stands in for the real programs a
12201
+ * check runs, keyed by the exact command string.
12202
+ */
12203
+ const applyFlowScript = (repo, commands, script) => {
12204
+ if (!script.startsWith(`${SHEBANG}\n`)) return void 0;
12205
+ return recognizeRemove(repo, script) ?? recognizeMove(repo, script) ?? recognizeShow(repo, script) ?? recognizeRevert(repo, script) ?? recognizeRestore(repo, script) ?? recognizeCheck(repo, commands, script);
12206
+ };
12207
+ //#endregion
11579
12208
  //#region src/testkit/EmittedScriptRecognizer.ts
11580
12209
  /**
11581
12210
  * Reverses `GitScript.ts`'s `shellQuote`: extracts every `'...'` token from
@@ -11612,6 +12241,8 @@ const extractQuotedTokens = (text) => {
11612
12241
  }
11613
12242
  return tokens;
11614
12243
  };
12244
+ /** What the in-memory tier prints for `$PATH` — there is none to name. */
12245
+ const IN_MEMORY_PATH = "(in-memory: no PATH)";
11615
12246
  const SET_FLAGS_RE = /^set\s+-\S+(\s+\S+)*$/;
11616
12247
  const recognizeSetFlags = (block) => SET_FLAGS_RE.test(block) ? { kind: "noop" } : void 0;
11617
12248
  const PRECONDITION_RE = /^\[ "\$\(git rev-parse HEAD\)" = '([0-9a-f]{40})' \] \|\| \{ echo "precondition failed: HEAD moved" >&2; exit 1; \}$/;
@@ -11619,11 +12250,9 @@ const recognizePrecondition = (repo, block) => {
11619
12250
  const match = PRECONDITION_RE.exec(block);
11620
12251
  if (!match) return void 0;
11621
12252
  const expected = match[1];
11622
- const actual = repo.resolveRef("HEAD");
11623
- if (actual === expected) return { kind: "noop" };
11624
- return {
12253
+ return repo.resolveRef("HEAD") === expected ? { kind: "noop" } : {
11625
12254
  kind: "failed",
11626
- error: `precondition failed: HEAD is ${actual ?? "(no commits)"}, expected ${expected}`
12255
+ error: "precondition failed: HEAD moved"
11627
12256
  };
11628
12257
  };
11629
12258
  /**
@@ -11647,7 +12276,7 @@ const recognizeGitBuilders = (repo, block) => {
11647
12276
  return { kind: "applied" };
11648
12277
  }
11649
12278
  const conditionLine = conditionStatement(block);
11650
- if (block.startsWith("git add -A &&\n")) {
12279
+ if (block.startsWith("git add -A || exit 1\n")) {
11651
12280
  const [message] = extractQuotedTokens(conditionLine);
11652
12281
  if (message !== void 0 && commitAll(message) === block) {
11653
12282
  repo.commitAllWithPrefix(message);
@@ -11710,9 +12339,9 @@ const recognizeFileExistsGuard = (repo, block) => {
11710
12339
  if (file === void 0 || fileExistsGuard(file) !== block) return void 0;
11711
12340
  return repo.readFile(file) !== void 0 ? { kind: "noop" } : { kind: "stopped" };
11712
12341
  };
11713
- /** `binaryGuard`. The in-memory tier has no real `$PATH` to probe — a scripted command IS the stand-in for "this binary exists" — so this always no-ops; the guard actually tripping is `@live`-only coverage. */
12342
+ /** `binaryGuard`. The in-memory tier has no `$PATH`: a binary is present unless declared `missing`. */
11714
12343
  const BINARY_GUARD_MESSAGE_RE = /^gtd: mode "([^"]+)": "(format|validate)" command not found: (.+)$/;
11715
- const recognizeBinaryGuard = (block) => {
12344
+ const recognizeBinaryGuard = (commands, block) => {
11716
12345
  const [binary, , message] = extractQuotedTokens(block);
11717
12346
  if (binary === void 0 || message === void 0) return void 0;
11718
12347
  const match = BINARY_GUARD_MESSAGE_RE.exec(message);
@@ -11720,10 +12349,29 @@ const recognizeBinaryGuard = (block) => {
11720
12349
  const [, mode, key, messageBinary] = match;
11721
12350
  if (messageBinary !== binary) return void 0;
11722
12351
  if (binaryGuard(binary, mode, key) !== block) return void 0;
11723
- return { kind: "noop" };
12352
+ if (commands.get(binary)?.kind !== "missing") return { kind: "noop" };
12353
+ return {
12354
+ kind: "failed",
12355
+ error: `${message}\n$PATH: ${IN_MEMORY_PATH}`,
12356
+ status: 127
12357
+ };
11724
12358
  };
11725
12359
  /** Recognized by `OUTCOME_MARKER` rather than re-derived and string-compared: an outcome only prints, so no git effect a loose match could miss — unlike `recognizeGitBuilders`, where a near-miss would silently skip a real mutation. */
11726
- const recognizeOutcome = (block) => block.startsWith("# gtd: outcome (print-only)") ? { kind: "noop" } : void 0;
12360
+ const recognizeOutcome = (repo, block) => {
12361
+ if (!block.startsWith(`# gtd: outcome (print-only)\nprintf `)) return void 0;
12362
+ const line = block.slice(28);
12363
+ const [format, ...args] = shellWords(repo, line.slice(7));
12364
+ return {
12365
+ kind: "noop",
12366
+ output: renderFormat(format.replace(/\\n/g, "\n"), ...args)
12367
+ };
12368
+ };
12369
+ /** The word list of `printf`'s arguments: quoted literals, and the `"$(git …)"` lookups outcome lines carry. */
12370
+ const shellWords = (repo, text) => [...text.matchAll(/'((?:[^']|'\\'')*)'|"\$\(git (rev-parse --short|log -1 --format=%s) '([^']*)'\)"/g)].map((m) => {
12371
+ if (m[1] !== void 0) return m[1].replaceAll("'\\''", "'");
12372
+ const hash = repo.resolveRef(m[3]) ?? "";
12373
+ return m[2] === "rev-parse --short" ? hash.slice(0, 7) : repo.lastCommitSubject(hash) ?? "";
12374
+ });
11727
12375
  /**
11728
12376
  * `failurePromptWrapper`. The constants below bracket the template's one
11729
12377
  * variable part; the extracted pair is confirmed by RE-RUNNING the real
@@ -11740,8 +12388,27 @@ const expandFileVar = (text) => {
11740
12388
  if (file === void 0) return void 0;
11741
12389
  return text.slice(match[0].length).replace(/"\$GTD_FILE"|"\$\{GTD_FILE\}"/g, shellQuote(file)).replace(/\$\{GTD_FILE\}|\$GTD_FILE\b/g, file);
11742
12390
  };
11743
- /** A mode's `format:`/`validate:` command: `gtd check`, or a scripted command. */
11744
- const recognizeModeCommand = (repo, commands, command) => recognizeGtdCheck(repo, command) ?? recognizeScriptedCommand(repo, commands, command);
12391
+ /**
12392
+ * A mode's `format:`/`validate:` command, under `withFileVar` or not:
12393
+ * `gtd check`, or a scripted command — keyed by the command as expanded, or
12394
+ * as written in the config (`fmt $GTD_FILE`), which is the only key a command
12395
+ * run on a scratch path can have.
12396
+ */
12397
+ const recognizeModeCommand = (repo, commands, block) => {
12398
+ const command = expandFileVar(block) ?? block;
12399
+ const exact = recognizeGtdCheck(repo, command) ?? recognizeScriptedCommand(repo, commands, command);
12400
+ if (exact !== void 0) return exact;
12401
+ const match = FILE_VAR_PREFIX_RE.exec(block);
12402
+ if (match === null) return void 0;
12403
+ const scripted = commands.get(block.slice(match[0].length));
12404
+ const [file] = extractQuotedTokens(match[1]);
12405
+ if (scripted === void 0 || file === void 0) return void 0;
12406
+ const bound = scripted.kind === "rewrite" && scripted.file === "$GTD_FILE" ? {
12407
+ ...scripted,
12408
+ file
12409
+ } : scripted;
12410
+ return recognizeScriptedCommand(repo, /* @__PURE__ */ new Map([[command, bound]]), command);
12411
+ };
11745
12412
  const FAILURE_PROMPT_HEADER = "gtd_validate_status=0\ngtd_validate_out=\"$( {\n";
11746
12413
  const FAILURE_PROMPT_MIDDLE = "\n} 2>&1 )\" || gtd_validate_status=$?\nif [ \"$gtd_validate_status\" -ne 0 ]; then\n printf '%s\\n\\n%s\\n' ";
11747
12414
  const FAILURE_PROMPT_FOOTER = " \"$gtd_validate_out\"\n exit \"$gtd_validate_status\"\nfi";
@@ -11753,11 +12420,12 @@ const recognizeFailurePromptWrapper = (repo, commands, block) => {
11753
12420
  const promptQuoted = block.slice(middleIndex + 101, block.length - 53);
11754
12421
  const [prompt] = extractQuotedTokens(promptQuoted);
11755
12422
  if (prompt === void 0 || failurePromptWrapper(inner, prompt) !== block) return void 0;
11756
- const innerOutcome = recognizeModeCommand(repo, commands, expandFileVar(inner) ?? inner);
12423
+ const innerOutcome = recognizeModeCommand(repo, commands, inner);
11757
12424
  if (innerOutcome === void 0) return void 0;
11758
12425
  if (innerOutcome.kind === "failed") return {
11759
12426
  kind: "failed",
11760
- error: `${prompt}\n\n${innerOutcome.error}`
12427
+ error: `${prompt}\n\n${innerOutcome.error.replace(/\n+$/, "")}`,
12428
+ status: innerOutcome.status ?? 1
11761
12429
  };
11762
12430
  return innerOutcome;
11763
12431
  };
@@ -11770,7 +12438,10 @@ const recognizeModeContradictionSkipNotice = (block) => {
11770
12438
  if (!match) return void 0;
11771
12439
  const mode = match[1];
11772
12440
  if (modeContradictionSkipNotice(mode) !== block) return void 0;
11773
- return { kind: "noop" };
12441
+ return {
12442
+ kind: "noop",
12443
+ output: `${message}\n`
12444
+ };
11774
12445
  };
11775
12446
  /** The printf line's own pieces — the sample bytes, the scratch path, and the literal PREFIX text (up to and including that path) — or `undefined` when `block` doesn't open with `printf '%s' ...`. */
11776
12447
  const parsePrintfPrefix = (block) => {
@@ -11833,7 +12504,7 @@ const simulateModeContradictionCheck = (repo, commands, parsed) => {
11833
12504
  const { mode, samplePath, sample, formatCommand, format } = parsed;
11834
12505
  repo.writeFile(samplePath, sample);
11835
12506
  const command = expandFileVar(formatCommand) ?? formatCommand;
11836
- const formatOutcome = recognizeScriptedCommand(repo, commands, command);
12507
+ const formatOutcome = recognizeModeCommand(repo, commands, formatCommand);
11837
12508
  if (formatOutcome === void 0) {
11838
12509
  repo.deleteFile(samplePath);
11839
12510
  return {
@@ -11875,16 +12546,16 @@ const recognizeGtdCheck = (repo, block) => {
11875
12546
  const format = steeringFormatFor(mode);
11876
12547
  if (format === void 0 || file === void 0) return {
11877
12548
  kind: "failed",
11878
- error: `gtd check: no built-in steering format named "${mode}"`
12549
+ error: `gtd check: unknown mode "${mode}" — known modes: ${BUILT_IN_MODE_NAMES.join(", ")}`
11879
12550
  };
11880
12551
  const content = repo.readFile(file);
11881
12552
  if (content === void 0) return { kind: "noop" };
11882
- const findings = format.validate(content);
11883
- if (findings.length > 0) return {
12553
+ const findings = format.validate(content).map((finding) => formatFinding(file, finding));
12554
+ if (findings.length === 0) return { kind: "noop" };
12555
+ return {
11884
12556
  kind: "failed",
11885
- error: `gtd check ${mode} ${file}: ${findings.map((f) => f.line !== void 0 ? `${file}:${f.line + 1}: ${f.message}` : f.message).join("; ")}`
12557
+ error: `gtd check: ${file} is not valid under mode "${mode}" (${findings.length} finding(s)):\n` + findings.join("\n")
11886
12558
  };
11887
- return { kind: "noop" };
11888
12559
  };
11889
12560
  const GTD_UNCHECK_RE = /^gtd uncheck (.+)$/;
11890
12561
  /** `gtd uncheck <file>` — the review-gate reset prepended ahead of the human's commit, so no tick ever reaches it. Re-runs the REAL `clearTicks`, writing back only on an actual change. */
@@ -11908,27 +12579,44 @@ const recognizeGtdUncheck = (repo, block) => {
11908
12579
  */
11909
12580
  const recognizeDidNotRunComment = (block) => block === "# gtd emitted this and did NOT run it — pipe it into `sh` to land the turn" ? { kind: "noop" } : void 0;
11910
12581
  /** `combinedScript`'s optional half. ALWAYS reports `noop` whatever the inner result, mirroring bash's `( … ) || <warning>`, where the subshell's exit status is swallowed and must never fail the outer script. */
12582
+ const PRESENTATION_FAILURE_TEXT = `${extractQuotedTokens(PRESENTATION_FAILURE_WARNING)[0].replace(/\\n$/, "")}\n`;
11911
12583
  const PRESENTATION_SUBSHELL_PREFIX = `${PRESENTATION_ONLY_COMMENT}\n(\n`;
11912
12584
  const PRESENTATION_SUBSHELL_SUFFIX = `\n) || ${PRESENTATION_FAILURE_WARNING}`;
11913
12585
  const recognizePresentationSubshell = (repo, commands, block) => {
11914
12586
  if (!block.startsWith(PRESENTATION_SUBSHELL_PREFIX) || !block.endsWith(PRESENTATION_SUBSHELL_SUFFIX)) return;
11915
12587
  const inner = block.slice(PRESENTATION_SUBSHELL_PREFIX.length, block.length - PRESENTATION_SUBSHELL_SUFFIX.length);
11916
- applyEmittedScript(repo, commands, inner);
11917
- return { kind: "noop" };
12588
+ const result = applyEmittedScript(repo, commands, inner);
12589
+ if (result.ok) return {
12590
+ kind: "noop",
12591
+ output: result.output
12592
+ };
12593
+ return {
12594
+ kind: "noop",
12595
+ output: `${result.output}${PRESENTATION_FAILURE_TEXT}`
12596
+ };
11918
12597
  };
11919
12598
  /** Anything left over must be an EXACT hit in the scripted-command table — mirrors `ScriptedCommand`'s two `kind`s (`Layers.ts`). */
11920
12599
  const recognizeScriptedCommand = (repo, commands, block) => {
11921
12600
  const scripted = commands.get(block);
11922
12601
  if (scripted === void 0) return void 0;
11923
12602
  if (scripted.kind === "rewrite") {
11924
- repo.writeFile(scripted.file, scripted.content);
12603
+ repo.writeFile(scripted.file, rewritten(repo, scripted));
11925
12604
  return { kind: "applied" };
11926
12605
  }
12606
+ if (scripted.kind === "missing") return {
12607
+ kind: "failed",
12608
+ error: `sh: ${block.split(" ")[0]}: not found`,
12609
+ status: 127
12610
+ };
11927
12611
  if (scripted.status !== 0) return {
11928
12612
  kind: "failed",
11929
- error: `scripted command "${block}" exited ${scripted.status}: ${scripted.output}`
12613
+ error: scripted.output,
12614
+ status: scripted.status
12615
+ };
12616
+ return {
12617
+ kind: "noop",
12618
+ output: scripted.output
11930
12619
  };
11931
- return { kind: "noop" };
11932
12620
  };
11933
12621
  /**
11934
12622
  * Splits on blank lines, IGNORING those inside a single-quoted string: every
@@ -11984,14 +12672,27 @@ const splitBlocks = (script) => {
11984
12672
  if (current.trim().length > 0) blocks.push(current.trim());
11985
12673
  return blocks;
11986
12674
  };
12675
+ /** `deferNotesScript`: append its one section to `.gtd/TODO.md`, reading the section's lines off the final `printf`. */
12676
+ const DEFER_HEAD = "mkdir -p .gtd\nif [ -s .gtd/TODO.md ]; then";
12677
+ const recognizeDeferNotes = (repo, block) => {
12678
+ if (!block.startsWith(DEFER_HEAD)) return void 0;
12679
+ const at = block.lastIndexOf("\nprintf '%s\\n' ");
12680
+ if (at === -1) return void 0;
12681
+ const lines = extractQuotedTokens(block.slice(at + 15));
12682
+ const old = repo.readFile(".gtd/TODO.md") ?? "";
12683
+ const sep = old === "" ? "" : `${old.endsWith("\n") ? "" : "\n"}\n`;
12684
+ repo.writeFile(".gtd/TODO.md", `${old}${sep}${lines.join("\n")}\n`);
12685
+ return { kind: "applied" };
12686
+ };
11987
12687
  /** Every recognizer `applyEmittedScript` tries, first match wins — `recognizeScriptedCommand` stays last since it's the exact-hit fallback for anything the rest don't claim. */
11988
12688
  const recognizersFor = (repo, commands) => [
11989
12689
  (block) => recognizeSetFlags(block),
11990
12690
  (block) => recognizeDidNotRunComment(block),
11991
12691
  (block) => recognizePrecondition(repo, block),
11992
12692
  (block) => recognizeFileExistsGuard(repo, block),
11993
- (block) => recognizeBinaryGuard(block),
12693
+ (block) => recognizeBinaryGuard(commands, block),
11994
12694
  (block) => recognizeGitBuilders(repo, block),
12695
+ (block) => recognizeDeferNotes(repo, block),
11995
12696
  (block) => recognizeFailurePromptWrapper(repo, commands, block),
11996
12697
  (block) => recognizePresentationSubshell(repo, commands, block),
11997
12698
  (block) => recognizeModeContradictionSkipNotice(block),
@@ -11999,10 +12700,9 @@ const recognizersFor = (repo, commands) => [
11999
12700
  (block) => recognizeGtdCheck(repo, block),
12000
12701
  (block) => recognizeGtdUncheck(repo, block),
12001
12702
  (block) => {
12002
- const command = expandFileVar(block);
12003
- return command === void 0 ? void 0 : recognizeModeCommand(repo, commands, command);
12703
+ return FILE_VAR_PREFIX_RE.test(block) ? recognizeModeCommand(repo, commands, block) : void 0;
12004
12704
  },
12005
- (block) => recognizeOutcome(block),
12705
+ (block) => recognizeOutcome(repo, block),
12006
12706
  (block) => recognizeScriptedCommand(repo, commands, block)
12007
12707
  ];
12008
12708
  const recognizeBlock = (recognizers, block) => {
@@ -12018,21 +12718,50 @@ const recognizeBlock = (recognizers, block) => {
12018
12718
  * real script. No block after the failing one is applied.
12019
12719
  */
12020
12720
  const applyEmittedScript = (repo, commands, script) => {
12721
+ const flow = applyFlowScript(repo, commands, script);
12722
+ if (flow !== void 0) return flow.ok ? {
12723
+ ok: true,
12724
+ status: 0,
12725
+ output: flow.output
12726
+ } : {
12727
+ ok: false,
12728
+ status: 1,
12729
+ output: flow.output,
12730
+ error: flow.error ?? ""
12731
+ };
12732
+ return script.startsWith("#!") ? unrecognized(script) : applyBlocks(repo, commands, script);
12733
+ };
12734
+ const applyBlocks = (repo, commands, script) => {
12021
12735
  const recognizers = recognizersFor(repo, commands);
12736
+ let output = "";
12022
12737
  for (const block of splitBlocks(script)) {
12023
12738
  const outcome = recognizeBlock(recognizers, block);
12024
- if (outcome === void 0) return {
12025
- ok: false,
12026
- error: `unrecognized script block: ${block.split("\n")[0]}`
12027
- };
12028
- if (outcome.kind === "stopped") return { ok: true };
12029
- if (outcome.kind === "failed") return {
12030
- ok: false,
12031
- error: outcome.error
12032
- };
12739
+ if (outcome === void 0) return unrecognized(block, output);
12740
+ if (outcome.kind === "noop") output += outcome.output ?? "";
12741
+ if (outcome.kind === "stopped") break;
12742
+ if (outcome.kind === "failed") return failedAt(outcome, output);
12033
12743
  }
12034
- return { ok: true };
12744
+ return {
12745
+ ok: true,
12746
+ status: 0,
12747
+ output
12748
+ };
12035
12749
  };
12750
+ const failedAt = (outcome, output) => {
12751
+ const printed = outcome.error.length === 0 || outcome.error.endsWith("\n") ? "" : "\n";
12752
+ return {
12753
+ ok: false,
12754
+ status: outcome.status ?? 1,
12755
+ output: `${output}${outcome.error}${printed}`,
12756
+ error: outcome.error
12757
+ };
12758
+ };
12759
+ const unrecognized = (block, output = "") => ({
12760
+ ok: false,
12761
+ status: 1,
12762
+ output,
12763
+ error: `unrecognized script block: ${block.split("\n")[0]}`
12764
+ });
12036
12765
  //#endregion
12037
12766
  //#region src/testkit/TestRepo.ts
12038
12767
  const REGISTRY = Symbol.for("@pmelab/gtd/testkit:modules");
@@ -12167,7 +12896,9 @@ const testRepo = (options) => {
12167
12896
  commit: (message) => repo.commitAllWithPrefix(message),
12168
12897
  show: (ref, path) => repo.fileAtRef(ref, path) ?? void 0,
12169
12898
  runScript: (script) => {
12170
- for (const line of script.split("\n")) runLine(repo, line.trim());
12899
+ const flow = applyFlowScript(repo, /* @__PURE__ */ new Map(), script);
12900
+ if (flow === void 0) for (const line of script.split("\n")) runLine(repo, line.trim());
12901
+ else if (!flow.ok) throw new Error(`testkit: ${flow.error ?? ""}`);
12171
12902
  },
12172
12903
  gtd,
12173
12904
  start: (workflow, ...args) => begin([
@@ -12212,6 +12943,21 @@ const gitExecIn = (dir, ...args) => execSync(`git ${args.join(" ")}`, {
12212
12943
  const makeLiveTier = (initialCommit = true) => {
12213
12944
  const root = realpathSync(mkdtempSync(join(tmpdir(), "gtd-git-tier-")));
12214
12945
  const gitExec = (...args) => gitExecIn(root, ...args);
12946
+ let tick = 0;
12947
+ const commitEnv = () => ({
12948
+ ...process.env,
12949
+ GIT_COMMITTER_DATE: new Date(Date.UTC(2024, 0, 1, 0, 0, ++tick)).toISOString()
12950
+ });
12951
+ const gitCommit = (message, allowEmpty = false) => execFileSync("git", [
12952
+ "commit",
12953
+ ...allowEmpty ? ["--allow-empty"] : [],
12954
+ "-m",
12955
+ message
12956
+ ], {
12957
+ cwd: root,
12958
+ env: commitEnv(),
12959
+ stdio: "pipe"
12960
+ });
12215
12961
  gitExec("init");
12216
12962
  gitExec(`config user.email "test@test.com"`);
12217
12963
  gitExec(`config user.name "Test"`);
@@ -12219,7 +12965,7 @@ const makeLiveTier = (initialCommit = true) => {
12219
12965
  if (initialCommit) {
12220
12966
  writeFileSync(join(root, "readme.txt"), "hello");
12221
12967
  gitExec("add", "-A");
12222
- gitExec(`commit -m "init: first commit"`);
12968
+ gitCommit("init: first commit");
12223
12969
  }
12224
12970
  const provide = (eff) => runPromise(eff.pipe(provide$2(GitService.Live), provide$2(Host.layer({
12225
12971
  root,
@@ -12238,7 +12984,8 @@ const makeLiveTier = (initialCommit = true) => {
12238
12984
  onDiskContent: true,
12239
12985
  commitHooks: true,
12240
12986
  linkedWorktrees: true,
12241
- directoryChainConfig: true
12987
+ directoryChainConfig: true,
12988
+ remotes: true
12242
12989
  },
12243
12990
  provide,
12244
12991
  provideExit,
@@ -12250,7 +12997,7 @@ const makeLiveTier = (initialCommit = true) => {
12250
12997
  writeFileSync(join(root, path), content);
12251
12998
  }
12252
12999
  gitExec("add", "-A");
12253
- gitExec(`commit --allow-empty -m "${message}"`);
13000
+ gitCommit(message, true);
12254
13001
  },
12255
13002
  writeFile: (path, content) => {
12256
13003
  mkdirSync(dirname(join(root, path)), { recursive: true });
@@ -12277,7 +13024,37 @@ const makeLiveTier = (initialCommit = true) => {
12277
13024
  }
12278
13025
  },
12279
13026
  readWorktreeFile: (path) => readFileSync(join(root, path), "utf8"),
12280
- existsPath: (path) => existsSync(join(root, path))
13027
+ existsPath: (path) => existsSync(join(root, path)),
13028
+ snapshot: async () => {
13029
+ gitExec("status", "--porcelain");
13030
+ await new Promise((r) => setTimeout(r, 1100));
13031
+ gitExec("status", "--porcelain");
13032
+ const files = readdirSync(join(root, ".git"), {
13033
+ recursive: true,
13034
+ withFileTypes: true
13035
+ }).filter((entry) => entry.isFile()).map((entry) => join(entry.parentPath, entry.name)).filter((path) => !path.includes(`${sep}gtd-cache${sep}`)).sort().map((path) => `${path}:${statSync(path).size}:${statSync(path).mtimeMs}`);
13036
+ return JSON.stringify({
13037
+ files,
13038
+ status: gitExec("status", "--porcelain", "-uall")
13039
+ });
13040
+ },
13041
+ headSubject: () => gitExec("log", "-1", "--pretty=%s"),
13042
+ headTouchedPaths: () => gitExec("show", "--name-only", "--pretty=format:", "HEAD").split("\n").filter((line) => line.length > 0)
13043
+ },
13044
+ runScript: (script) => {
13045
+ const result = spawnSync("sh", ["-c", `exec 2>&1\n${script}`], {
13046
+ cwd: root,
13047
+ encoding: "utf8",
13048
+ env: {
13049
+ ...process.env,
13050
+ PATH: `${gtdShimDir()}:${process.env["PATH"] ?? ""}`
13051
+ }
13052
+ });
13053
+ return {
13054
+ ok: result.status === 0,
13055
+ status: result.status ?? 1,
13056
+ output: result.stdout
13057
+ };
12281
13058
  },
12282
13059
  dispose: () => rmSync(root, {
12283
13060
  recursive: true,
@@ -12285,6 +13062,15 @@ const makeLiveTier = (initialCommit = true) => {
12285
13062
  })
12286
13063
  };
12287
13064
  };
13065
+ /** A dir whose `gtd` runs this build's bundle, so a script's bare `gtd` is the code under test. */
13066
+ let shimDir;
13067
+ const gtdShimDir = () => {
13068
+ if (shimDir !== void 0) return shimDir;
13069
+ shimDir = mkdtempSync(join(tmpdir(), "gtd-tier-shim-"));
13070
+ const bundle = join(import.meta.dirname, "../../dist/gtd.bundle.mjs");
13071
+ writeFileSync(join(shimDir, "gtd"), `#!/bin/sh\nexec node "${bundle}" "$@"\n`, { mode: 493 });
13072
+ return shimDir;
13073
+ };
12288
13074
  const IN_MEM_ROOT = "/repo";
12289
13075
  const makeInMemTier = (initialCommit = true) => {
12290
13076
  const repo = new InMemRepo();
@@ -12302,7 +13088,8 @@ const makeInMemTier = (initialCommit = true) => {
12302
13088
  onDiskContent: false,
12303
13089
  commitHooks: false,
12304
13090
  linkedWorktrees: false,
12305
- directoryChainConfig: true
13091
+ directoryChainConfig: true,
13092
+ remotes: false
12306
13093
  },
12307
13094
  provide,
12308
13095
  provideExit,
@@ -12335,7 +13122,21 @@ const makeInMemTier = (initialCommit = true) => {
12335
13122
  if (content === void 0) throw new Error(`ENOENT: no such file or directory, open '${path}'`);
12336
13123
  return content;
12337
13124
  },
12338
- existsPath: (path) => repo.hasPath(path)
13125
+ existsPath: (path) => repo.hasPath(path),
13126
+ snapshot: async () => repo.snapshot(),
13127
+ headSubject: () => repo.lastCommitSubject() ?? "",
13128
+ headTouchedPaths: () => {
13129
+ return [.../* @__PURE__ */ new Set([...repo.pathsAtRef("HEAD~1"), ...repo.pathsAtRef("HEAD")])].filter((p) => repo.fileAtRef("HEAD~1", p) !== repo.fileAtRef("HEAD", p)).sort();
13130
+ }
13131
+ },
13132
+ runScript: (script, commands = /* @__PURE__ */ new Map()) => {
13133
+ const { ok, status, output, error } = applyEmittedScript(repo, commands, script);
13134
+ if (error?.startsWith("unrecognized script block:") === true) throw new Error(error);
13135
+ return {
13136
+ ok,
13137
+ status,
13138
+ output
13139
+ };
12339
13140
  },
12340
13141
  dispose: () => {}
12341
13142
  };
@@ -12382,6 +13183,32 @@ const runGitServiceContract = (makeTier) => {
12382
13183
  afterEach(() => {
12383
13184
  t.dispose();
12384
13185
  });
13186
+ it("no reader writes to the repository", async () => {
13187
+ t.seed.commit("feat: second", { "second.txt": "two" });
13188
+ t.seed.writeFile("dirty.txt", "dirty");
13189
+ const before = await t.observe.snapshot();
13190
+ await runGit(t, (g) => all$1([
13191
+ g.lastCommitSubject(),
13192
+ g.lastCommitMessage(),
13193
+ g.hasCommits(),
13194
+ g.resolveRef("HEAD"),
13195
+ g.readRefOption("refs/gtd/none"),
13196
+ g.isAncestor("HEAD~1", "HEAD"),
13197
+ g.mergeBase("HEAD~1", "HEAD"),
13198
+ g.defaultBranch(),
13199
+ g.topLevel(),
13200
+ g.gitDir(),
13201
+ g.gitCommonDir(),
13202
+ g.commitHistory(),
13203
+ g.subjectHistory(10, 0),
13204
+ g.readFileAtRef("HEAD", "second.txt"),
13205
+ g.changedPaths(),
13206
+ g.changedPaths("HEAD~1")
13207
+ ]));
13208
+ expect(await t.observe.snapshot()).toBe(before);
13209
+ await runGit(t, (g) => g.updateRef("refs/gtd/marker", t.observe.resolveRef("HEAD")));
13210
+ expect(await t.observe.snapshot()).not.toBe(before);
13211
+ });
12385
13212
  describe("resolveRef", () => {
12386
13213
  it("resolves HEAD to a 40-char hash", async () => {
12387
13214
  expect(await runGit(t, (g) => g.resolveRef("HEAD"))).toMatch(/^[0-9a-f]{40}$/);
@@ -12414,6 +13241,54 @@ const runGitServiceContract = (makeTier) => {
12414
13241
  expect(await runGit(t, (g) => g.lastCommitMessage())).toBe("feat: subject only");
12415
13242
  });
12416
13243
  });
13244
+ describe("lastCommitMessage with a ref", () => {
13245
+ it("reads the given ref's full message, not HEAD's", async () => {
13246
+ t.seed.commit("feat: first\n\nGtd-Branch: one");
13247
+ t.seed.commit("feat: second", { "extra.txt": "extra" });
13248
+ expect(await runGit(t, (g) => g.lastCommitMessage("HEAD~1"))).toBe("feat: first\n\nGtd-Branch: one");
13249
+ });
13250
+ });
13251
+ describe("currentBranch", () => {
13252
+ it("is the checked-out branch's name", async () => {
13253
+ expect(isSome(await runGit(t, (g) => g.currentBranch()))).toBe(true);
13254
+ });
13255
+ it.skipIf(!capabilities.onDiskContent)("is none on a detached HEAD, never a failure", async () => {
13256
+ gitExecIn(t.root, "checkout", "--detach", "HEAD");
13257
+ expect(isNone(await runGit(t, (g) => g.currentBranch()))).toBe(true);
13258
+ });
13259
+ });
13260
+ describe("listRefs", () => {
13261
+ it("is [] when no ref lives under the prefix", async () => {
13262
+ expect(await runGit(t, (g) => g.listRefs("refs/gtd/history/"))).toEqual([]);
13263
+ });
13264
+ it("lists {ref, hash, date} for refs under the prefix only, newest tip first", async () => {
13265
+ const first = t.observe.resolveRef("HEAD");
13266
+ t.seed.commit("feat: second", { "second.txt": "2" });
13267
+ const second = t.observe.resolveRef("HEAD");
13268
+ t.seed.updateRef("refs/gtd/history/old", first);
13269
+ t.seed.updateRef("refs/gtd/history/new", second);
13270
+ t.seed.updateRef("refs/gtd/other/elsewhere", second);
13271
+ const refs = await runGit(t, (g) => g.listRefs("refs/gtd/history/"));
13272
+ expect(refs.map((r) => [r.ref, r.hash])).toEqual([["refs/gtd/history/new", second], ["refs/gtd/history/old", first]]);
13273
+ for (const r of refs) expect(r.date.length).toBeGreaterThan(0);
13274
+ });
13275
+ it("reports the date as ISO 8601 strict", async () => {
13276
+ t.seed.updateRef("refs/gtd/history/x", t.observe.resolveRef("HEAD"));
13277
+ const [entry] = await runGit(t, (g) => g.listRefs("refs/gtd/history/"));
13278
+ expect(entry?.date).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/);
13279
+ });
13280
+ });
13281
+ describe("history reachable only from a retained ref", () => {
13282
+ it("commitHistory and readFileAtRef read it after HEAD moved off those commits", async () => {
13283
+ t.seed.commit("feat: second", { "kept.txt": "kept" });
13284
+ const tip = t.observe.resolveRef("HEAD");
13285
+ t.seed.updateRef("refs/gtd/history/tip", tip);
13286
+ t.seed.mixedReset("HEAD~1");
13287
+ expect(t.observe.resolveRef("HEAD")).not.toBe(tip);
13288
+ expect((await runGit(t, (g) => g.commitHistory(void 0, "refs/gtd/history/tip"))).map((c) => c.message)).toEqual(["init: first commit", "feat: second"]);
13289
+ expect(await runGit(t, (g) => g.readFileAtRef("refs/gtd/history/tip", "kept.txt"))).toBe("kept");
13290
+ });
13291
+ });
12417
13292
  describe("hasCommits", () => {
12418
13293
  it("returns false in an empty repo", async () => {
12419
13294
  const empty = t.emptyRepo();
@@ -12869,31 +13744,18 @@ const runGitServiceContract = (makeTier) => {
12869
13744
  });
12870
13745
  };
12871
13746
  /**
12872
- * Runs a `GitScript.ts` builder's output through a REAL shell against `t.root`
12873
- * — the same execution model the eventual driver uses (`bash -c <script>`),
12874
- * so a builder that's syntactically fine but semantically wrong (a missing
12875
- * `&&`, a misquoted path) fails here exactly as it would in production.
13747
+ * The SAME writer postconditions `runGitServiceContract` asserts against
13748
+ * `GitOperations`, asserted here against `GitScript.ts`'s builders as a driver
13749
+ * runs them: real `sh` on Live, the in-memory twin on InMemory.
12876
13750
  */
12877
- const execScript = (t, script) => {
12878
- execFileSync("bash", ["-c", script], {
12879
- cwd: t.root,
12880
- stdio: "pipe"
12881
- });
13751
+ const run = (t, script) => {
13752
+ const result = t.runScript(script);
13753
+ if (!result.ok) throw new Error(`script failed on ${t.name}: ${result.output}`);
12882
13754
  };
12883
- /** Names touched by HEAD's own commit — `git show`'s diff against its parent, not the whole tree. */
12884
- const headTouchedPaths = (t) => gitExecIn(t.root, "show", "--name-only", "--pretty=format:", "HEAD").split("\n").filter((line) => line.length > 0);
12885
- const headSubject = (t) => gitExecIn(t.root, "log", "-1", "--pretty=%s");
12886
- /**
12887
- * The SAME 9 writer postconditions `runGitServiceContract` asserts against
12888
- * `GitOperations`, asserted here against `GitScript.ts`'s bash builders
12889
- * instead — driven through real `bash` against a real repo (`makeLiveTier`),
12890
- * never the in-memory fake, since a fake root (`/repo`) has no shell to run
12891
- * against.
12892
- */
12893
- const runGitScriptContract = () => {
13755
+ const runGitScriptContract = (makeTier) => {
12894
13756
  let t;
12895
13757
  beforeEach(() => {
12896
- t = makeLiveTier();
13758
+ t = makeTier();
12897
13759
  });
12898
13760
  afterEach(() => {
12899
13761
  t.dispose();
@@ -12902,17 +13764,17 @@ const runGitScriptContract = () => {
12902
13764
  it("stages (git add -A) before committing — a new untracked file lands in the commit", () => {
12903
13765
  t.seed.writeFile("new.ts", "export const x = 1");
12904
13766
  const headBefore = t.observe.resolveRef("HEAD");
12905
- execScript(t, commitAll("gtd: building"));
13767
+ run(t, commitAll("gtd: building"));
12906
13768
  expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore);
12907
- expect(headSubject(t)).toBe("gtd: building");
12908
- expect(headTouchedPaths(t)).toEqual(["new.ts"]);
13769
+ expect(t.observe.headSubject()).toBe("gtd: building");
13770
+ expect(t.observe.headTouchedPaths()).toEqual(["new.ts"]);
12909
13771
  expect(t.observe.statusPorcelain().trim()).toBe("");
12910
13772
  });
12911
13773
  it("commits even on a clean tree (--allow-empty), never 'nothing to commit'", () => {
12912
13774
  const headBefore = t.observe.resolveRef("HEAD");
12913
- execScript(t, commitAll("gtd: grilled"));
13775
+ run(t, commitAll("gtd: grilled"));
12914
13776
  expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore);
12915
- expect(headSubject(t)).toBe("gtd: grilled");
13777
+ expect(t.observe.headSubject()).toBe("gtd: grilled");
12916
13778
  });
12917
13779
  });
12918
13780
  describe("commitAsIs", () => {
@@ -12920,22 +13782,22 @@ const runGitScriptContract = () => {
12920
13782
  t.seed.writeFile("a.txt", "a");
12921
13783
  t.seed.stageAll();
12922
13784
  t.seed.writeFile("b.txt", "b");
12923
- execScript(t, commitAsIs("feat: squash"));
12924
- expect(headSubject(t)).toBe("feat: squash");
12925
- expect(headTouchedPaths(t)).toEqual(["a.txt"]);
13785
+ run(t, commitAsIs("feat: squash"));
13786
+ expect(t.observe.headSubject()).toBe("feat: squash");
13787
+ expect(t.observe.headTouchedPaths()).toEqual(["a.txt"]);
12926
13788
  });
12927
13789
  it("commits even on a clean index (--allow-empty), never 'nothing to commit'", () => {
12928
13790
  const headBefore = t.observe.resolveRef("HEAD");
12929
- execScript(t, commitAsIs("gtd: empty squash"));
13791
+ run(t, commitAsIs("gtd: empty squash"));
12930
13792
  expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore);
12931
- expect(headSubject(t)).toBe("gtd: empty squash");
13793
+ expect(t.observe.headSubject()).toBe("gtd: empty squash");
12932
13794
  });
12933
13795
  });
12934
13796
  describe("softResetTo", () => {
12935
13797
  it("moves HEAD back but keeps worktree changes from the second commit", () => {
12936
13798
  const firstHash = t.observe.resolveRef("HEAD");
12937
13799
  t.seed.commit("feat: second", { "second.txt": "second content" });
12938
- execScript(t, softResetTo(firstHash));
13800
+ run(t, softResetTo(firstHash));
12939
13801
  expect(t.observe.resolveRef("HEAD")).toBe(firstHash);
12940
13802
  expect(t.observe.statusPorcelain()).toContain("second.txt");
12941
13803
  });
@@ -12944,7 +13806,7 @@ const runGitScriptContract = () => {
12944
13806
  it("moves HEAD, index, and working tree content back to the target ref", () => {
12945
13807
  const firstHash = t.observe.resolveRef("HEAD");
12946
13808
  t.seed.commit("feat: second", { "readme.txt": "modified content" });
12947
- execScript(t, hardResetTo(firstHash));
13809
+ run(t, hardResetTo(firstHash));
12948
13810
  expect(t.observe.resolveRef("HEAD")).toBe(firstHash);
12949
13811
  expect(t.observe.statusPorcelain()).toBe("");
12950
13812
  expect(t.observe.readWorktreeFile("readme.txt")).toBe("hello");
@@ -12954,7 +13816,7 @@ const runGitScriptContract = () => {
12954
13816
  it("moves HEAD and index, leaving the working tree untouched", () => {
12955
13817
  const firstHash = t.observe.resolveRef("HEAD");
12956
13818
  t.seed.commit("feat: second", { "second.txt": "second content" });
12957
- execScript(t, mixedResetTo(firstHash));
13819
+ run(t, mixedResetTo(firstHash));
12958
13820
  expect(t.observe.resolveRef("HEAD")).toBe(firstHash);
12959
13821
  expect(t.observe.statusPorcelain()).toContain("second.txt");
12960
13822
  });
@@ -12963,29 +13825,84 @@ const runGitScriptContract = () => {
12963
13825
  it("drops both tracked modifications and untracked files", () => {
12964
13826
  t.seed.writeFile("readme.txt", "modified");
12965
13827
  t.seed.writeFile("untracked.txt", "new");
12966
- execScript(t, discardPending());
13828
+ run(t, discardPending());
12967
13829
  expect(t.observe.statusPorcelain()).toBe("");
12968
13830
  });
12969
13831
  });
12970
13832
  describe("updateRef / deleteRef", () => {
12971
13833
  it("updateRef points a ref at a commit", () => {
12972
13834
  const head = t.observe.resolveRef("HEAD");
12973
- execScript(t, updateRef("refs/gtd/marker", head));
13835
+ run(t, updateRef("refs/gtd/marker", head));
12974
13836
  expect(t.observe.refExists("refs/gtd/marker")).toBe(true);
12975
13837
  expect(t.observe.resolveRef("refs/gtd/marker")).toBe(head);
12976
13838
  });
13839
+ it("updateRef with HEAD points the ref at the commit just made", () => {
13840
+ run(t, commitAll("feat: just made"));
13841
+ run(t, updateRef("refs/gtd/history/x", "HEAD"));
13842
+ expect(t.observe.resolveRef("refs/gtd/history/x")).toBe(t.observe.resolveRef("HEAD"));
13843
+ });
12977
13844
  it("deleteRef removes an existing ref, and tolerates deleting it again", () => {
12978
13845
  const head = t.observe.resolveRef("HEAD");
12979
- execScript(t, updateRef("refs/gtd/marker", head));
12980
- execScript(t, deleteRef("refs/gtd/marker"));
12981
- execScript(t, deleteRef("refs/gtd/marker"));
13846
+ run(t, updateRef("refs/gtd/marker", head));
13847
+ run(t, deleteRef("refs/gtd/marker"));
13848
+ run(t, deleteRef("refs/gtd/marker"));
12982
13849
  expect(t.observe.refExists("refs/gtd/marker")).toBe(false);
12983
13850
  });
12984
13851
  it("deleteRef tolerates a ref that was never created", () => {
12985
- expect(() => execScript(t, deleteRef("refs/gtd/never-created"))).not.toThrow();
13852
+ expect(() => run(t, deleteRef("refs/gtd/never-created"))).not.toThrow();
12986
13853
  expect(t.observe.refExists("refs/gtd/never-created")).toBe(false);
12987
13854
  });
12988
13855
  });
13856
+ describe("fetchHistoryRefs", () => {
13857
+ it("pulls every refs/gtd/history/* from a bare remote and prints the count", (ctx) => {
13858
+ if (!t.capabilities.remotes) ctx.skip();
13859
+ const bare = realpathSync(mkdtempSync(join(tmpdir(), "gtd-git-tier-bare-")));
13860
+ try {
13861
+ gitExecIn(bare, "init", "-q", "--bare");
13862
+ gitExecIn(t.root, "remote", "add", "origin", bare);
13863
+ const head = t.observe.resolveRef("HEAD");
13864
+ gitExecIn(t.root, "push", "-q", "origin", `${head}:refs/gtd/history/one`);
13865
+ gitExecIn(t.root, "push", "-q", "origin", `${head}:refs/gtd/history/two`);
13866
+ gitExecIn(t.root, "push", "-q", "origin", `${head}:refs/gtd/other/skip`);
13867
+ const out = execFileSync("bash", ["-c", fetchHistoryRefs("origin")], {
13868
+ cwd: t.root,
13869
+ encoding: "utf8"
13870
+ });
13871
+ expect(t.observe.refExists("refs/gtd/history/one")).toBe(true);
13872
+ expect(t.observe.refExists("refs/gtd/history/two")).toBe(true);
13873
+ expect(t.observe.refExists("refs/gtd/other/skip")).toBe(false);
13874
+ expect(out.trim()).toBe("2 history refs arrived");
13875
+ } finally {
13876
+ rmSync(bare, {
13877
+ recursive: true,
13878
+ force: true
13879
+ });
13880
+ }
13881
+ });
13882
+ it("fails loudly when a local history ref conflicts with the remote's", (ctx) => {
13883
+ if (!t.capabilities.remotes) ctx.skip();
13884
+ const bare = realpathSync(mkdtempSync(join(tmpdir(), "gtd-git-tier-bare-")));
13885
+ try {
13886
+ gitExecIn(bare, "init", "-q", "--bare");
13887
+ gitExecIn(t.root, "remote", "add", "origin", bare);
13888
+ const first = t.observe.resolveRef("HEAD");
13889
+ t.seed.commit("feat: second", { "second.txt": "x" });
13890
+ const second = t.observe.resolveRef("HEAD");
13891
+ gitExecIn(t.root, "push", "-q", "origin", `${first}:refs/gtd/history/one`);
13892
+ t.seed.updateRef("refs/gtd/history/one", second);
13893
+ expect(() => execFileSync("bash", ["-c", fetchHistoryRefs("origin")], {
13894
+ cwd: t.root,
13895
+ stdio: "pipe"
13896
+ })).toThrow();
13897
+ expect(t.observe.resolveRef("refs/gtd/history/one")).toBe(second);
13898
+ } finally {
13899
+ rmSync(bare, {
13900
+ recursive: true,
13901
+ force: true
13902
+ });
13903
+ }
13904
+ });
13905
+ });
12989
13906
  };
12990
13907
  //#endregion
12991
13908
  //#region src/testkit/StrictGitOperations.ts