@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
@@ -4126,7 +4126,7 @@ var DeferredSymbolKey, DeferredTypeId$1, deferredVariance, pending, done$5, init
4126
4126
  return new SingleShotGen(this.self);
4127
4127
  }
4128
4128
  };
4129
- })), blocked, runRequestBlock, EffectTypeId, RevertFlags, EffectPrimitive, EffectPrimitiveFailure, EffectPrimitiveSuccess, isEffect$1, withFiberRuntime$1, acquireUseRelease$3, as$1, asVoid$1, custom, unsafeAsync, asyncInterrupt, async_, catchAllCause$1, catchAll$1, catchIf, originalSymbol, capture, die$3, dieMessage$1, either$1, exit$2, fail$12, failSync, failCause$6, failCauseSync$1, fiberId, fiberIdWith$1, flatMap$6, andThen$1, step, flatten$6, matchCause$1, matchCauseEffect$2, matchEffect$1, forEachSequential, forEachSequentialDiscard, interrupt$4, interruptWith, interruptible$2, intoDeferred$1, map$8, mapBoth$3, mapError$2, onError$1, onExit$1, onInterrupt, orElse$2, orDie$1, orDieWith, succeed$8, suspend$7, sync$2, tap$1, transplant, attemptOrElse, uninterruptible$1, uninterruptibleMask$2, void_$4, updateRuntimeFlags, whenEffect, whileLoop, fromIterator, gen$1, fnUntraced$1, withRuntimeFlags, yieldNow$2, zip$3, zipLeft$1, zipRight$3, zipWith$1, interruptFiber, interruptAsFiber, logLevelAll, logLevelFatal, logLevelError, logLevelWarning, logLevelInfo, logLevelDebug, logLevelTrace, logLevelNone, FiberRefSymbolKey, FiberRefTypeId, fiberRefVariance, fiberRefGet, fiberRefGetWith, fiberRefSet, fiberRefModify, fiberRefLocally, fiberRefLocallyWith, fiberRefUnsafeMake, fiberRefUnsafeMakeHashSet, fiberRefUnsafeMakeReadonlyArray, fiberRefUnsafeMakeContext, fiberRefUnsafeMakePatch, fiberRefUnsafeMakeRuntimeFlags, currentContext$1, currentSchedulingPriority, currentMaxOpsBeforeYield, currentLogAnnotations, currentLogLevel, currentLogSpan, withSchedulingPriority, currentConcurrency, currentRequestBatching, currentUnhandledErrorLogLevel, currentVersionMismatchErrorLogLevel, currentMetricLabels, currentForkScopeOverride, currentInterruptedCause, ScopeTypeId, CloseableScopeTypeId, scopeAddFinalizer, scopeAddFinalizerExit, scopeClose, scopeFork, causeSquash, causeSquashWith, YieldableError$1, makeException, RuntimeExceptionTypeId, RuntimeException, InterruptedExceptionTypeId, InterruptedException, isInterruptedException, IllegalArgumentExceptionTypeId, IllegalArgumentException$1, NoSuchElementExceptionTypeId, NoSuchElementException$2, TimeoutExceptionTypeId, TimeoutException$1, timeoutExceptionFromDuration, UnknownExceptionTypeId, UnknownException, exitIsExit, exitIsFailure, exitIsSuccess, exitAs, exitAsVoid, exitCollectAll, exitDie$1, exitFail, exitFailCause$1, exitInterrupt$1, exitMap, exitMapBoth, exitMatch, exitMatchEffect, exitSucceed$1, exitVoid$1, exitZip, exitZipRight, exitZipWith, exitCollectAllInternal, deferredUnsafeMake, deferredMake, deferredMakeAs, deferredAwait, deferredComplete, deferredCompleteWith, deferredDone, deferredFail, deferredFailCause, deferredInterrupt, deferredInterruptWith, deferredIsDone, deferredSucceed, deferredUnsafeDone, deferredInterruptJoiner, constContext, context$1, contextWithEffect, provideContext$1, provideSomeContext, mapInputContext, currentSpanFromFiber;
4129
+ })), blocked, runRequestBlock, EffectTypeId, RevertFlags, EffectPrimitive, EffectPrimitiveFailure, EffectPrimitiveSuccess, isEffect$1, withFiberRuntime$1, acquireUseRelease$3, as$1, asVoid$1, custom, unsafeAsync, asyncInterrupt, async_, catchAllCause$1, catchAll$1, catchIf$1, originalSymbol, capture, die$3, dieMessage$1, either$1, exit$2, fail$12, failSync, failCause$6, failCauseSync$1, fiberId, fiberIdWith$1, flatMap$6, andThen$1, step, flatten$6, matchCause$1, matchCauseEffect$2, matchEffect$1, forEachSequential, forEachSequentialDiscard, interrupt$4, interruptWith, interruptible$2, intoDeferred$1, map$8, mapBoth$3, mapError$2, onError$1, onExit$1, onInterrupt, orElse$2, orDie$1, orDieWith, succeed$8, suspend$7, sync$2, tap$1, transplant, attemptOrElse, uninterruptible$1, uninterruptibleMask$2, void_$4, updateRuntimeFlags, whenEffect, whileLoop, fromIterator, gen$1, fnUntraced$1, withRuntimeFlags, yieldNow$2, zip$3, zipLeft$1, zipRight$3, zipWith$1, interruptFiber, interruptAsFiber, logLevelAll, logLevelFatal, logLevelError, logLevelWarning, logLevelInfo, logLevelDebug, logLevelTrace, logLevelNone, FiberRefSymbolKey, FiberRefTypeId, fiberRefVariance, fiberRefGet, fiberRefGetWith, fiberRefSet, fiberRefModify, fiberRefLocally, fiberRefLocallyWith, fiberRefUnsafeMake, fiberRefUnsafeMakeHashSet, fiberRefUnsafeMakeReadonlyArray, fiberRefUnsafeMakeContext, fiberRefUnsafeMakePatch, fiberRefUnsafeMakeRuntimeFlags, currentContext$1, currentSchedulingPriority, currentMaxOpsBeforeYield, currentLogAnnotations, currentLogLevel, currentLogSpan, withSchedulingPriority, currentConcurrency, currentRequestBatching, currentUnhandledErrorLogLevel, currentVersionMismatchErrorLogLevel, currentMetricLabels, currentForkScopeOverride, currentInterruptedCause, ScopeTypeId, CloseableScopeTypeId, scopeAddFinalizer, scopeAddFinalizerExit, scopeClose, scopeFork, causeSquash, causeSquashWith, YieldableError$1, makeException, RuntimeExceptionTypeId, RuntimeException, InterruptedExceptionTypeId, InterruptedException, isInterruptedException, IllegalArgumentExceptionTypeId, IllegalArgumentException$1, NoSuchElementExceptionTypeId, NoSuchElementException$2, TimeoutExceptionTypeId, TimeoutException$1, timeoutExceptionFromDuration, UnknownExceptionTypeId, UnknownException, exitIsExit, exitIsFailure, exitIsSuccess, exitAs, exitAsVoid, exitCollectAll, exitDie$1, exitFail, exitFailCause$1, exitInterrupt$1, exitMap, exitMapBoth, exitMatch, exitMatchEffect, exitSucceed$1, exitVoid$1, exitZip, exitZipRight, exitZipWith, exitCollectAllInternal, deferredUnsafeMake, deferredMake, deferredMakeAs, deferredAwait, deferredComplete, deferredCompleteWith, deferredDone, deferredFail, deferredFailCause, deferredInterrupt, deferredInterruptWith, deferredIsDone, deferredSucceed, deferredUnsafeDone, deferredInterruptJoiner, constContext, context$1, contextWithEffect, provideContext$1, provideSomeContext, mapInputContext, currentSpanFromFiber;
4130
4130
  var init_core = __esmMin((() => {
4131
4131
  init_Array();
4132
4132
  init_Chunk();
@@ -4382,7 +4382,7 @@ var init_core = __esmMin((() => {
4382
4382
  onFailure: f,
4383
4383
  onSuccess: succeed$8
4384
4384
  }));
4385
- catchIf = /*#__PURE__*/ dual(3, (self, predicate, f) => catchAllCause$1(self, (cause) => {
4385
+ catchIf$1 = /*#__PURE__*/ dual(3, (self, predicate, f) => catchAllCause$1(self, (cause) => {
4386
4386
  const either = failureOrCause$1(cause);
4387
4387
  switch (either._tag) {
4388
4388
  case "Left": return predicate(either.left) ? f(either.left) : failCause$6(cause);
@@ -6871,7 +6871,7 @@ var init_core_effect = __esmMin((() => {
6871
6871
  for (let i = 0; i < args.length - 1; i++) if (args[i] === tag) return true;
6872
6872
  return false;
6873
6873
  };
6874
- return catchIf(self, predicate, f);
6874
+ return catchIf$1(self, predicate, f);
6875
6875
  });
6876
6876
  diffFiberRefs = (self) => summarized(self, fiberRefs, diff$2);
6877
6877
  bind = /*#__PURE__*/ bind$1(map$8, flatMap$6);
@@ -11435,7 +11435,7 @@ var init_MutableQueue = __esmMin((() => {
11435
11435
  }));
11436
11436
  //#endregion
11437
11437
  //#region node_modules/effect/dist/esm/Effect.js
11438
- var isEffect, all$1, forEach$2, async, withFiberRuntime, fail$7, failCause$2, die, dieMessage, gen, promise, succeed$3, suspend$5, sync$1, _void, catchAll, catchAllCause, catchTag, ignore, sandbox, retry, try_, tryMap, tryPromise, interrupt, interruptible, uninterruptible, uninterruptibleMask, as, asVoid, map$4, mapBoth$1, mapError$1, negate, acquireRelease, acquireUseRelease$2, addFinalizer, ensuring$2, onError, scope$1, scopeWith, scopedWith$1, scoped$3, fiberIdWith, forkDaemon, forkIn, forkScoped, timeout, provide$1, provideService, serviceOption, either, exit$1, intoDeferred, when, flatMap$4, andThen, flatten$5, race, raceWith, tap, tapErrorCause, repeat, match$2, matchCause, matchCauseEffect, matchEffect, logWarning, logError, annotateLogs, orDie, orElse$1, orElseSucceed, runtime, unsafeMakeSemaphore, makeSemaphore, unsafeMakeLatch, runFork$1, runPromise, runSync, zip$1, zipLeft, zipRight$1, zipWith, fnUntraced;
11438
+ var isEffect, all$1, forEach$2, async, withFiberRuntime, fail$7, failCause$2, die, dieMessage, gen, promise, succeed$3, suspend$5, sync$1, _void, catchAll, catchAllCause, catchIf, catchTag, ignore, sandbox, retry, try_, tryMap, tryPromise, interrupt, interruptible, uninterruptible, uninterruptibleMask, as, asVoid, map$4, mapBoth$1, mapError$1, negate, acquireRelease, acquireUseRelease$2, addFinalizer, ensuring$2, onError, scope$1, scopeWith, scopedWith$1, scoped$3, fiberIdWith, forkDaemon, forkIn, forkScoped, timeout, provide$1, provideService, serviceOption, either, exit$1, intoDeferred, when, flatMap$4, andThen, flatten$5, race, raceWith, tap, tapErrorCause, repeat, match$2, matchCause, matchCauseEffect, matchEffect, logWarning, logError, annotateLogs, orDie, orElse$1, orElseSucceed, runtime, unsafeMakeSemaphore, makeSemaphore, unsafeMakeLatch, runFork$1, runPromise, runSync, zip$1, zipLeft, zipRight$1, zipWith, fnUntraced;
11439
11439
  var init_Effect = __esmMin((() => {
11440
11440
  init_core_effect();
11441
11441
  init_core();
@@ -11461,6 +11461,7 @@ var init_Effect = __esmMin((() => {
11461
11461
  _void = void_$4;
11462
11462
  catchAll = catchAll$1;
11463
11463
  catchAllCause = catchAllCause$1;
11464
+ catchIf = catchIf$1;
11464
11465
  catchTag = catchTag$1;
11465
11466
  ignore = ignore$1;
11466
11467
  sandbox = sandbox$1;
@@ -20267,6 +20268,7 @@ const makeGitImpl = (executor, root) => {
20267
20268
  }))),
20268
20269
  hasCommits: () => exec("git", "rev-parse", "--verify", "HEAD").pipe(map$4(() => true), catchAll(() => succeed$3(false))),
20269
20270
  readFileAtRef: (ref, path) => exec("git", "show", `${ref}:${path}`),
20271
+ 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)),
20270
20272
  changedPaths: (base) => gen(function* () {
20271
20273
  const ref = base ?? "HEAD";
20272
20274
  const nameStatusOut = yield* exec("git", "diff", "--name-status", "-z", ref).pipe(catchAll(() => succeed$3("")));
@@ -20943,6 +20945,8 @@ const HISTORY_REF = "refs/worktree/gtd/history";
20943
20945
  /** One ref per finished process, kept past the branch's deletion; distinct from the per-worktree `HISTORY_REF`. */
20944
20946
  const PROCESS_HISTORY_PREFIX = "refs/gtd/history/";
20945
20947
  const processHistoryRef = (hash) => `${PROCESS_HISTORY_PREFIX}${hash}`;
20948
+ /** 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. */
20949
+ const keptRef = (entry, step) => `refs/gtd/kept/${entry}/${step}`;
20946
20950
  const readRetainedHistory = (git) => git.readRefOption(HISTORY_REF);
20947
20951
  /**
20948
20952
  * Whether resetting to `tipHash` is safe: HEAD must be an ancestor of
@@ -21715,6 +21719,19 @@ const changesSince = (hash, pattern) => {
21715
21719
  const all = context.changesSince(hash);
21716
21720
  return toChanges(pattern === void 0 ? all : all.filter((c) => context.matches(c.path, pattern)));
21717
21721
  };
21722
+ /**
21723
+ * The latest kept tip of step `name` (scoped from here, same as `agent()`) in
21724
+ * this episode, or `undefined` when none.
21725
+ */
21726
+ const kept = (name) => {
21727
+ const context = ctx();
21728
+ if (typeof context.kept !== "function") throw new Error("gtd: kept() needs a newer gtd — upgrade the gtd that runs it");
21729
+ const tip = context.kept(name);
21730
+ return tip === void 0 ? void 0 : {
21731
+ hash: tip.hash,
21732
+ changes: toChanges(tip.changes)
21733
+ };
21734
+ };
21718
21735
  /** The commit the process stands on at this point of the flow. */
21719
21736
  const head = () => ctx().head();
21720
21737
  /** The process's diff base: the commit before it began, or the base `gtd --workflow` fixed. */
@@ -22108,6 +22125,7 @@ var flows_exports = /* @__PURE__ */ __exportAll({
22108
22125
  installContext: () => installContext,
22109
22126
  isFactoryWorkflow: () => isFactoryWorkflow,
22110
22127
  judge: () => judge,
22128
+ kept: () => kept,
22111
22129
  moveScript: () => moveScript,
22112
22130
  numeric: () => numeric,
22113
22131
  openQuestions: () => openQuestions,
@@ -37470,7 +37488,7 @@ var init_Glob = __esmMin((() => {
37470
37488
  i += 2;
37471
37489
  }
37472
37490
  }
37473
- return new RegExp(`${pattern}$`);
37491
+ return new RegExp(`${pattern}$`, "s");
37474
37492
  };
37475
37493
  globMatches = (path, glob) => compile(glob).test(path);
37476
37494
  }));
@@ -37491,8 +37509,24 @@ var init_wire = __esmMin((() => {
37491
37509
  init_Glob();
37492
37510
  }));
37493
37511
  //#endregion
37494
- //#region src/replay/Tree.ts
37512
+ //#region src/replay/RefName.ts
37495
37513
  init_wire();
37514
+ const FORBIDDEN = /[\u0000- \u007f~^:?*[\\]/;
37515
+ /** Why `name` cannot be (part of) a git ref name, or `undefined` when it can. */
37516
+ const refNameFault = (name) => {
37517
+ if (name === "") return "it is empty";
37518
+ if (FORBIDDEN.test(name)) return "it contains a space, control character or one of ~^:?*[\\";
37519
+ if (name.includes("..")) return "it contains \"..\"";
37520
+ if (name.includes("@{")) return "it contains \"@{\"";
37521
+ if (name.endsWith(".")) return "it ends with \".\"";
37522
+ for (const part of name.split("/")) {
37523
+ if (part === "") return "it has an empty \"/\" component";
37524
+ if (part.startsWith(".")) return "a component starts with \".\"";
37525
+ if (part.endsWith(".lock")) return "a component ends with \".lock\"";
37526
+ }
37527
+ };
37528
+ //#endregion
37529
+ //#region src/replay/Tree.ts
37496
37530
  const treeFromRecord = (files) => ({
37497
37531
  paths: () => Object.keys(files).sort(),
37498
37532
  read: (path) => Object.hasOwn(files, path) ? files[path] : void 0
@@ -37586,6 +37620,7 @@ const decodePath = (raw) => {
37586
37620
  return typeof path === "string" && path !== "" && !CONTROL_CHARACTER$1.test(path) ? path : void 0;
37587
37621
  };
37588
37622
  const FORMAT_RE = /^[1-9][0-9]*$/;
37623
+ const HASH_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
37589
37624
  const TRAILER_READERS = {
37590
37625
  "Gtd-Step": (value, into) => {
37591
37626
  into.step ??= parseStepId(value);
@@ -37615,6 +37650,9 @@ const TRAILER_READERS = {
37615
37650
  "Gtd-Version": (value, into) => {
37616
37651
  if (value !== "") into.version ??= value;
37617
37652
  },
37653
+ "Gtd-Kept": (value, into) => {
37654
+ if (HASH_RE.test(value)) into.kept ??= value;
37655
+ },
37618
37656
  "Gtd-Format": (value, into) => {
37619
37657
  if (FORMAT_RE.test(value)) into.format ??= Number(value);
37620
37658
  },
@@ -37635,6 +37673,7 @@ const parseCommitMessage = (message) => {
37635
37673
  reviewBase: void 0,
37636
37674
  branch: void 0,
37637
37675
  version: void 0,
37676
+ kept: void 0,
37638
37677
  judge: [],
37639
37678
  vars: {},
37640
37679
  config: {},
@@ -37749,7 +37788,8 @@ const KNOWN_OPTIONS = {
37749
37788
  ...STEERING_OPTIONS,
37750
37789
  "model",
37751
37790
  "system",
37752
- "allowEmpty"
37791
+ "allowEmpty",
37792
+ "keep"
37753
37793
  ]),
37754
37794
  human: /* @__PURE__ */ new Set([
37755
37795
  ...STEERING_OPTIONS,
@@ -37781,6 +37821,7 @@ const replay = async (input) => {
37781
37821
  const scopeCallIds = [];
37782
37822
  let scopeCallCount = 0;
37783
37823
  const trace = [];
37824
+ const keptTips = /* @__PURE__ */ new Map();
37784
37825
  let landed;
37785
37826
  let outcome;
37786
37827
  let signal;
@@ -37940,6 +37981,12 @@ const replay = async (input) => {
37940
37981
  }
37941
37982
  cursor++;
37942
37983
  advance(commit);
37984
+ if (commit.parsed.kept !== void 0) if (commit.kept === void 0) keptTips.delete(step.name);
37985
+ else keptTips.set(step.name, {
37986
+ hash: commit.kept.hash,
37987
+ before: commit.tree,
37988
+ after: commit.kept.tree
37989
+ });
37943
37990
  const to = commit.parsed.parsed?.to;
37944
37991
  if (to !== void 0) expectedNext = {
37945
37992
  name: to,
@@ -37947,6 +37994,21 @@ const replay = async (input) => {
37947
37994
  };
37948
37995
  return replyFor(step.request, commit.parsed.judge);
37949
37996
  };
37997
+ const landPending = (step, pending) => {
37998
+ pendingUsed = true;
37999
+ landed = step;
38000
+ const keeping = step.request.kind === "agent" && step.request.options.keep === true;
38001
+ if (keeping) keptTips.set(step.name, {
38002
+ hash: "",
38003
+ before: position.tree,
38004
+ after: pending.tree
38005
+ });
38006
+ advance({
38007
+ hash: "",
38008
+ tree: keeping ? position.tree : pending.tree
38009
+ });
38010
+ return replyFor(step.request, pending.verdicts ?? []);
38011
+ };
37950
38012
  const answer = (step) => {
37951
38013
  while (cursor < commits.length && isAttemptAt(commits[cursor], step.name)) cursor++;
37952
38014
  const commit = commits[cursor];
@@ -37957,17 +38019,18 @@ const replay = async (input) => {
37957
38019
  trace,
37958
38020
  landed
37959
38021
  });
37960
- pendingUsed = true;
37961
- landed = step;
37962
- advance({
37963
- hash: "",
37964
- tree: input.pending.tree
37965
- });
37966
- return replyFor(step.request, input.pending.verdicts ?? []);
38022
+ return landPending(step, input.pending);
37967
38023
  };
37968
38024
  const requestFault = (request) => {
37969
38025
  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)`;
37970
- return request.kind === "agent" ? scopeAccessFault() : void 0;
38026
+ if (request.kind !== "agent") return void 0;
38027
+ const name = scoped(request.name);
38028
+ if (request.options.keep === true) {
38029
+ 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`;
38030
+ const refFault = name.includes("/") ? "it contains \"/\"" : refNameFault(name);
38031
+ if (refFault !== void 0) return `gtd: step "${name}": keep: true files the turn under a git ref named after the step, but ${refFault}`;
38032
+ }
38033
+ return scopeAccessFault();
37971
38034
  };
37972
38035
  const handleStep = async (request) => {
37973
38036
  if (outcome !== void 0) throw new Stop();
@@ -38050,6 +38113,13 @@ const replay = async (input) => {
38050
38113
  faults: findings.filter((f) => f.message.startsWith(`Code thread at ${c.path}:${t.line + 1}: `)).map((f) => f.message)
38051
38114
  }));
38052
38115
  }),
38116
+ kept: (name) => {
38117
+ const tip = keptTips.get(scoped(name));
38118
+ return tip === void 0 ? void 0 : {
38119
+ hash: tip.hash,
38120
+ changes: changesBetween(tip.before, tip.after)
38121
+ };
38122
+ },
38053
38123
  vars: input.vars,
38054
38124
  env: input.env,
38055
38125
  start: () => input.start,
@@ -39019,7 +39089,11 @@ const planStep = (snapshot, opts = {}) => {
39019
39089
  ...uncheckStep(snapshot),
39020
39090
  {
39021
39091
  kind: "gitWrite",
39022
- write: {
39092
+ write: landing.keepTurn !== void 0 ? {
39093
+ kind: "keepTurn",
39094
+ ref: landing.keepTurn,
39095
+ message
39096
+ } : {
39023
39097
  kind: "commitAll",
39024
39098
  message
39025
39099
  }
@@ -39137,6 +39211,11 @@ const resolveBase = (commandLabel, value) => gen(function* () {
39137
39211
  return mergeBase.value;
39138
39212
  });
39139
39213
  //#endregion
39214
+ //#region src/step/strayPaths.ts
39215
+ init_wire();
39216
+ /** Globs use the `access.write` dialect (`globMatches`). */
39217
+ const strayPaths = (touched, allow) => touched.filter((path) => !allow.some((glob) => globMatches(path, glob))).sort();
39218
+ //#endregion
39140
39219
  //#region src/edge/Edge.ts
39141
39220
  init_esm();
39142
39221
  init_steering();
@@ -39499,6 +39578,7 @@ const promptDef = (common, request, skills, access) => ({
39499
39578
  ...optional$1("model", request.options.model),
39500
39579
  ...optional$1("system", request.options.system),
39501
39580
  ...optional$1("allowEmpty", request.options.allowEmpty),
39581
+ ...optional$1("keep", request.options.keep),
39502
39582
  ...optional$1("skills", skills),
39503
39583
  ...optional$1("access", access)
39504
39584
  });
@@ -39613,11 +39693,21 @@ const setupFor = (config, run, workspace, host, startOverrides = {}) => gen(func
39613
39693
  hash: run.episode.base,
39614
39694
  tree: commitTree(workspace, run.episode.base)
39615
39695
  };
39616
- const episode = run.episode.commits.map((c) => ({
39617
- hash: c.hash,
39618
- message: c.message,
39619
- tree: commitTree(workspace, c.hash)
39620
- }));
39696
+ const git = yield* GitService;
39697
+ const episode = [];
39698
+ for (const c of run.episode.commits) {
39699
+ const tip = parseCommitMessage(c.message).kept;
39700
+ const exists = tip !== void 0 && isSome(yield* git.readRefOption(`${tip}^{commit}`));
39701
+ episode.push({
39702
+ hash: c.hash,
39703
+ message: c.message,
39704
+ tree: commitTree(workspace, c.hash),
39705
+ ...exists ? { kept: {
39706
+ hash: tip,
39707
+ tree: commitTree(workspace, tip)
39708
+ } } : {}
39709
+ });
39710
+ }
39621
39711
  let pendingMemo;
39622
39712
  return {
39623
39713
  resolved,
@@ -39793,6 +39883,7 @@ const closingTrailers = (branch) => ({
39793
39883
  ...isSome(branch) ? { branch: branch.value } : {}
39794
39884
  });
39795
39885
  const isSettledNoop = (rest, to) => rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state;
39886
+ const keptTurn = (rest) => rest.stepDef.keep === true && rest.changes.length > 0 ? { keepTurn: keptRef(rest.run.startHash, rest.state) } : {};
39796
39887
  const landingTo = (rest, to, branch) => {
39797
39888
  if (isSettledNoop(rest, to)) return {
39798
39889
  kind: "noop",
@@ -39817,7 +39908,8 @@ const landingTo = (rest, to, branch) => {
39817
39908
  } : {},
39818
39909
  ...finishes ? closingTrailers(branch) : {}
39819
39910
  },
39820
- ...finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {}
39911
+ ...finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {},
39912
+ ...keptTurn(rest)
39821
39913
  };
39822
39914
  };
39823
39915
  /**
@@ -39954,7 +40046,7 @@ const commitAllowEmpty = (message) => {
39954
40046
  return [
39955
40047
  `if ! out=$(git commit --allow-empty -m ${m} 2>&1); then`,
39956
40048
  ` case "$out" in`,
39957
- ` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m} ;;`,
40049
+ ` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m} || exit 1 ;;`,
39958
40050
  ` *) printf '%s\\n' "$out" >&2; exit 1 ;;`,
39959
40051
  ` esac`,
39960
40052
  `fi`
@@ -39964,6 +40056,36 @@ const commitAllowEmpty = (message) => {
39964
40056
  const commitAll = (message) => `git add -A || exit 1\n${commitAllowEmpty(message)}`;
39965
40057
  const mixedResetTo = (ref) => `git reset --mixed ${shellQuote$1(ref)}`;
39966
40058
  const hardResetTo = (ref) => `git reset --hard ${shellQuote$1(ref)}`;
40059
+ /**
40060
+ * POSIX sh (runs under dash). The marker is a `commit-tree` over HEAD's own
40061
+ * tree, so no hook runs on it and its tree is the pre-turn tree by
40062
+ * construction; `commit-tree` ignores `commit.gpgSign`, hence the explicit
40063
+ * `-S`, read (and failing closed) before any object or ref is written; the
40064
+ * index is already staged by then, so an early exit leaves the turn staged.
40065
+ * The kept ref is deleted only after `reset -q` succeeds: it is then the
40066
+ * turn's only handle.
40067
+ * The branch moves before the final hard reset, so if that reset fails the
40068
+ * marker has landed and the turn is still in the working tree (kept ref
40069
+ * present); every other failure leaves the branch untouched.
40070
+ */
40071
+ const keepTurn = (refPrefix, message) => {
40072
+ const keptRef = `${shellQuote$1(`${refPrefix}/`)}"$gtd_kept"`;
40073
+ return [
40074
+ `git add -A || exit 1`,
40075
+ `gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
40076
+ `if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
40077
+ `gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
40078
+ `gtd_tree=$(git write-tree) || exit 1`,
40079
+ `gtd_kept=$(git commit-tree "$gtd_tree" -p "$gtd_head" -m ${shellQuote$1(message)}) || exit 1`,
40080
+ `git update-ref ${keptRef} "$gtd_kept" || exit 1`,
40081
+ `gtd_marker=$(printf '%s\\nGtd-Kept: %s' ${shellQuote$1(message)} "$gtd_kept")`,
40082
+ `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`,
40083
+ ` git reset -q && git update-ref -d ${keptRef}`,
40084
+ ` exit 1`,
40085
+ `fi`,
40086
+ `git reset --hard HEAD`
40087
+ ].join("\n");
40088
+ };
39967
40089
  const updateRef = (ref, hash) => `git update-ref ${shellQuote$1(ref)} ${shellQuote$1(hash)}`;
39968
40090
  /** `git update-ref -d <ref>` — idempotent: deleting a missing ref is already a no-op in real git. */
39969
40091
  const deleteRef = (ref) => `git update-ref -d ${shellQuote$1(ref)}`;
@@ -39996,6 +40118,7 @@ const renderGitWrite = (write) => {
39996
40118
  switch (write.kind) {
39997
40119
  case "commitAll": return commitAll(write.message);
39998
40120
  case "updateRef": return updateRef(write.ref, write.target);
40121
+ case "keepTurn": return keepTurn(write.ref, write.message);
39999
40122
  }
40000
40123
  };
40001
40124
  /**
@@ -40386,6 +40509,26 @@ const writeSelection = (out, fields, path) => gen(function* () {
40386
40509
  else if (selection.kind === "unknown") return yield* fail$7(new SelectorUsageError(`gtd: unknown --json selector "${selection.path}" — see \`gtd --help\``));
40387
40510
  });
40388
40511
  //#endregion
40512
+ //#region src/cli/Strays.ts
40513
+ init_esm();
40514
+ init_wire();
40515
+ const resolveSide = (git, side) => git.resolveRef(`${side}^{commit}`).pipe(mapError$1(() => new GtdError(`gtd strays: unknown revision '${side}'`)));
40516
+ /** 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. */
40517
+ const runStraysCommand = (command, json, out) => gen(function* () {
40518
+ const git = yield* GitService;
40519
+ const narrator = yield* Narrator;
40520
+ const base = yield* resolveSide(git, command.base);
40521
+ const tip = yield* resolveSide(git, command.tip);
40522
+ const touched = yield* git.diffPaths(base, tip);
40523
+ const strays = strayPaths(touched, command.globs);
40524
+ const total = touched.length;
40525
+ if (json.kind === "select") yield* writeSelection(out, strays, json.path).pipe(catchIf((error) => error instanceof SelectorUsageError && /^\d+$/.test(json.path), () => _void));
40526
+ else out.write(`${JSON.stringify(strays)}\n`);
40527
+ for (const path of strays) yield* narrator.narrate(`stray: ${JSON.stringify(path)}`);
40528
+ 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`);
40529
+ return strays.length === 0 ? 0 : 10;
40530
+ });
40531
+ //#endregion
40389
40532
  //#region src/cli/History.ts
40390
40533
  init_esm();
40391
40534
  /** Newest tip first. */
@@ -41046,7 +41189,8 @@ const needsOf = (kind) => {
41046
41189
  case "lsp":
41047
41190
  case "install":
41048
41191
  case "loop":
41049
- case "judgeRun": return "none";
41192
+ case "judgeRun":
41193
+ case "strays": return "none";
41050
41194
  case "check":
41051
41195
  case "uncheck": return "fs";
41052
41196
  default: return "state";
@@ -41055,10 +41199,13 @@ const needsOf = (kind) => {
41055
41199
  /**
41056
41200
  * Dispatches to the named `run*Command` handler for every `Command.kind` —
41057
41201
  * the counterpart of `Cli.ts`'s `parseArgv`, which has already validated
41058
- * every field a handler receives. A command choosing its own exit code is
41059
- * unrepresentable: every branch returns `Effect<void>`, and `Cli.ts`'s
41060
- * `runCli` supplies `EXIT_OK` once, after this Effect succeeds.
41202
+ * every field a handler receives. Only `strays` chooses its exit code; every
41203
+ * other branch returns `Effect<void>` and resolves to `EXIT_OK`.
41061
41204
  */
41205
+ const dispatchCommand = (command, json, out) => {
41206
+ if (command.kind === "strays") return runStraysCommand(command, json, out);
41207
+ return as(dispatchVoidCommand(command, json, out), 0);
41208
+ };
41062
41209
  const dispatchVoidCommand = (command, json, out) => {
41063
41210
  switch (command.kind) {
41064
41211
  case "lsp": return runLspCommand();
@@ -41098,7 +41245,7 @@ const dispatchVoidCommand = (command, json, out) => {
41098
41245
  * so a refusal writes nothing and emits no script by construction.
41099
41246
  */
41100
41247
  const runCommand = (command, json, out) => {
41101
- const dispatch = dispatchVoidCommand(command, json, out);
41248
+ const dispatch = dispatchCommand(command, json, out);
41102
41249
  if (needsOf(command.kind) !== "state") return dispatch;
41103
41250
  return gen(function* () {
41104
41251
  const git = yield* GitService;
@@ -41111,7 +41258,7 @@ const runCommand = (command, json, out) => {
41111
41258
  warn: () => _void
41112
41259
  }));
41113
41260
  for (const warning of config.warnings) yield* narrator.warn(`gtd: warning: ${formatDiagnostic(warning)}`);
41114
- yield* dispatch;
41261
+ return yield* dispatch;
41115
41262
  });
41116
41263
  };
41117
41264
  //#endregion
@@ -41128,13 +41275,13 @@ const FLAGS = [
41128
41275
  name: "--json",
41129
41276
  arity: "optional",
41130
41277
  repeatable: false,
41131
- scope: (kind) => kind === "next" || kind === "land" || kind === "judge" || kind === "judgeAnswer" || kind === "doors" || kind === "historyList" || kind === "historyShow",
41278
+ scope: (kind) => kind === "next" || kind === "land" || kind === "judge" || kind === "judgeAnswer" || kind === "doors" || kind === "historyList" || kind === "historyShow" || kind === "strays",
41132
41279
  decode: ([raw]) => right(raw ?? ""),
41133
- 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",
41280
+ 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",
41134
41281
  valueHint: "<path>",
41135
41282
  help: [
41136
41283
  "(gtd next/gtd land/gtd judge/gtd judge answer/gtd doors/gtd history",
41137
- "list/gtd history show only) output structured JSON. Bare --json prints the whole document;",
41284
+ "list/gtd history show/gtd strays only) output structured JSON. Bare --json prints the whole document;",
41138
41285
  "--json=<path> (a dotted key path into that document, e.g.",
41139
41286
  "kind, content, session.id) prints just that value: a",
41140
41287
  "scalar raw and unquoted, a boolean as true/false, a list",
@@ -41643,6 +41790,28 @@ const COMMAND_ROWS = [
41643
41790
  "every question, with nothing on stdout"
41644
41791
  ]
41645
41792
  },
41793
+ {
41794
+ token: "strays",
41795
+ kind: "strays",
41796
+ arity: {
41797
+ name: "range",
41798
+ variadic: "glob"
41799
+ },
41800
+ details: [
41801
+ "List the files <range> (<base>..<tip>, an empty side is HEAD)",
41802
+ "touched in its committed net diff that match none of the",
41803
+ "globs. Anchored at the repo root: * stays within one path",
41804
+ "segment, ** crosses them, everything else is literal — quote",
41805
+ "globs in the shell. stdout: a sorted JSON array, [] when",
41806
+ "clean; stderr: a summary, plus one stray: line per path with",
41807
+ "--verbose. --json=<n> picks one entry, nothing past the end;",
41808
+ "a symlink is a path like any other, never followed; a",
41809
+ "submodule counts as its path, whatever diff.ignoreSubmodules",
41810
+ "or .gitmodules ignore say. Runs in any git repository, no",
41811
+ "process needed. Exits 10 when",
41812
+ "strays were found, 1 on an unknown revision, 2 on a bad range"
41813
+ ]
41814
+ },
41646
41815
  {
41647
41816
  token: "history list",
41648
41817
  kind: "historyList",
@@ -41985,6 +42154,22 @@ const parseArgv = (argv) => {
41985
42154
  json,
41986
42155
  verbose
41987
42156
  };
42157
+ if (kind === "strays") {
42158
+ const [range, ...globs] = restPositionals;
42159
+ const at = range.indexOf("..");
42160
+ 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);
42161
+ return {
42162
+ kind: "command",
42163
+ command: {
42164
+ kind: "strays",
42165
+ base: range.slice(0, at) || "HEAD",
42166
+ tip: range.slice(at + 2) || "HEAD",
42167
+ globs
42168
+ },
42169
+ json,
42170
+ verbose
42171
+ };
42172
+ }
41988
42173
  if (kind === "uncheck") return {
41989
42174
  kind: "command",
41990
42175
  command: {
@@ -42076,9 +42261,9 @@ const runCli = (argv, io) => {
42076
42261
  return flatMap$4(Host, ({ env }) => try_({
42077
42262
  try: () => resolveFlags(env, io.flags),
42078
42263
  catch: (e) => new GtdError(e.message)
42079
- })).pipe(flatMap$4(() => runCommand(plan.command, plan.json, out)), map$4(() => {
42264
+ })).pipe(flatMap$4(() => runCommand(plan.command, plan.json, out)), map$4((code) => {
42080
42265
  out.flush();
42081
- io.exit(0);
42266
+ io.exit(code);
42082
42267
  }), provide$1(io.layers(plan.verbose)), sandbox, catchAll((cause) => isInterruptedOnly(cause) ? failCause$2(cause) : report(io, plan.json.kind !== "off")(cause)));
42083
42268
  };
42084
42269
  //#endregion