@pmelab/gtd 26.0.0 → 27.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +21 -3
  3. package/dist/chunks/{Schema-B5yK7QrT.mjs → Schema-CyImoHTj.mjs} +56 -3
  4. package/dist/chunks/{reserved-6pAgFGgB.mjs → reserved-BL9krPao.mjs} +1 -1
  5. package/dist/chunks/{wire-DA7q2ikx.mjs → wire-BY1S-obW.mjs} +5 -2
  6. package/dist/flows.mjs +15 -1
  7. package/dist/gtd.bundle.mjs +219 -34
  8. package/dist/testkit.mjs +447 -72
  9. package/dist/wire.mjs +2 -2
  10. package/dist/workflows/feature.mjs +1 -1
  11. package/dist/workflows/fix.mjs +1 -1
  12. package/dist/workflows/review.mjs +1 -1
  13. package/package.json +2 -2
  14. package/src/cli/Cli.test.ts +56 -4
  15. package/src/cli/Cli.ts +48 -6
  16. package/src/cli/History.test.ts +2 -2
  17. package/src/cli/Output.ts +3 -1
  18. package/src/cli/Program.test.ts +11 -12
  19. package/src/cli/Program.ts +28 -7
  20. package/src/cli/Strays.test.ts +88 -0
  21. package/src/cli/Strays.ts +56 -0
  22. package/src/edge/Edge.test.ts +113 -16
  23. package/src/edge/Edge.ts +22 -6
  24. package/src/emit/GitScript.test.ts +65 -2
  25. package/src/emit/GitScript.ts +34 -1
  26. package/src/emit/index.ts +1 -0
  27. package/src/flags/index.ts +0 -1
  28. package/src/flows/runtime.test.ts +33 -0
  29. package/src/flows/runtime.ts +31 -0
  30. package/src/platform/Git.test.ts +96 -1
  31. package/src/platform/Git.ts +24 -0
  32. package/src/platform/RetainedHistory.test.ts +9 -0
  33. package/src/platform/RetainedHistory.ts +3 -0
  34. package/src/platform/Workspace.test.ts +1 -1
  35. package/src/platform/index.ts +1 -0
  36. package/src/replay/RefName.test.ts +40 -0
  37. package/src/replay/RefName.ts +19 -0
  38. package/src/replay/Replay.test.ts +193 -2
  39. package/src/replay/Replay.ts +51 -6
  40. package/src/replay/Trailers.test.ts +26 -0
  41. package/src/replay/Trailers.ts +8 -0
  42. package/src/replay/index.ts +1 -0
  43. package/src/step/LandStep.ts +1 -0
  44. package/src/step/RepoSnapshot.ts +2 -0
  45. package/src/step/index.ts +1 -0
  46. package/src/step/planStart.test.ts +5 -5
  47. package/src/step/planStep.test.ts +53 -0
  48. package/src/step/planStep.ts +7 -1
  49. package/src/step/strayPaths.test.ts +17 -0
  50. package/src/step/strayPaths.ts +8 -0
  51. package/src/testkit/EmittedScriptRecognizer.test.ts +57 -29
  52. package/src/testkit/EmittedScriptRecognizer.ts +26 -4
  53. package/src/testkit/EmittedScripts.test.ts +182 -0
  54. package/src/testkit/FakeGitOperations.ts +10 -2
  55. package/src/testkit/GitTiers.ts +170 -25
  56. package/src/testkit/InMemRepo.ts +64 -12
  57. package/src/testkit/Layers.test.ts +34 -1
  58. package/src/testkit/Layers.ts +21 -1
  59. package/src/testkit/TestRepo.ts +3 -3
  60. package/src/wire/ExitCodes.test.ts +11 -3
  61. package/src/wire/ExitCodes.ts +3 -0
  62. package/src/wire/Glob.test.ts +35 -0
  63. package/src/wire/Glob.ts +1 -1
  64. package/src/workflow/Workflow.ts +1 -0
  65. package/src/workflow/discovery.test.ts +1 -1
  66. package/src/workflows/shared/text.fixture.ts +1 -0
package/dist/testkit.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { checkScript, configProblems, configure, installContext, isFactoryWorkflow, moveScript, protocolMismatch, removeScript, restoreScript, revertScript, showScript, t as flows_exports } from "./flows.mjs";
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";
2
+ import { E as ACCESS_REFUSAL, _ as demandOf, a as landFields, c as renderLandJson, d as renderBeatPlain, f as beatDocument, g as beatKindOf, i as judgeJson, k as renderFormat, l as renderLandPlain, m as statusOf, n as resolveSession, p as renderBeatJson, r as selectPath, s as noopText, t as globMatches } from "./chunks/wire-BY1S-obW.mjs";
3
3
  import { C as headingSections, S as headingSectionBodies, a as unansweredQuestions, b as parseThreadsWithFindings, n as openThreads, r as steeringFormatFor, s as parseCodeThreads, t as BUILT_IN_MODE_NAMES, 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";
4
+ import { $ as logWarning, $i as format, $n as map$5, $r as make$19, $t as provide$1, A as catchIf, Ai as get$5, An as scopeMake, Ar as flatMap$3, At as suspend$5, B as flatMap$4, Bi as isNone, Bn as get$3, Br as sync$1, Bt as unsafeMakeLatch, C as all$1, Ci as toArray, Cn as isInterrupted, Cr as exitFail, Ct as sandbox, D as async, Di as GenericTag, Dn as squash, Dr as exitZipRight, Dt as scopedWith$1, E as asVoid$1, Ei as unsafeHead, En as reduceWithContext, Er as exitVoid, Et as scoped$4, F as ensuring$2, Fi as splitAt, Fn as join$3, Fr as map$3, Ft as tryMap, G as forkIn, Gi as some, Gn as TypeId$10, Gr as whenEffect, Gt as zipLeft, H as fnUntraced, Hi as map$8, Hn as modify$1, Hr as uninterruptible, Ht as when, I as exit, Ii as filter$1, In as sequential, Ir as onInterrupt, It as tryPromise, J as ignore, Ji as match$2, Jn as all, Jr as DeferredTypeId$1, Jt as spaced$1, K as forkScoped, Ki as isLeft, Kn as Class, Kr as withFiberRuntime, Kt as zipRight$3, L as fail$5, Li as flatMap$5, Ln as bind, Lr as scopeClose, Lt as try_, M as die$2, Mi as omit, Mn as _await$2, Mr as interruptAsFiber, Mt as tap, N as dieMessage, Ni as empty$5, Nn as inheritAll$1, Nr as interruptFiber, Nt as tapErrorCause, O as catchAll$1, Oi as Tag, On as ensuring$3, Or as fiberId, Ot as serviceOption, P as either, Pi as fromIterable, Pn as interruptAllAs$1, Pr as interruptible$1, Pt as timeout, Q as isEffect, Qi as NodeInspectSymbol, Qn as isFailure, Qr as ids, Qt as mergeAll$2, R as failCause$4, Ri as fromNullable, Rn as bindTo, Rr as succeed$4, Rt as uninterruptible$1, S as addFinalizer$1, Si as takeRight, Sn as isDieType, Sr as exitAs, St as runtime, T as as, Ti as unsafeGet, Tn as map$6, Tr as exitSucceed, Tt as scopeWith, U as forEach$2, Ui as match$4, Un as set$1, Ur as uninterruptibleMask$1, Ut as withFiberRuntime$1, V as flatten$4, Vi as isSome, Vn as make$16, Vr as tap$1, Vt as unsafeMakeSemaphore$1, W as forkDaemon, Wi as none, Wn as update$1, Wr as void_$2, Wt as zip$1, X as interruptible, Xi as pipeArguments, Xn as fail$4, Xr as combine, Xt as tagged, Y as interrupt$4, Yi as right, Yn as die, Yr as empty$6, Yt as TaggedError$1, Z as intoDeferred, Zi as BaseProto, Zn as failCause$3, Zr as combineAll, Zt as fromEffect$5, _ as decodeSync, _i as of, _n as fail$3, _r as deferredIsDone, _t as raceWith, a as Literal, aa as constUndefined, ai as size$2, an as updateContext$1, ar as zipRight$2, at as matchCause, b as acquireRelease, bi as reverse, bn as interrupt$3, br as deferredUnsafeDone, bt as runPromise, c as String$, ca as identity, ci as append$1, cn as Scope, cr as asyncInterrupt, ct as negate, d as Union, di as empty$4, dn as close, dr as deferredAwait, dt as orElse, ea as toJSON, ei as none$1, en as provideMerge$1, er as mapBoth, et as makeSemaphore, f as between, fi as filter, fn as extend, fr as deferredComplete, ft as orElseSucceed, gi as map$7, gn as die$1, gr as deferredInterruptWith, gt as race, hi as isEmpty$2, hn as NoSuchElementException, hr as deferredInterrupt, ht as provideService, i as Defect, ia as constTrue, ii as has, in as unsafeRunPromiseExit, ir as zip$2, it as match$5, j as catchTag, ji as getOption, jn as scopeTag, jr as interrupt$2, jt as sync$2, k as catchAllCause, ki as add, kn as forEachConcurrentDiscard, kr as fiberRefLocally, kt as succeed$6, l as Struct, la as pipe, li as appendAll, ln as addFinalizer, lr as catchAll, lt as onError, m as optional$1, mi as isChunk, mn as make$18, mr as deferredFailCause, mt as provide$2, n as Boolean$, na as hasProperty, ni as make$17, nn as succeed$3, nr as succeed$5, nt as mapBoth$1, o as Never, oa as constVoid, oi as decode, on as forkIn$1, or as as$1, ot as matchCauseEffect, p as decodeUnknown, pi as fromIterable$1, pn as fork, pr as deferredFail, pt as promise, q as gen, qi as left, qn as spanTag, qr as zipRight$1, qt as zipWith, r as Cause, ra as constFalse, ri as set$2, rn as unsafeFork, rr as void_$3, rt as mapError, s as Number$, sa as dual, si as isFinite, sn as unsafeMakeSemaphore, sr as asVoid, st as matchEffect, t as Array$, ta as equals, ti as get$4, tn as scoped$3, tr as match$3, tt as map$4, u as TaggedError, ui as drop, un as addFinalizerExit, ur as currentContext$1, ut as orDie, v as encodeSync, vi as prepend, vn as failureOrCause, vr as deferredMake, vt as repeat, w as annotateLogs, wi as unsafeFromArray, wn as isInterruptedOnly, wr as exitFailCause, wt as scope, x as acquireUseRelease$2, xi as take$2, xn as interruptors, xr as deferredUnsafeMake, xt as runPromiseExit$1, y as _void, yi as reduce, yn as flipCauseOption, yr as deferredSucceed, yt as retry, z as fiberIdWith, zi as getOrElse, zn as sleep, zr as suspend$4, zt as uninterruptibleMask } from "./chunks/Schema-CyImoHTj.mjs";
5
5
  import { createRequire } from "node:module";
6
6
  import * as Crypto from "node:crypto";
7
7
  import { createHash } from "node:crypto";
@@ -5401,6 +5401,7 @@ const makeGitImpl = (executor, root) => {
5401
5401
  }))),
5402
5402
  hasCommits: () => exec("git", "rev-parse", "--verify", "HEAD").pipe(map$4(() => true), catchAll$1(() => succeed$6(false))),
5403
5403
  readFileAtRef: (ref, path) => exec("git", "show", `${ref}:${path}`),
5404
+ diffPaths: (base, tip) => exec("git", "diff", "--name-only", "-z", "--no-renames", "--no-relative", "--ignore-submodules=none", "--end-of-options", base, tip, "--").pipe(map$4(splitNul)),
5404
5405
  changedPaths: (base) => gen(function* () {
5405
5406
  const ref = base ?? "HEAD";
5406
5407
  const nameStatusOut = yield* exec("git", "diff", "--name-status", "-z", ref).pipe(catchAll$1(() => succeed$6("")));
@@ -6071,6 +6072,8 @@ const HISTORY_REF = "refs/worktree/gtd/history";
6071
6072
  /** One ref per finished process, kept past the branch's deletion; distinct from the per-worktree `HISTORY_REF`. */
6072
6073
  const PROCESS_HISTORY_PREFIX = "refs/gtd/history/";
6073
6074
  const processHistoryRef = (hash) => `${PROCESS_HISTORY_PREFIX}${hash}`;
6075
+ /** Directory of a step's turns set aside by `keep: true`, each at `<this>/<kept hash>`; own namespace, so history list/show/fetch never see it. */
6076
+ const keptRef = (entry, step) => `refs/gtd/kept/${entry}/${step}`;
6074
6077
  const readRetainedHistory = (git) => git.readRefOption(HISTORY_REF);
6075
6078
  /**
6076
6079
  * Whether resetting to `tipHash` is safe: HEAD must be an ancestor of
@@ -6770,6 +6773,22 @@ const accessShapeFault = (value) => {
6770
6773
  const sameGlobs = (a, b) => a === void 0 || b === void 0 ? a === b : a.length === b.length && a.every((g, i) => g === b[i]);
6771
6774
  const sameAccess = (a, b) => sameGlobs(a?.read, b?.read) && sameGlobs(a?.write, b?.write);
6772
6775
  //#endregion
6776
+ //#region src/replay/RefName.ts
6777
+ const FORBIDDEN = /[\u0000- \u007f~^:?*[\\]/;
6778
+ /** Why `name` cannot be (part of) a git ref name, or `undefined` when it can. */
6779
+ const refNameFault = (name) => {
6780
+ if (name === "") return "it is empty";
6781
+ if (FORBIDDEN.test(name)) return "it contains a space, control character or one of ~^:?*[\\";
6782
+ if (name.includes("..")) return "it contains \"..\"";
6783
+ if (name.includes("@{")) return "it contains \"@{\"";
6784
+ if (name.endsWith(".")) return "it ends with \".\"";
6785
+ for (const part of name.split("/")) {
6786
+ if (part === "") return "it has an empty \"/\" component";
6787
+ if (part.startsWith(".")) return "a component starts with \".\"";
6788
+ if (part.endsWith(".lock")) return "a component ends with \".lock\"";
6789
+ }
6790
+ };
6791
+ //#endregion
6773
6792
  //#region src/replay/Tree.ts
6774
6793
  const treeFromRecord = (files) => ({
6775
6794
  paths: () => Object.keys(files).sort(),
@@ -6864,6 +6883,7 @@ const decodePath = (raw) => {
6864
6883
  return typeof path === "string" && path !== "" && !CONTROL_CHARACTER$1.test(path) ? path : void 0;
6865
6884
  };
6866
6885
  const FORMAT_RE = /^[1-9][0-9]*$/;
6886
+ const HASH_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
6867
6887
  const TRAILER_READERS = {
6868
6888
  "Gtd-Step": (value, into) => {
6869
6889
  into.step ??= parseStepId(value);
@@ -6893,6 +6913,9 @@ const TRAILER_READERS = {
6893
6913
  "Gtd-Version": (value, into) => {
6894
6914
  if (value !== "") into.version ??= value;
6895
6915
  },
6916
+ "Gtd-Kept": (value, into) => {
6917
+ if (HASH_RE.test(value)) into.kept ??= value;
6918
+ },
6896
6919
  "Gtd-Format": (value, into) => {
6897
6920
  if (FORMAT_RE.test(value)) into.format ??= Number(value);
6898
6921
  },
@@ -6913,6 +6936,7 @@ const parseCommitMessage = (message) => {
6913
6936
  reviewBase: void 0,
6914
6937
  branch: void 0,
6915
6938
  version: void 0,
6939
+ kept: void 0,
6916
6940
  judge: [],
6917
6941
  vars: {},
6918
6942
  config: {},
@@ -7027,7 +7051,8 @@ const KNOWN_OPTIONS = {
7027
7051
  ...STEERING_OPTIONS,
7028
7052
  "model",
7029
7053
  "system",
7030
- "allowEmpty"
7054
+ "allowEmpty",
7055
+ "keep"
7031
7056
  ]),
7032
7057
  human: /* @__PURE__ */ new Set([
7033
7058
  ...STEERING_OPTIONS,
@@ -7059,6 +7084,7 @@ const replay = async (input) => {
7059
7084
  const scopeCallIds = [];
7060
7085
  let scopeCallCount = 0;
7061
7086
  const trace = [];
7087
+ const keptTips = /* @__PURE__ */ new Map();
7062
7088
  let landed;
7063
7089
  let outcome;
7064
7090
  let signal;
@@ -7218,6 +7244,12 @@ const replay = async (input) => {
7218
7244
  }
7219
7245
  cursor++;
7220
7246
  advance(commit);
7247
+ if (commit.parsed.kept !== void 0) if (commit.kept === void 0) keptTips.delete(step.name);
7248
+ else keptTips.set(step.name, {
7249
+ hash: commit.kept.hash,
7250
+ before: commit.tree,
7251
+ after: commit.kept.tree
7252
+ });
7221
7253
  const to = commit.parsed.parsed?.to;
7222
7254
  if (to !== void 0) expectedNext = {
7223
7255
  name: to,
@@ -7225,6 +7257,21 @@ const replay = async (input) => {
7225
7257
  };
7226
7258
  return replyFor(step.request, commit.parsed.judge);
7227
7259
  };
7260
+ const landPending = (step, pending) => {
7261
+ pendingUsed = true;
7262
+ landed = step;
7263
+ const keeping = step.request.kind === "agent" && step.request.options.keep === true;
7264
+ if (keeping) keptTips.set(step.name, {
7265
+ hash: "",
7266
+ before: position.tree,
7267
+ after: pending.tree
7268
+ });
7269
+ advance({
7270
+ hash: "",
7271
+ tree: keeping ? position.tree : pending.tree
7272
+ });
7273
+ return replyFor(step.request, pending.verdicts ?? []);
7274
+ };
7228
7275
  const answer = (step) => {
7229
7276
  while (cursor < commits.length && isAttemptAt(commits[cursor], step.name)) cursor++;
7230
7277
  const commit = commits[cursor];
@@ -7235,17 +7282,18 @@ const replay = async (input) => {
7235
7282
  trace,
7236
7283
  landed
7237
7284
  });
7238
- pendingUsed = true;
7239
- landed = step;
7240
- advance({
7241
- hash: "",
7242
- tree: input.pending.tree
7243
- });
7244
- return replyFor(step.request, input.pending.verdicts ?? []);
7285
+ return landPending(step, input.pending);
7245
7286
  };
7246
7287
  const requestFault = (request) => {
7247
7288
  if (request.kind === "run" && typeof request.body !== "string") return `gtd: step "${scoped(request.name)}": a run() body is a shell script string — decide in flow code, then render the script (see checkScript and friends)`;
7248
- return request.kind === "agent" ? scopeAccessFault() : void 0;
7289
+ if (request.kind !== "agent") return void 0;
7290
+ const name = scoped(request.name);
7291
+ if (request.options.keep === true) {
7292
+ if (request.options.allowEmpty === true) return `gtd: step "${name}": keep: true cannot be combined with allowEmpty — a turn that changes nothing has nothing to keep`;
7293
+ const refFault = name.includes("/") ? "it contains \"/\"" : refNameFault(name);
7294
+ if (refFault !== void 0) return `gtd: step "${name}": keep: true files the turn under a git ref named after the step, but ${refFault}`;
7295
+ }
7296
+ return scopeAccessFault();
7249
7297
  };
7250
7298
  const handleStep = async (request) => {
7251
7299
  if (outcome !== void 0) throw new Stop();
@@ -7328,6 +7376,13 @@ const replay = async (input) => {
7328
7376
  faults: findings.filter((f) => f.message.startsWith(`Code thread at ${c.path}:${t.line + 1}: `)).map((f) => f.message)
7329
7377
  }));
7330
7378
  }),
7379
+ kept: (name) => {
7380
+ const tip = keptTips.get(scoped(name));
7381
+ return tip === void 0 ? void 0 : {
7382
+ hash: tip.hash,
7383
+ changes: changesBetween(tip.before, tip.after)
7384
+ };
7385
+ },
7331
7386
  vars: input.vars,
7332
7387
  env: input.env,
7333
7388
  start: () => input.start,
@@ -8293,7 +8348,11 @@ const planStep = (snapshot, opts = {}) => {
8293
8348
  ...uncheckStep(snapshot),
8294
8349
  {
8295
8350
  kind: "gitWrite",
8296
- write: {
8351
+ write: landing.keepTurn !== void 0 ? {
8352
+ kind: "keepTurn",
8353
+ ref: landing.keepTurn,
8354
+ message
8355
+ } : {
8297
8356
  kind: "commitAll",
8298
8357
  message
8299
8358
  }
@@ -8409,6 +8468,10 @@ const resolveBase = (commandLabel, value) => gen(function* () {
8409
8468
  return mergeBase.value;
8410
8469
  });
8411
8470
  //#endregion
8471
+ //#region src/step/strayPaths.ts
8472
+ /** Globs use the `access.write` dialect (`globMatches`). */
8473
+ const strayPaths = (touched, allow) => touched.filter((path) => !allow.some((glob) => globMatches(path, glob))).sort();
8474
+ //#endregion
8412
8475
  //#region src/edge/Edge.ts
8413
8476
  const EMPTY_TREE$1 = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
8414
8477
  const ACTORS = /* @__PURE__ */ new Set([
@@ -8768,6 +8831,7 @@ const promptDef = (common, request, skills, access) => ({
8768
8831
  ...optional("model", request.options.model),
8769
8832
  ...optional("system", request.options.system),
8770
8833
  ...optional("allowEmpty", request.options.allowEmpty),
8834
+ ...optional("keep", request.options.keep),
8771
8835
  ...optional("skills", skills),
8772
8836
  ...optional("access", access)
8773
8837
  });
@@ -8882,11 +8946,21 @@ const setupFor = (config, run, workspace, host, startOverrides = {}) => gen(func
8882
8946
  hash: run.episode.base,
8883
8947
  tree: commitTree(workspace, run.episode.base)
8884
8948
  };
8885
- const episode = run.episode.commits.map((c) => ({
8886
- hash: c.hash,
8887
- message: c.message,
8888
- tree: commitTree(workspace, c.hash)
8889
- }));
8949
+ const git = yield* GitService;
8950
+ const episode = [];
8951
+ for (const c of run.episode.commits) {
8952
+ const tip = parseCommitMessage(c.message).kept;
8953
+ const exists = tip !== void 0 && isSome(yield* git.readRefOption(`${tip}^{commit}`));
8954
+ episode.push({
8955
+ hash: c.hash,
8956
+ message: c.message,
8957
+ tree: commitTree(workspace, c.hash),
8958
+ ...exists ? { kept: {
8959
+ hash: tip,
8960
+ tree: commitTree(workspace, tip)
8961
+ } } : {}
8962
+ });
8963
+ }
8890
8964
  let pendingMemo;
8891
8965
  return {
8892
8966
  resolved,
@@ -9062,6 +9136,7 @@ const closingTrailers = (branch) => ({
9062
9136
  ...isSome(branch) ? { branch: branch.value } : {}
9063
9137
  });
9064
9138
  const isSettledNoop = (rest, to) => rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state;
9139
+ const keptTurn = (rest) => rest.stepDef.keep === true && rest.changes.length > 0 ? { keepTurn: keptRef(rest.run.startHash, rest.state) } : {};
9065
9140
  const landingTo = (rest, to, branch) => {
9066
9141
  if (isSettledNoop(rest, to)) return {
9067
9142
  kind: "noop",
@@ -9086,7 +9161,8 @@ const landingTo = (rest, to, branch) => {
9086
9161
  } : {},
9087
9162
  ...finishes ? closingTrailers(branch) : {}
9088
9163
  },
9089
- ...finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {}
9164
+ ...finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {},
9165
+ ...keptTurn(rest)
9090
9166
  };
9091
9167
  };
9092
9168
  /**
@@ -9223,7 +9299,7 @@ const commitAllowEmpty = (message) => {
9223
9299
  return [
9224
9300
  `if ! out=$(git commit --allow-empty -m ${m} 2>&1); then`,
9225
9301
  ` case "$out" in`,
9226
- ` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m} ;;`,
9302
+ ` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m} || exit 1 ;;`,
9227
9303
  ` *) printf '%s\\n' "$out" >&2; exit 1 ;;`,
9228
9304
  ` esac`,
9229
9305
  `fi`
@@ -9245,6 +9321,36 @@ const hardResetTo = (ref) => `git reset --hard ${shellQuote(ref)}`;
9245
9321
  * `GitWriterOperations.discardPending` for why it's kept anyway.
9246
9322
  */
9247
9323
  const discardPending = () => `git add -A && git reset --hard HEAD`;
9324
+ /**
9325
+ * POSIX sh (runs under dash). The marker is a `commit-tree` over HEAD's own
9326
+ * tree, so no hook runs on it and its tree is the pre-turn tree by
9327
+ * construction; `commit-tree` ignores `commit.gpgSign`, hence the explicit
9328
+ * `-S`, read (and failing closed) before any object or ref is written; the
9329
+ * index is already staged by then, so an early exit leaves the turn staged.
9330
+ * The kept ref is deleted only after `reset -q` succeeds: it is then the
9331
+ * turn's only handle.
9332
+ * The branch moves before the final hard reset, so if that reset fails the
9333
+ * marker has landed and the turn is still in the working tree (kept ref
9334
+ * present); every other failure leaves the branch untouched.
9335
+ */
9336
+ const keepTurn = (refPrefix, message) => {
9337
+ const keptRef = `${shellQuote(`${refPrefix}/`)}"$gtd_kept"`;
9338
+ return [
9339
+ `git add -A || exit 1`,
9340
+ `gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
9341
+ `if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
9342
+ `gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
9343
+ `gtd_tree=$(git write-tree) || exit 1`,
9344
+ `gtd_kept=$(git commit-tree "$gtd_tree" -p "$gtd_head" -m ${shellQuote(message)}) || exit 1`,
9345
+ `git update-ref ${keptRef} "$gtd_kept" || exit 1`,
9346
+ `gtd_marker=$(printf '%s\\nGtd-Kept: %s' ${shellQuote(message)} "$gtd_kept")`,
9347
+ `if ! gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m "$gtd_marker") || ! git update-ref HEAD "$gtd_tip" "$gtd_head"; then`,
9348
+ ` git reset -q && git update-ref -d ${keptRef}`,
9349
+ ` exit 1`,
9350
+ `fi`,
9351
+ `git reset --hard HEAD`
9352
+ ].join("\n");
9353
+ };
9248
9354
  const updateRef = (ref, hash) => `git update-ref ${shellQuote(ref)} ${shellQuote(hash)}`;
9249
9355
  /** `git update-ref -d <ref>` — idempotent: deleting a missing ref is already a no-op in real git. */
9250
9356
  const deleteRef = (ref) => `git update-ref -d ${shellQuote(ref)}`;
@@ -9277,6 +9383,7 @@ const renderGitWrite = (write) => {
9277
9383
  switch (write.kind) {
9278
9384
  case "commitAll": return commitAll(write.message);
9279
9385
  case "updateRef": return updateRef(write.ref, write.target);
9386
+ case "keepTurn": return keepTurn(write.ref, write.message);
9280
9387
  }
9281
9388
  };
9282
9389
  /**
@@ -9661,6 +9768,24 @@ const writeSelection = (out, fields, path) => gen(function* () {
9661
9768
  else if (selection.kind === "unknown") return yield* fail$5(new SelectorUsageError(`gtd: unknown --json selector "${selection.path}" — see \`gtd --help\``));
9662
9769
  });
9663
9770
  //#endregion
9771
+ //#region src/cli/Strays.ts
9772
+ const resolveSide = (git, side) => git.resolveRef(`${side}^{commit}`).pipe(mapError(() => new GtdError(`gtd strays: unknown revision '${side}'`)));
9773
+ /** stdout carries the array alone; the summary is ungated stderr so a quiet run still explains its exit code, the per-path lines are narration. */
9774
+ const runStraysCommand = (command, json, out) => gen(function* () {
9775
+ const git = yield* GitService;
9776
+ const narrator = yield* Narrator;
9777
+ const base = yield* resolveSide(git, command.base);
9778
+ const tip = yield* resolveSide(git, command.tip);
9779
+ const touched = yield* git.diffPaths(base, tip);
9780
+ const strays = strayPaths(touched, command.globs);
9781
+ const total = touched.length;
9782
+ if (json.kind === "select") yield* writeSelection(out, strays, json.path).pipe(catchIf((error) => error instanceof SelectorUsageError && /^\d+$/.test(json.path), () => _void));
9783
+ else out.write(`${JSON.stringify(strays)}\n`);
9784
+ for (const path of strays) yield* narrator.narrate(`stray: ${JSON.stringify(path)}`);
9785
+ yield* narrator.warn(strays.length === 0 ? `gtd strays: no strays — ${total} touched files, all inside the allowlist` : `gtd strays: ${strays.length} of ${total} touched files outside the allowlist`);
9786
+ return strays.length === 0 ? 0 : 10;
9787
+ });
9788
+ //#endregion
9664
9789
  //#region src/cli/History.ts
9665
9790
  /** Newest tip first. */
9666
9791
  const historyEntries = gen(function* () {
@@ -10313,7 +10438,8 @@ const needsOf = (kind) => {
10313
10438
  case "lsp":
10314
10439
  case "install":
10315
10440
  case "loop":
10316
- case "judgeRun": return "none";
10441
+ case "judgeRun":
10442
+ case "strays": return "none";
10317
10443
  case "check":
10318
10444
  case "uncheck": return "fs";
10319
10445
  default: return "state";
@@ -10322,10 +10448,13 @@ const needsOf = (kind) => {
10322
10448
  /**
10323
10449
  * Dispatches to the named `run*Command` handler for every `Command.kind` —
10324
10450
  * the counterpart of `Cli.ts`'s `parseArgv`, which has already validated
10325
- * every field a handler receives. A command choosing its own exit code is
10326
- * unrepresentable: every branch returns `Effect<void>`, and `Cli.ts`'s
10327
- * `runCli` supplies `EXIT_OK` once, after this Effect succeeds.
10451
+ * every field a handler receives. Only `strays` chooses its exit code; every
10452
+ * other branch returns `Effect<void>` and resolves to `EXIT_OK`.
10328
10453
  */
10454
+ const dispatchCommand = (command, json, out) => {
10455
+ if (command.kind === "strays") return runStraysCommand(command, json, out);
10456
+ return as(dispatchVoidCommand(command, json, out), 0);
10457
+ };
10329
10458
  const dispatchVoidCommand = (command, json, out) => {
10330
10459
  switch (command.kind) {
10331
10460
  case "lsp": return runLspCommand();
@@ -10365,7 +10494,7 @@ const dispatchVoidCommand = (command, json, out) => {
10365
10494
  * so a refusal writes nothing and emits no script by construction.
10366
10495
  */
10367
10496
  const runCommand$1 = (command, json, out) => {
10368
- const dispatch = dispatchVoidCommand(command, json, out);
10497
+ const dispatch = dispatchCommand(command, json, out);
10369
10498
  if (needsOf(command.kind) !== "state") return dispatch;
10370
10499
  return gen(function* () {
10371
10500
  const git = yield* GitService;
@@ -10378,7 +10507,7 @@ const runCommand$1 = (command, json, out) => {
10378
10507
  warn: () => _void
10379
10508
  }));
10380
10509
  for (const warning of config.warnings) yield* narrator.warn(`gtd: warning: ${formatDiagnostic(warning)}`);
10381
- yield* dispatch;
10510
+ return yield* dispatch;
10382
10511
  });
10383
10512
  };
10384
10513
  //#endregion
@@ -10393,13 +10522,13 @@ const FLAGS = [
10393
10522
  name: "--json",
10394
10523
  arity: "optional",
10395
10524
  repeatable: false,
10396
- scope: (kind) => kind === "next" || kind === "land" || kind === "judge" || kind === "judgeAnswer" || kind === "doors" || kind === "historyList" || kind === "historyShow",
10525
+ scope: (kind) => kind === "next" || kind === "land" || kind === "judge" || kind === "judgeAnswer" || kind === "doors" || kind === "historyList" || kind === "historyShow" || kind === "strays",
10397
10526
  decode: ([raw]) => right(raw ?? ""),
10398
- 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",
10527
+ scopeError: "gtd: --json is only valid for `gtd next`/`gtd land`/`gtd judge`/`gtd judge answer`/`gtd doors`/`gtd history list`/`gtd history show`/`gtd strays` — every other command prints plain text; see `gtd install` for the driver protocol briefing",
10399
10528
  valueHint: "<path>",
10400
10529
  help: [
10401
10530
  "(gtd next/gtd land/gtd judge/gtd judge answer/gtd doors/gtd history",
10402
- "list/gtd history show only) output structured JSON. Bare --json prints the whole document;",
10531
+ "list/gtd history show/gtd strays only) output structured JSON. Bare --json prints the whole document;",
10403
10532
  "--json=<path> (a dotted key path into that document, e.g.",
10404
10533
  "kind, content, session.id) prints just that value: a",
10405
10534
  "scalar raw and unquoted, a boolean as true/false, a list",
@@ -10908,6 +11037,28 @@ const COMMAND_ROWS = [
10908
11037
  "every question, with nothing on stdout"
10909
11038
  ]
10910
11039
  },
11040
+ {
11041
+ token: "strays",
11042
+ kind: "strays",
11043
+ arity: {
11044
+ name: "range",
11045
+ variadic: "glob"
11046
+ },
11047
+ details: [
11048
+ "List the files <range> (<base>..<tip>, an empty side is HEAD)",
11049
+ "touched in its committed net diff that match none of the",
11050
+ "globs. Anchored at the repo root: * stays within one path",
11051
+ "segment, ** crosses them, everything else is literal — quote",
11052
+ "globs in the shell. stdout: a sorted JSON array, [] when",
11053
+ "clean; stderr: a summary, plus one stray: line per path with",
11054
+ "--verbose. --json=<n> picks one entry, nothing past the end;",
11055
+ "a symlink is a path like any other, never followed; a",
11056
+ "submodule counts as its path, whatever diff.ignoreSubmodules",
11057
+ "or .gitmodules ignore say. Runs in any git repository, no",
11058
+ "process needed. Exits 10 when",
11059
+ "strays were found, 1 on an unknown revision, 2 on a bad range"
11060
+ ]
11061
+ },
10911
11062
  {
10912
11063
  token: "history list",
10913
11064
  kind: "historyList",
@@ -11250,6 +11401,22 @@ const parseArgv = (argv) => {
11250
11401
  json,
11251
11402
  verbose
11252
11403
  };
11404
+ if (kind === "strays") {
11405
+ const [range, ...globs] = restPositionals;
11406
+ const at = range.indexOf("..");
11407
+ if (at < 0 || range.includes("...") || range.indexOf("..", at + 1) >= 0) return usagePlan(`gtd strays: <range> must be <base>..<tip> (an empty side is HEAD) — got '${range}'`, jsonSeen);
11408
+ return {
11409
+ kind: "command",
11410
+ command: {
11411
+ kind: "strays",
11412
+ base: range.slice(0, at) || "HEAD",
11413
+ tip: range.slice(at + 2) || "HEAD",
11414
+ globs
11415
+ },
11416
+ json,
11417
+ verbose
11418
+ };
11419
+ }
11253
11420
  if (kind === "uncheck") return {
11254
11421
  kind: "command",
11255
11422
  command: {
@@ -11328,9 +11495,9 @@ const runCli = (argv, io) => {
11328
11495
  return flatMap$4(Host, ({ env }) => try_({
11329
11496
  try: () => resolveFlags(env, io.flags),
11330
11497
  catch: (e) => new GtdError(e.message)
11331
- })).pipe(flatMap$4(() => runCommand$1(plan.command, plan.json, out)), map$4(() => {
11498
+ })).pipe(flatMap$4(() => runCommand$1(plan.command, plan.json, out)), map$4((code) => {
11332
11499
  out.flush();
11333
- io.exit(0);
11500
+ io.exit(code);
11334
11501
  }), provide$2(io.layers(plan.verbose)), sandbox, catchAll$1((cause) => isInterruptedOnly(cause) ? failCause$4(cause) : report(io, plan.json.kind !== "off")(cause)));
11335
11502
  };
11336
11503
  //#endregion
@@ -11406,6 +11573,7 @@ var InMemRepo = class InMemRepo {
11406
11573
  return this.head !== null;
11407
11574
  }
11408
11575
  resolveRef(ref) {
11576
+ if (ref.endsWith("^{commit}")) return this.resolveRef(ref.slice(0, -9));
11409
11577
  if (/^[0-9a-f]{40}$/.test(ref)) return this.commits.has(ref) ? ref : null;
11410
11578
  const tildeMatch = /^(HEAD|[0-9a-f]{40})(~(\d+)|\^)?$/.exec(ref);
11411
11579
  if (tildeMatch) {
@@ -11526,6 +11694,10 @@ var InMemRepo = class InMemRepo {
11526
11694
  changedPathsWorktree(base) {
11527
11695
  return diffTrees(this.treeAt(base), new Map(this.worktree));
11528
11696
  }
11697
+ diffPaths(base, tip) {
11698
+ for (const ref of [base, tip]) if (this.resolveRef(ref) === null) throw new Error(`Invalid ref: ${ref}`);
11699
+ return diffTrees(this.treeAt(base), this.treeAt(tip)).map((e) => e.path);
11700
+ }
11529
11701
  /** The worktree content of `path`, or `undefined` when absent. A LIVE read — never a snapshot. */
11530
11702
  readFile(path) {
11531
11703
  return this.worktree.get(path);
@@ -11578,8 +11750,12 @@ var InMemRepo = class InMemRepo {
11578
11750
  stageAll() {
11579
11751
  this.index = new Map(this.worktree);
11580
11752
  }
11581
- /** Commit whatever is currently staged (the index) verbatim, with no implicit staging first — mirrors `git commit --allow-empty -m <message>` after a soft reset. */
11582
- commitAsIs(message) {
11753
+ /** Seeded failure modes (see `GitTierSeed`): a pre-commit hook printing this and exiting 1, and a signer that cannot write. */
11754
+ hookRejection = void 0;
11755
+ signingFails = false;
11756
+ /** Seeded: a pre-commit hook writing and staging this file. */
11757
+ hookStages = void 0;
11758
+ writeCommit(message) {
11583
11759
  const tree = new Map(this.index);
11584
11760
  const parent = this.head;
11585
11761
  const hash = makeHash(message, parent, tree);
@@ -11594,27 +11770,59 @@ var InMemRepo = class InMemRepo {
11594
11770
  this.head = hash;
11595
11771
  this.branches.set(this.currentBranch, hash);
11596
11772
  }
11597
- /** Discard every pending change, tracked or untracked: stage everything, then hard-reset (which drops the freshly-staged untracked paths too). */
11598
- discardPending() {
11599
- this.index = new Map(this.worktree);
11600
- this.resetHard();
11773
+ /**
11774
+ * Twin of the emitted `git commit --allow-empty` if/case retry: a hook
11775
+ * rejection printing "empty git commit" retries without hooks, any other
11776
+ * rejection fails; a failing signer fails either attempt. Commits whatever is
11777
+ * staged verbatim, like `git commit` after a soft reset.
11778
+ */
11779
+ commitAsIs(message) {
11780
+ if (this.hookRejection !== void 0 && !this.hookRejection.includes("empty git commit")) return this.hookRejection;
11781
+ if (this.signingFails) return "error: gpg failed to sign the data";
11782
+ if (this.hookStages !== void 0) {
11783
+ this.worktree.set(this.hookStages.path, this.hookStages.content);
11784
+ this.index.set(this.hookStages.path, this.hookStages.content);
11785
+ }
11786
+ this.writeCommit(message);
11601
11787
  }
11602
- commitAllWithPrefix(prefix) {
11788
+ /** Setup commit: stages everything, and throws if a failure is seeded, as the Live tier's hooks and signer would. */
11789
+ seedCommit(message) {
11790
+ if (this.hookRejection !== void 0 || this.hookStages !== void 0 || this.signingFails) throw new Error("seedCommit after a seeded commit failure");
11603
11791
  this.index = new Map(this.worktree);
11792
+ this.writeCommit(message);
11793
+ }
11794
+ /** Twin of `GitScript.keepTurn`: no hook runs; `signingFails` fails the marker. */
11795
+ keepTurn(refPrefix, message) {
11796
+ this.stageAll();
11604
11797
  const tree = new Map(this.index);
11605
- const message = prefix;
11606
11798
  const parent = this.head;
11607
11799
  const hash = makeHash(message, parent, tree);
11608
- const commit = {
11800
+ this.commits.set(hash, {
11609
11801
  hash,
11610
11802
  message,
11611
11803
  files: new Map(tree),
11612
11804
  parent,
11613
11805
  seq: this.nextSeq++
11614
- };
11615
- this.commits.set(hash, commit);
11616
- this.head = hash;
11617
- this.branches.set(this.currentBranch, hash);
11806
+ });
11807
+ const ref = `${refPrefix}/${hash}`;
11808
+ this.refs.set(ref, hash);
11809
+ this.index = new Map(this.headTree());
11810
+ if (this.signingFails) {
11811
+ this.refs.delete(ref);
11812
+ return "error: gpg failed to sign the data";
11813
+ }
11814
+ this.writeCommit(`${message}\nGtd-Kept: ${hash}`);
11815
+ this.index = new Map(tree);
11816
+ this.resetHard();
11817
+ }
11818
+ /** Discard every pending change, tracked or untracked: stage everything, then hard-reset (which drops the freshly-staged untracked paths too). */
11819
+ discardPending() {
11820
+ this.index = new Map(this.worktree);
11821
+ this.resetHard();
11822
+ }
11823
+ commitAllWithPrefix(prefix) {
11824
+ this.index = new Map(this.worktree);
11825
+ return this.commitAsIs(prefix);
11618
11826
  }
11619
11827
  static EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
11620
11828
  softResetTo(ref) {
@@ -11767,6 +11975,7 @@ const makeGitReaderOps = (repo, root) => ({
11767
11975
  return content !== null ? succeed$6(content) : fail$5(/* @__PURE__ */ new Error(`path not found at ${ref}: ${path}`));
11768
11976
  },
11769
11977
  readRefOption: (ref) => {
11978
+ if (/^[0-9a-f]{40}$/.test(ref)) return succeed$6(some(ref));
11770
11979
  const hash = repo.resolveRef(ref);
11771
11980
  return succeed$6(hash !== null ? some(hash) : none());
11772
11981
  },
@@ -11787,12 +11996,16 @@ const makeGitReaderOps = (repo, root) => ({
11787
11996
  gitCommonDir: () => succeed$6(`${root}/.git`),
11788
11997
  commitHistory: (base, head) => succeed$6(repo.commitHistory(base, head)),
11789
11998
  subjectHistory: (pageSize, skip, head) => succeed$6(repo.subjectHistory(pageSize, skip, head)),
11790
- changedPaths: (base) => succeed$6(repo.changedPathsWorktree(base))
11999
+ changedPaths: (base) => succeed$6(repo.changedPathsWorktree(base)),
12000
+ diffPaths: (base, tip) => tryCatch(() => repo.diffPaths(base, tip))
11791
12001
  });
12002
+ const failOn = (output) => {
12003
+ if (output !== void 0) throw new Error(output);
12004
+ };
11792
12005
  const makeGitWriterOps = (repo) => ({
11793
- commitAllWithPrefix: (prefix) => tryCatch(() => repo.commitAllWithPrefix(prefix)),
12006
+ commitAllWithPrefix: (prefix) => tryCatch(() => failOn(repo.commitAllWithPrefix(prefix))),
11794
12007
  softResetTo: (ref) => tryCatch(() => repo.softResetTo(ref)),
11795
- commitAsIs: (message) => tryCatch(() => repo.commitAsIs(message)),
12008
+ commitAsIs: (message) => tryCatch(() => failOn(repo.commitAsIs(message))),
11796
12009
  discardPending: () => tryCatch(() => repo.discardPending()),
11797
12010
  updateRef: (ref, hash) => tryCatch(() => repo.updateRef(ref, hash)),
11798
12011
  deleteRef: (ref) => tryCatch(() => repo.deleteRef(ref)),
@@ -11921,6 +12134,13 @@ const missingToolCommands = {
11921
12134
  };
11922
12135
  /** The fine-grained `GitService` layer alone — for unit tests that need only git. */
11923
12136
  const gitTestLayer = (repo, root = "/repo") => succeed$1(GitService, fakeGitOperations(repo, root));
12137
+ /**
12138
+ * The one sanctioned leak of the real `process.env` into the in-memory `Host`:
12139
+ * the runner's `GTD_FLAG_*` state, so a flag-on `npm test` state exercises the
12140
+ * flag in memory as it does live. A test asserting one flag side sets the flag
12141
+ * itself; explicit env wins.
12142
+ */
12143
+ const ambientFlagEnv = (env = process.env) => Object.fromEntries(Object.entries(env).filter((e) => e[0].startsWith("GTD_FLAG_") && e[1] !== void 0));
11924
12144
  /** Every layer `makeProgram` needs — `CommandRequirements`'s return type here IS the guarantee: a new port added there fails this function's typecheck instead of silently under-providing. */
11925
12145
  function testLayers(repo, opts = {}) {
11926
12146
  const root = opts.root ?? "/repo";
@@ -11928,7 +12148,10 @@ function testLayers(repo, opts = {}) {
11928
12148
  return mergeAll$1(gitTestLayer(repo, root), succeed$1(Workspace, makeInMemoryWorkspaceOps(repo, root)), Host.layer({
11929
12149
  root,
11930
12150
  home,
11931
- env: opts.env ?? {},
12151
+ env: {
12152
+ ...ambientFlagEnv(),
12153
+ ...opts.env
12154
+ },
11932
12155
  realPath: () => succeed$6(root)
11933
12156
  }), ConfigService.Live, makeInMemoryConfigDiscovery(repo, root), Narrator.layer(opts.narrate ?? (() => {}), opts.verbose ?? true), succeed$1(ToolCommands, {
11934
12157
  ...missingToolCommands,
@@ -12295,25 +12518,51 @@ const conditionStatement = (block) => {
12295
12518
  const end = block.indexOf(CONDITION_TERMINATOR, start);
12296
12519
  return end === -1 ? block.slice(start) : block.slice(start, end);
12297
12520
  };
12521
+ /** `keepTurn`: an invalid ref name fails before anything moves, as `git update-ref` does. */
12522
+ const recognizeKeepTurn = (repo, block) => {
12523
+ const [message, refDir] = extractQuotedTokens(block);
12524
+ const ref = refDir?.slice(0, -1);
12525
+ if (message === void 0 || ref === void 0 || keepTurn(ref, message) !== block) return;
12526
+ if (refNameFault(ref) !== void 0) return {
12527
+ kind: "failed",
12528
+ error: `fatal: invalid ref format: ${ref}`,
12529
+ status: 128
12530
+ };
12531
+ const failure = repo.keepTurn(ref, message);
12532
+ return failure === void 0 ? { kind: "applied" } : {
12533
+ kind: "failed",
12534
+ error: failure,
12535
+ status: 1
12536
+ };
12537
+ };
12298
12538
  const recognizeGitBuilders = (repo, block) => {
12299
12539
  if (block === discardPending()) {
12300
12540
  repo.discardPending();
12301
12541
  return { kind: "applied" };
12302
12542
  }
12303
12543
  const conditionLine = conditionStatement(block);
12544
+ if (block.startsWith("git add -A || exit 1\ngtd_sign_cfg=")) return recognizeKeepTurn(repo, block);
12304
12545
  if (block.startsWith("git add -A || exit 1\n")) {
12305
12546
  const [message] = extractQuotedTokens(conditionLine);
12306
12547
  if (message !== void 0 && commitAll(message) === block) {
12307
- repo.commitAllWithPrefix(message);
12308
- return { kind: "applied" };
12548
+ const failure = repo.commitAllWithPrefix(message);
12549
+ return failure === void 0 ? { kind: "applied" } : {
12550
+ kind: "failed",
12551
+ error: failure,
12552
+ status: 1
12553
+ };
12309
12554
  }
12310
12555
  return;
12311
12556
  }
12312
12557
  if (block.startsWith("if ! out=$(git commit --allow-empty -m ")) {
12313
12558
  const [message] = extractQuotedTokens(conditionLine);
12314
12559
  if (message !== void 0 && commitAsIs(message) === block) {
12315
- repo.commitAsIs(message);
12316
- return { kind: "applied" };
12560
+ const failure = repo.commitAsIs(message);
12561
+ return failure === void 0 ? { kind: "applied" } : {
12562
+ kind: "failed",
12563
+ error: failure,
12564
+ status: 1
12565
+ };
12317
12566
  }
12318
12567
  return;
12319
12568
  }
@@ -12850,7 +13099,7 @@ const testRepo = (options) => {
12850
13099
  const id = `module-${++registered}`;
12851
13100
  repo.writeFile("gtd.config.ts", typeof workflow === "string" ? workflow : moduleSource(id, workflow));
12852
13101
  for (const [path, content] of Object.entries(options.files ?? {})) repo.writeFile(path, content);
12853
- repo.commitAllWithPrefix("chore: initial commit");
13102
+ repo.seedCommit("chore: initial commit");
12854
13103
  const gtd = async (...args) => {
12855
13104
  const { io, result } = makeCapturingCliIo(repo, env);
12856
13105
  if (typeof workflow !== "string") registry().set(id, workflow);
@@ -12918,7 +13167,7 @@ const testRepo = (options) => {
12918
13167
  read: (path) => repo.readFile(path),
12919
13168
  write: (path, content) => repo.writeFile(path, content),
12920
13169
  remove: (path) => repo.deleteFile(path),
12921
- commit: (message) => repo.commitAllWithPrefix(message),
13170
+ commit: (message) => repo.seedCommit(message),
12922
13171
  show: (ref, path) => repo.fileAtRef(ref, path) ?? void 0,
12923
13172
  runScript: (script) => {
12924
13173
  const flow = applyFlowScript(repo, /* @__PURE__ */ new Map(), script);
@@ -12958,6 +13207,14 @@ const testRepo = (options) => {
12958
13207
  };
12959
13208
  //#endregion
12960
13209
  //#region src/testkit/GitTiers.ts
13210
+ const writeRejectingHook = (hooksDir, output) => {
13211
+ mkdirSync(hooksDir, { recursive: true });
13212
+ writeFileSync(join(hooksDir, "pre-commit"), `#!/bin/sh\ncat >&2 <<'GTD_HOOK_EOF'\n${output}\nGTD_HOOK_EOF\nexit 1\n`, { mode: 493 });
13213
+ };
13214
+ const writeStagingHook = (hooksDir, path, content) => {
13215
+ mkdirSync(hooksDir, { recursive: true });
13216
+ writeFileSync(join(hooksDir, "pre-commit"), `#!/bin/sh\nprintf '%s' ${shellQuote(content)} > ${shellQuote(path)} && git add ${shellQuote(path)}\nexit 0\n`, { mode: 493 });
13217
+ };
12961
13218
  /** No-op — these tests assert on git/config behavior, not narration. */
12962
13219
  const noopNarratorLayer = Narrator.layer(() => {}, false);
12963
13220
  const gitExecIn = (dir, ...args) => execSync(`git ${args.join(" ")}`, {
@@ -13007,7 +13264,6 @@ const makeLiveTier = (initialCommit = true) => {
13007
13264
  root,
13008
13265
  capabilities: {
13009
13266
  onDiskContent: true,
13010
- commitHooks: true,
13011
13267
  linkedWorktrees: true,
13012
13268
  directoryChainConfig: true,
13013
13269
  remotes: true
@@ -13035,7 +13291,22 @@ const makeLiveTier = (initialCommit = true) => {
13035
13291
  },
13036
13292
  stageAll: () => gitExec("add", "-A"),
13037
13293
  updateRef: (ref, hash) => gitExec("update-ref", ref, hash),
13038
- mixedReset: (ref) => gitExec("reset", "--mixed", ref)
13294
+ mixedReset: (ref) => gitExec("reset", "--mixed", ref),
13295
+ rejectingHook: (output) => {
13296
+ const hooks = join(root, ".git/hooks");
13297
+ gitExec("config", "core.hooksPath", hooks);
13298
+ writeRejectingHook(hooks, output);
13299
+ },
13300
+ stagingHook: (path, content) => {
13301
+ const hooks = join(root, ".git/hooks");
13302
+ gitExec("config", "core.hooksPath", hooks);
13303
+ writeStagingHook(hooks, path, content);
13304
+ },
13305
+ failingSigner: () => {
13306
+ gitExec("config", "commit.gpgsign", "true");
13307
+ gitExec("config", "gpg.format", "openpgp");
13308
+ gitExec("config", "gpg.program", "false");
13309
+ }
13039
13310
  },
13040
13311
  observe: {
13041
13312
  resolveRef: (ref) => gitExec("rev-parse", ref),
@@ -13101,7 +13372,7 @@ const makeInMemTier = (initialCommit = true) => {
13101
13372
  const repo = new InMemRepo();
13102
13373
  if (initialCommit) {
13103
13374
  repo.writeFile("readme.txt", "hello");
13104
- repo.commitAllWithPrefix("init: first commit");
13375
+ repo.seedCommit("init: first commit");
13105
13376
  }
13106
13377
  const gitLayer = gitTestLayer(repo, IN_MEM_ROOT);
13107
13378
  const provide = (eff) => runPromise(eff.pipe(provide$2(gitLayer), provide$2(noopNarratorLayer)));
@@ -13111,7 +13382,6 @@ const makeInMemTier = (initialCommit = true) => {
13111
13382
  root: IN_MEM_ROOT,
13112
13383
  capabilities: {
13113
13384
  onDiskContent: false,
13114
- commitHooks: false,
13115
13385
  linkedWorktrees: false,
13116
13386
  directoryChainConfig: true,
13117
13387
  remotes: false
@@ -13122,17 +13392,29 @@ const makeInMemTier = (initialCommit = true) => {
13122
13392
  seed: {
13123
13393
  commit: (message, files = { "file.txt": message }) => {
13124
13394
  for (const [path, content] of Object.entries(files)) repo.writeFile(path, content);
13125
- repo.commitAllWithPrefix(message);
13395
+ repo.seedCommit(message);
13126
13396
  },
13127
13397
  writeFile: (path, content) => repo.writeFile(path, content),
13128
13398
  deleteFile: (path) => repo.deleteFile(path),
13129
13399
  commitDeletion: (path, message) => {
13130
13400
  repo.deleteFile(path);
13131
- repo.commitAllWithPrefix(message);
13401
+ repo.seedCommit(message);
13132
13402
  },
13133
13403
  stageAll: () => repo.stageAll(),
13134
13404
  updateRef: (ref, hash) => repo.updateRef(ref, hash),
13135
- mixedReset: (ref) => repo.mixedResetTo(ref)
13405
+ mixedReset: (ref) => repo.mixedResetTo(ref),
13406
+ rejectingHook: (output) => {
13407
+ repo.hookRejection = output;
13408
+ },
13409
+ stagingHook: (path, content) => {
13410
+ repo.hookStages = {
13411
+ path,
13412
+ content
13413
+ };
13414
+ },
13415
+ failingSigner: () => {
13416
+ repo.signingFails = true;
13417
+ }
13136
13418
  },
13137
13419
  observe: {
13138
13420
  resolveRef: (ref) => {
@@ -13193,7 +13475,7 @@ const PATHOLOGICAL_PATHS = [
13193
13475
  }
13194
13476
  ];
13195
13477
  /**
13196
- * Exercise all 21 `GitOperations` methods identically against `makeTier()` —
13478
+ * Exercise every `GitOperations` methods identically against `makeTier()` —
13197
13479
  * called once per tier by `src/platform/Git.test.ts`. A capability-gated group
13198
13480
  * (`t.capabilities.X`) is skipped, not faked, on a tier that can't support it.
13199
13481
  */
@@ -13228,7 +13510,8 @@ const runGitServiceContract = (makeTier) => {
13228
13510
  g.subjectHistory(10, 0),
13229
13511
  g.readFileAtRef("HEAD", "second.txt"),
13230
13512
  g.changedPaths(),
13231
- g.changedPaths("HEAD~1")
13513
+ g.changedPaths("HEAD~1"),
13514
+ g.diffPaths("HEAD~1", "HEAD")
13232
13515
  ]));
13233
13516
  expect(await t.observe.snapshot()).toBe(before);
13234
13517
  await runGit(t, (g) => g.updateRef("refs/gtd/marker", t.observe.resolveRef("HEAD")));
@@ -13463,16 +13746,6 @@ const runGitServiceContract = (makeTier) => {
13463
13746
  const history = await runGit(t, (g) => g.commitHistory());
13464
13747
  expect(history[history.length - 1]?.message).toBe("gtd: grilled");
13465
13748
  });
13466
- it.skipIf(!capabilities.commitHooks)("retries with --no-verify when a hook blocks the empty commit", async () => {
13467
- const hookPath = join(t.root, ".git/hooks/pre-commit");
13468
- writeFileSync(hookPath, `#!/bin/sh\necho "lint-staged prevented an empty git commit." >&2\nexit 1\n`);
13469
- execSync(`chmod +x "${hookPath}"`);
13470
- const headBefore = t.observe.resolveRef("HEAD");
13471
- await runGit(t, (g) => g.commitAllWithPrefix("gtd: grilled"));
13472
- expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore);
13473
- const history = await runGit(t, (g) => g.commitHistory());
13474
- expect(history[history.length - 1]?.message).toBe("gtd: grilled");
13475
- });
13476
13749
  });
13477
13750
  describe("commitAsIs", () => {
13478
13751
  it("commits exactly what's staged, ignoring later worktree writes (the squash mechanic)", async () => {
@@ -13491,6 +13764,29 @@ const runGitServiceContract = (makeTier) => {
13491
13764
  expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore);
13492
13765
  });
13493
13766
  });
13767
+ describe.each([["commitAllWithPrefix", (g) => g.commitAllWithPrefix("gtd: building")], ["commitAsIs", (g) => g.commitAsIs("gtd: building")]])("%s seeded commit failures", (_name, commit) => {
13768
+ it("retries with --no-verify when a hook blocks the empty commit", async () => {
13769
+ t.seed.rejectingHook("lint-staged prevented an empty git commit.");
13770
+ const headBefore = t.observe.resolveRef("HEAD");
13771
+ await runGit(t, commit);
13772
+ expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore);
13773
+ const history = await runGit(t, (g) => g.commitHistory());
13774
+ expect(history[history.length - 1]?.message).toBe("gtd: building");
13775
+ });
13776
+ it("fails on a hook rejection that is not the empty-commit one, leaving HEAD unchanged", async () => {
13777
+ t.seed.rejectingHook("lint error");
13778
+ const headBefore = t.observe.resolveRef("HEAD");
13779
+ expect(isFailure(await runGitExit(t, commit))).toBe(true);
13780
+ expect(t.observe.resolveRef("HEAD")).toBe(headBefore);
13781
+ });
13782
+ it("fails both attempts when the signer cannot write, leaving HEAD unchanged", async () => {
13783
+ t.seed.rejectingHook("rejected: empty git commit");
13784
+ t.seed.failingSigner();
13785
+ const headBefore = t.observe.resolveRef("HEAD");
13786
+ expect(isFailure(await runGitExit(t, commit))).toBe(true);
13787
+ expect(t.observe.resolveRef("HEAD")).toBe(headBefore);
13788
+ });
13789
+ });
13494
13790
  describe("softResetTo", () => {
13495
13791
  it("moves HEAD back but keeps worktree changes from second commit", async () => {
13496
13792
  const firstHash = t.observe.resolveRef("HEAD");
@@ -13538,6 +13834,20 @@ const runGitServiceContract = (makeTier) => {
13538
13834
  it("readRefOption is None for a ref that doesn't exist — never a failure", async () => {
13539
13835
  expect((await runGit(t, (g) => g.readRefOption("refs/gtd/nonexistent")))._tag).toBe("None");
13540
13836
  });
13837
+ it("readRefOption of <hash>^{commit} is Some for an existing commit", async () => {
13838
+ const head = t.observe.resolveRef("HEAD");
13839
+ const resolved = await runGit(t, (g) => g.readRefOption(`${head}^{commit}`));
13840
+ expect(resolved._tag).toBe("Some");
13841
+ if (resolved._tag === "Some") expect(resolved.value).toBe(head);
13842
+ });
13843
+ it("readRefOption of <hash>^{commit} is None for a 40-hex hash with no object", async () => {
13844
+ const missing = "0123456789abcdef0123456789abcdef01234567";
13845
+ expect((await runGit(t, (g) => g.readRefOption(`${missing}^{commit}`)))._tag).toBe("None");
13846
+ });
13847
+ it("readRefOption of a bare 40-hex hash with no object is Some", async () => {
13848
+ const missing = "0123456789abcdef0123456789abcdef01234567";
13849
+ expect((await runGit(t, (g) => g.readRefOption(missing)))._tag).toBe("Some");
13850
+ });
13541
13851
  it("deleteRef removes a ref idempotently (deleting twice is a no-op)", async () => {
13542
13852
  const head = t.observe.resolveRef("HEAD");
13543
13853
  await runGit(t, (g) => g.updateRef("refs/gtd/marker", head));
@@ -13606,6 +13916,71 @@ const runGitServiceContract = (makeTier) => {
13606
13916
  expect(isFailure(await runGitExit(t, (g) => g.readFileAtRef("HEAD", "nonexistent.txt")))).toBe(true);
13607
13917
  });
13608
13918
  });
13919
+ describe("diffPaths", () => {
13920
+ const sorted = (paths) => [...paths].sort();
13921
+ it("reports added, modified and deleted paths between two commits", async () => {
13922
+ t.seed.commit("chore: seed", {
13923
+ "mod.txt": "one",
13924
+ "del.txt": "bye"
13925
+ });
13926
+ const base = t.observe.resolveRef("HEAD");
13927
+ t.seed.writeFile("mod.txt", "two");
13928
+ t.seed.stageAll();
13929
+ t.seed.commitDeletion("del.txt", "chore: delete");
13930
+ t.seed.commit("feat: add", {
13931
+ "add.txt": "new",
13932
+ "mod.txt": "two"
13933
+ });
13934
+ const tip = t.observe.resolveRef("HEAD");
13935
+ const paths = await runGit(t, (g) => g.diffPaths(base, tip));
13936
+ expect(sorted(paths)).toEqual([
13937
+ "add.txt",
13938
+ "del.txt",
13939
+ "mod.txt"
13940
+ ]);
13941
+ });
13942
+ it("reports both paths of a rename", async () => {
13943
+ t.seed.commit("chore: seed", { "old.txt": "same content unique-marker\n" });
13944
+ const base = t.observe.resolveRef("HEAD");
13945
+ t.seed.deleteFile("old.txt");
13946
+ t.seed.commit("refactor: rename", { "new.txt": "same content unique-marker\n" });
13947
+ const tip = t.observe.resolveRef("HEAD");
13948
+ expect(sorted(await runGit(t, (g) => g.diffPaths(base, tip)))).toEqual(["new.txt", "old.txt"]);
13949
+ });
13950
+ it("omits a file changed then restored between the endpoints", async () => {
13951
+ t.seed.commit("chore: seed", { "flip.txt": "one" });
13952
+ const base = t.observe.resolveRef("HEAD");
13953
+ t.seed.commit("chore: change", { "flip.txt": "two" });
13954
+ t.seed.commit("chore: restore", { "flip.txt": "one" });
13955
+ const tip = t.observe.resolveRef("HEAD");
13956
+ expect(await runGit(t, (g) => g.diffPaths(base, tip))).toEqual([]);
13957
+ });
13958
+ it("never reports staged, unstaged or untracked changes", async () => {
13959
+ t.seed.commit("chore: seed", {
13960
+ "a.txt": "a",
13961
+ "b.txt": "b"
13962
+ });
13963
+ const base = t.observe.resolveRef("HEAD");
13964
+ t.seed.writeFile("a.txt", "staged");
13965
+ t.seed.stageAll();
13966
+ t.seed.writeFile("b.txt", "unstaged");
13967
+ t.seed.writeFile("untracked.txt", "u");
13968
+ expect(await runGit(t, (g) => g.diffPaths(base, "HEAD"))).toEqual([]);
13969
+ });
13970
+ it("is [] for diffPaths(x, x)", async () => {
13971
+ const head = t.observe.resolveRef("HEAD");
13972
+ expect(await runGit(t, (g) => g.diffPaths(head, head))).toEqual([]);
13973
+ });
13974
+ for (const { label, path } of PATHOLOGICAL_PATHS) it(`reports a path containing ${label}, verbatim`, async () => {
13975
+ const base = t.observe.resolveRef("HEAD");
13976
+ t.seed.commit("feat: pathological", { [path]: "content" });
13977
+ expect(await runGit(t, (g) => g.diffPaths(base, "HEAD"))).toEqual([path]);
13978
+ });
13979
+ it("fails when either side does not resolve", async () => {
13980
+ expect(isFailure(await runGitExit(t, (g) => g.diffPaths("totally-invalid-ref-xyz", "HEAD")))).toBe(true);
13981
+ expect(isFailure(await runGitExit(t, (g) => g.diffPaths("HEAD", "totally-invalid-ref-xyz")))).toBe(true);
13982
+ });
13983
+ });
13609
13984
  describe("changedPaths", () => {
13610
13985
  it("unions a tracked modification, a tracked deletion, and an untracked addition, deduplicated by path", async () => {
13611
13986
  t.seed.commit("chore: seed", {