@pmelab/gtd 64.3.0 → 66.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 (91) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +35 -25
  3. package/claude/hooks/assist.ts +9 -35
  4. package/claude/hooks/doors.ts +4 -4
  5. package/claude/hooks/drive.ts +10 -1
  6. package/claude/hooks/git.ts +56 -0
  7. package/claude/hooks/handoff.ts +11 -11
  8. package/claude/hooks/jobs.ts +8 -8
  9. package/claude/hooks/pr.ts +86 -26
  10. package/claude/hooks/register.tsx +35 -82
  11. package/claude/hooks/tools.ts +7 -20
  12. package/claude/hooks/wording.ts +3 -3
  13. package/dist/chunks/{flows-dnIE_qbe.mjs → flows-CmQplvh1.mjs} +146 -35
  14. package/dist/chunks/{steering-DEh_MS40.mjs → steering-CMrePUiY.mjs} +115 -17
  15. package/dist/chunks/{testkit-BUX-47TD.mjs → testkit-BJhAAVGt.mjs} +905 -99
  16. package/dist/chunks/{wire-CaPJ9Tme.mjs → wire-xTbzFc26.mjs} +12 -6
  17. package/dist/flows.mjs +1 -1
  18. package/dist/gtd.bundle.mjs +965 -163
  19. package/dist/gtd.core.mjs +150 -134
  20. package/dist/steering.mjs +1 -1
  21. package/dist/testkit/gherkin.mjs +1 -1
  22. package/dist/testkit.mjs +2 -2
  23. package/dist/web/main.css +1 -1
  24. package/dist/web/main.js +185 -18
  25. package/dist/wire.mjs +1 -1
  26. package/dist/workflow-kit.mjs +9 -3
  27. package/package.json +1 -1
  28. package/src/cli/Cli.ts +5 -5
  29. package/src/cli/History.test.ts +10 -8
  30. package/src/cli/Metrics.test.ts +2 -1
  31. package/src/cli/Program.test.ts +4 -2
  32. package/src/cli/Program.ts +105 -7
  33. package/src/edge/Edge.test.ts +37 -16
  34. package/src/edge/Edge.ts +380 -33
  35. package/src/emit/GitScript.ts +116 -15
  36. package/src/emit/index.ts +7 -0
  37. package/src/flows/config.ts +7 -1
  38. package/src/flows/helpers.ts +36 -18
  39. package/src/flows/index.test.ts +4 -0
  40. package/src/flows/index.ts +7 -0
  41. package/src/flows/runtime.ts +153 -15
  42. package/src/install/Install.ts +9 -2
  43. package/src/platform/Git.test.ts +7 -11
  44. package/src/platform/NodeSync.ts +2 -0
  45. package/src/platform/RetainedHistory.ts +3 -0
  46. package/src/platform/SyncIo.ts +2 -0
  47. package/src/platform/Workspace.test.ts +37 -0
  48. package/src/platform/Workspace.ts +36 -0
  49. package/src/platform/index.ts +1 -0
  50. package/src/replay/Replay.test.ts +9 -0
  51. package/src/replay/Replay.ts +92 -16
  52. package/src/replay/Trailers.ts +17 -0
  53. package/src/replay/Tree.ts +5 -0
  54. package/src/replay/index.ts +1 -1
  55. package/src/steering/FORMATS.md +3 -1
  56. package/src/steering/SteeringFormat.ts +13 -0
  57. package/src/steering/fixtures/review/sectioned.md +1 -1
  58. package/src/steering/fixtures/review/sectioned.read.json +0 -1
  59. package/src/steering/index.ts +1 -0
  60. package/src/steering/review.test.ts +181 -5
  61. package/src/steering/review.ts +160 -18
  62. package/src/step/LandStep.ts +12 -0
  63. package/src/step/RepoSnapshot.ts +15 -0
  64. package/src/step/index.ts +1 -0
  65. package/src/step/planStart.test.ts +4 -3
  66. package/src/step/planStart.ts +7 -0
  67. package/src/step/planStep.ts +22 -17
  68. package/src/step/startBranch.ts +54 -0
  69. package/src/twins/EmittedScriptRecognizer.ts +118 -1
  70. package/src/twins/EmittedScripts.test.ts +147 -1
  71. package/src/twins/InMemRepo.ts +140 -3
  72. package/src/twins/Layers.ts +1 -0
  73. package/src/twins/SyncIo.test.ts +19 -4
  74. package/src/twins/SyncIo.ts +13 -1
  75. package/src/wire/BeatDocument.test.ts +15 -0
  76. package/src/wire/BeatDocument.ts +8 -2
  77. package/src/wire/Demand.ts +26 -12
  78. package/src/wire/constants.ts +3 -0
  79. package/src/wire/golden/1/beat-capture.json +1 -0
  80. package/src/wire/golden/1/beat-message.json +1 -0
  81. package/src/wire/golden/1/beat-prompt.json +1 -0
  82. package/src/wire/golden/1/beat-script.json +1 -0
  83. package/src/wire/golden/1/beat-stalled.json +1 -0
  84. package/src/wire/types.ts +4 -0
  85. package/src/workflow/Workflow.ts +3 -0
  86. package/src/workflow/compile.ts +2 -0
  87. package/src/workflow/load.ts +2 -0
  88. package/src/workflow-kit/index.test.ts +1 -0
  89. package/src/workflow-kit/index.ts +1 -0
  90. package/src/workflow-kit/scenarios.ts +8 -0
  91. package/claude/hooks/ship.ts +0 -730
@@ -1,6 +1,6 @@
1
- import { C as moveScript, D as revertScript, E as restoreScript, H as installContext, K as markedOptions, O as showScript, S as checkScript, T as removeScript, Y as protocolMismatch, i as configure, k as writeScript$1, n as configProblems, o as isFactoryWorkflow, p as numeric, s as answered, t as flows_exports, x as applyScript } from "./flows-dnIE_qbe.mjs";
2
- import { M as renderFormat, O as ACCESS_REFUSAL, a as renderScriptJson, d as renderLandPlain, f as DEMAND_BRIEFING, g as statusOf, h as renderBeatJson, i as judgeJson, k as PIN_PATH, l as noopText, m as beatDocument, n as resolveSession, o as scriptFields, p as renderBeatPlain, r as selectPath, s as landFields, t as globMatches, u as renderLandJson, v as beatKindOf, y as demandOf } from "./wire-CaPJ9Tme.mjs";
3
- import { F as headingSectionBodies, I as formatFinding, a as sections, c as openThreads, d as formatFromSpec, i as sectionBodies, l as steeringFormatFor, o as threads, r as openQuestions, s as BUILT_IN_MODE_NAMES, t as parseCodeThreads, u as unansweredQuestions } from "./steering-DEh_MS40.mjs";
1
+ import { A as revertScript, E as moveScript, G as installContext, M as writeScript$1, O as removeScript, Q as protocolMismatch, T as checkScript, Y as markedOptions, i as configure, j as showScript, k as restoreScript, n as configProblems, o as isFactoryWorkflow, p as numeric, s as answered, t as flows_exports, w as applyScript } from "./flows-CmQplvh1.mjs";
2
+ import { A as RUN_FAILED_PATH, N as renderFormat, O as ACCESS_REFUSAL, a as renderScriptJson, d as renderLandPlain, f as DEMAND_BRIEFING, g as statusOf, h as renderBeatJson, i as judgeJson, k as PIN_PATH, l as noopText, m as beatDocument, n as resolveSession, o as scriptFields, p as renderBeatPlain, r as selectPath, s as landFields, t as globMatches, u as renderLandJson, v as beatKindOf, y as demandOf } from "./wire-xTbzFc26.mjs";
3
+ import { F as headingSectionBodies, I as formatFinding, a as sections, c as openThreads, d as formatFromSpec, i as sectionBodies, l as steeringFormatFor, o as threads, r as openQuestions, s as BUILT_IN_MODE_NAMES, t as parseCodeThreads, u as unansweredQuestions } from "./steering-CMrePUiY.mjs";
4
4
  import { createRequire, isBuiltin } from "node:module";
5
5
  import * as Crypto from "node:crypto";
6
6
  import { createHash } from "node:crypto";
@@ -3306,6 +3306,44 @@ const fromNullishOr = (a) => a == null ? none() : some(a);
3306
3306
  */
3307
3307
  const getOrUndefined$1 = /*#__PURE__*/ getOrElse(constUndefined);
3308
3308
  /**
3309
+ * Extracts the value from a `Some`, or throws a default `Error` for `None`.
3310
+ *
3311
+ * **When to use**
3312
+ *
3313
+ * Use when you need quick fail-fast unwrapping of an `Option` and a generic
3314
+ * error is acceptable.
3315
+ *
3316
+ * **Details**
3317
+ *
3318
+ * - `Some` → returns the inner value
3319
+ * - `None` → throws `new Error("getOrThrow called on a None")`
3320
+ *
3321
+ * **Example** (Throwing a default error)
3322
+ *
3323
+ * ```ts import.meta.vitest
3324
+ * import { Option, Result } from "effect"
3325
+ *
3326
+ * Option.getOrThrow(Option.some(1)) // => 1
3327
+ *
3328
+ * const failure = Result.try({
3329
+ * try: () => Option.getOrThrow(Option.none()),
3330
+ * catch: (error) => (error as Error).message
3331
+ * })
3332
+ * Result.getFailure(failure).pipe(Option.getOrElse(() => "no error")) // => "getOrThrow called on a None"
3333
+ * ```
3334
+ *
3335
+ * @see {@link getOrThrowWith} for a custom error
3336
+ * @see {@link getOrElse} for a non-throwing alternative
3337
+ *
3338
+ * @stability stable
3339
+ * @category converting
3340
+ * @since 2.0.0
3341
+ */
3342
+ const getOrThrow$1 = /*#__PURE__*/ (/* @__PURE__ */ dual(2, (self, onNone) => {
3343
+ if (isSome(self)) return self.value;
3344
+ throw onNone();
3345
+ }))(() => /* @__PURE__ */ new Error("getOrThrow called on a None"));
3346
+ /**
3309
3347
  * Transforms the value inside a `Some` using the provided function, leaving
3310
3348
  * `None` unchanged.
3311
3349
  *
@@ -20316,7 +20354,8 @@ const orAbsent = (read) => {
20316
20354
  };
20317
20355
  const infoOf = (stats) => ({
20318
20356
  type: stats.isSymbolicLink() ? "symlink" : stats.isDirectory() ? "directory" : stats.isFile() ? "file" : "other",
20319
- uid: process.platform === "win32" ? void 0 : stats.uid
20357
+ uid: process.platform === "win32" ? void 0 : stats.uid,
20358
+ ...process.platform === "win32" ? {} : { executable: (stats.mode & 64) !== 0 }
20320
20359
  });
20321
20360
  const nodeSyncIo = {
20322
20361
  readFile: (path) => orAbsent(() => readFileSync(path)),
@@ -20786,6 +20825,15 @@ const applyRawDiff = (tree, entries) => {
20786
20825
  tree.set(entry.path, entry.newId);
20787
20826
  }
20788
20827
  };
20828
+ /** `git ls-tree -r -z --full-tree <ref>`'s output, parsed into `path → mode`. */
20829
+ const parseLsTreeModes = (out) => {
20830
+ const modes = /* @__PURE__ */ new Map();
20831
+ for (const record of out.split("\0")) {
20832
+ const tab = record.indexOf(" ");
20833
+ if (tab !== -1) modes.set(record.slice(tab + 1), record.slice(0, tab).split(" ")[0]);
20834
+ }
20835
+ return modes;
20836
+ };
20789
20837
  /** `git ls-tree -r -z --full-tree <ref>`'s output, parsed into `path → blob id`. */
20790
20838
  const parseLsTree = (out) => {
20791
20839
  const entries = /* @__PURE__ */ new Map();
@@ -21018,6 +21066,34 @@ const makeWorkspaceOps = ({ root, pid }, git, commonDir, paths, io) => {
21018
21066
  }
21019
21067
  return new Map([...entries].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
21020
21068
  };
21069
+ const modeSync = (ref) => {
21070
+ const modes = parseLsTreeModes(gitSync([
21071
+ "ls-tree",
21072
+ "-r",
21073
+ "-z",
21074
+ "--full-tree",
21075
+ ref ?? "HEAD"
21076
+ ]));
21077
+ if (ref !== void 0) return modes;
21078
+ const status = gitSync([
21079
+ "status",
21080
+ "--porcelain=v1",
21081
+ "-z",
21082
+ "--untracked-files=all",
21083
+ "--no-renames",
21084
+ "--ignore-submodules=none"
21085
+ ]);
21086
+ for (const record of status.split("\0")) {
21087
+ if (record.length < 4) continue;
21088
+ const path = record.slice(3).replace(/\/$/, "");
21089
+ const stat = io.lstat(paths.join(root, path));
21090
+ if (stat === void 0) modes.delete(path);
21091
+ else if (stat.type === "symlink") modes.set(path, "120000");
21092
+ else if (stat.type === "directory") modes.set(path, "160000");
21093
+ else modes.set(path, stat.executable === true ? "100755" : "100644");
21094
+ }
21095
+ return modes;
21096
+ };
21021
21097
  /** A submodule's (or embedded repository's) checked-out commit — what `git add` records for it. */
21022
21098
  const gitlinkOf = (path) => {
21023
21099
  try {
@@ -21035,6 +21111,7 @@ const makeWorkspaceOps = ({ root, pid }, git, commonDir, paths, io) => {
21035
21111
  return {
21036
21112
  treeSync,
21037
21113
  worktreeSync,
21114
+ modeSync,
21038
21115
  episodeTrees,
21039
21116
  readSync,
21040
21117
  read: (path) => try_({
@@ -22479,6 +22556,8 @@ const loopLogPath = gen(function* () {
22479
22556
  // src/platform/RetainedHistory.ts
22480
22557
  /** Per-worktree (`refs/worktree/gtd/*`) so linked worktrees sharing one `.git` don't clobber each other's retained history. */
22481
22558
  const HISTORY_REF = "refs/worktree/gtd/history";
22559
+ /** Set by an abandon that left `gtd/<b>` for `<b>`: only then does restore recreate `gtd/<b>`. */
22560
+ const RESUME_BRANCH_REF = "refs/worktree/gtd/history-branch";
22482
22561
  /** One ref per finished process, kept past the branch's deletion; distinct from the per-worktree `HISTORY_REF`. */
22483
22562
  const PROCESS_HISTORY_PREFIX = "refs/gtd/history/";
22484
22563
  const processHistoryRef = (hash) => `${PROCESS_HISTORY_PREFIX}${hash}`;
@@ -22690,6 +22769,7 @@ while :; do
22690
22769
  model="$(gtd next --json=model)"
22691
22770
  system="$(gtd next --json=system)"
22692
22771
  validate="$(gtd next --json=validate)"
22772
+ fresh="$(gtd next --json=fresh)"
22693
22773
  log="$(gtd next --json=log)"
22694
22774
  # \`model\` is a hint, not a model name — gtd-workflows' default ships
22695
22775
  # \`smart\` and \`base\`, which \`claude --model\` rejects. Resolve them
@@ -22732,8 +22812,14 @@ while :; do
22732
22812
  then first=--resume second=--session-id
22733
22813
  else first=--session-id second=--resume
22734
22814
  fi
22815
+ # A validator retry resumes with the correction alone; a session that
22816
+ # cannot resume starts over with \`fresh\`, the original prompt plus it.
22817
+ second_prompt="$log.prompt"
22818
+ if [ "$second" = --session-id ] && [ -n "\${fresh:-}" ]
22819
+ then printf '%s\\n' "$fresh" >"$log.fresh" && second_prompt="$log.fresh"
22820
+ fi
22735
22821
  turn "$first" "$session_id" <"$log.prompt" ||
22736
- { [ "$ran" = false ] && turn "$second" "$session_id" <"$log.prompt"; } || {
22822
+ { [ "$ran" = false ] && turn "$second" "$session_id" <"$second_prompt"; } || {
22737
22823
  echo "agent turn failed — see $log" >&2
22738
22824
  exit 1
22739
22825
  }
@@ -22747,7 +22833,7 @@ while :; do
22747
22833
  exit 1
22748
22834
  }
22749
22835
  done
22750
- rm -f "$log.prompt" ;;
22836
+ rm -f "$log.prompt" "$log.fresh" ;;
22751
22837
  esac
22752
22838
 
22753
22839
  # Read every value the landing needs BEFORE piping the script to \`sh\`:
@@ -23339,6 +23425,9 @@ const treeFromRecord = (files) => ({
23339
23425
  });
23340
23426
  const diffTrees$1 = (before, after) => {
23341
23427
  const same = (path) => {
23428
+ if (before.mode !== void 0 && after.mode !== void 0) {
23429
+ if (before.mode(path) !== after.mode(path)) return false;
23430
+ }
23342
23431
  const a = before.id?.(path);
23343
23432
  const b = after.id?.(path);
23344
23433
  return a !== void 0 && b !== void 0 ? a === b : before.read(path) === after.read(path);
@@ -23514,6 +23603,12 @@ const TRAILER_READERS = {
23514
23603
  "Gtd-Anchor": (value, into) => {
23515
23604
  if (HASH_RE.test(value)) into.anchor ??= value;
23516
23605
  },
23606
+ "Gtd-Shipped": (value, into) => {
23607
+ if (HASH_RE.test(value)) into.shipped ??= value;
23608
+ },
23609
+ "Gtd-Tip": (value, into) => {
23610
+ if (HASH_RE.test(value)) into.tip ??= value;
23611
+ },
23517
23612
  "Gtd-Format": (value, into) => {
23518
23613
  if (FORMAT_RE.test(value)) into.format ??= Number(value);
23519
23614
  },
@@ -23538,6 +23633,8 @@ const parseCommitMessage = (message) => {
23538
23633
  approved: void 0,
23539
23634
  pin: void 0,
23540
23635
  anchor: void 0,
23636
+ shipped: void 0,
23637
+ tip: void 0,
23541
23638
  judge: [],
23542
23639
  marks: [],
23543
23640
  vars: {},
@@ -23570,7 +23667,8 @@ const formatCommitMessage = (spec) => {
23570
23667
  ...trailer("Anchor", spec.anchor),
23571
23668
  ...trailer("Branch", spec.branch),
23572
23669
  ...trailer("Version", spec.version),
23573
- `Gtd-Format: 4`
23670
+ `Gtd-Format: 4`,
23671
+ ...trailer("Shipped", spec.shipped)
23574
23672
  ];
23575
23673
  return `${formatSubject(spec.actor, spec.to, spec.from)}\n\n${lines.join("\n")}`;
23576
23674
  };
@@ -23579,7 +23677,7 @@ const namesUnpin = (commit) => {
23579
23677
  const unpin = pinStateIn(commit.parsed.format, "remove");
23580
23678
  return unpin !== void 0 && commit.parsed.parsed?.to === unpin;
23581
23679
  };
23582
- const actorOfKind = (kind) => kind === "run" ? "check" : kind === "agent" ? "agent" : kind === "human" ? "human" : "judge";
23680
+ const actorOfKind = (kind) => kind === "run" || kind === "ship" ? "check" : kind === "agent" ? "agent" : kind === "human" ? "human" : "judge";
23583
23681
  const memoryScopeOf = (name) => {
23584
23682
  const dot = name.lastIndexOf(".");
23585
23683
  return dot === -1 ? "" : name.slice(0, dot);
@@ -23705,7 +23803,11 @@ const KNOWN_OPTIONS = {
23705
23803
  "model",
23706
23804
  "system",
23707
23805
  "allowEmpty",
23708
- "keep"
23806
+ "keep",
23807
+ "validate",
23808
+ "cap",
23809
+ "exhausted",
23810
+ "correction"
23709
23811
  ]),
23710
23812
  human: /* @__PURE__ */ new Set([
23711
23813
  ...STEERING_OPTIONS,
@@ -23713,9 +23815,24 @@ const KNOWN_OPTIONS = {
23713
23815
  "acceptClean",
23714
23816
  "approve"
23715
23817
  ]),
23716
- run: new Set(STEERING_OPTIONS),
23818
+ run: /* @__PURE__ */ new Set([...STEERING_OPTIONS, "onFail"]),
23819
+ ship: /* @__PURE__ */ new Set(["label", "base"]),
23717
23820
  judge: /* @__PURE__ */ new Set([...STEERING_OPTIONS, "message"])
23718
23821
  };
23822
+ const validatorOptionFault = (name, options) => {
23823
+ const { validate, cap, exhausted, correction } = options;
23824
+ const prefix = `gtd: step "${name}": `;
23825
+ const notFunction = Object.entries({
23826
+ validate,
23827
+ exhausted,
23828
+ correction
23829
+ }).find(([, value]) => value !== void 0 && typeof value !== "function");
23830
+ if (notFunction !== void 0) return `${prefix}${notFunction[0]} must be a function in agent() options`;
23831
+ if (cap !== void 0 && !(Number.isInteger(cap) && cap > 0)) return `${prefix}cap must be a positive integer in agent() options`;
23832
+ if (validate !== void 0 && options.keep === true) return `${prefix}validate cannot be combined with keep: true — a kept turn is set aside, so there is no landed result to validate`;
23833
+ };
23834
+ const restsOnFail = (step) => step.request.kind === "run" && step.request.options.onFail === "rest";
23835
+ const onFailFault = (step) => step.request.kind === "run" && step.request.options.onFail !== void 0 && step.request.options.onFail !== "rest" ? `gtd: step "${step.name}": onFail must be "rest" in run() options` : void 0;
23719
23836
  const unknownOptions = (step) => {
23720
23837
  const unknown = Object.keys(step.request.options).filter((key) => !KNOWN_OPTIONS[step.kind].has(key));
23721
23838
  if (unknown.length === 0) return void 0;
@@ -23826,15 +23943,15 @@ const replay = async (raw) => {
23826
23943
  return calls;
23827
23944
  };
23828
23945
  const scoped = (name) => [...scopes.flatMap((s) => s.name === void 0 ? [] : [s.name]), name].join(".");
23829
- const isAttemptAt = (commit, name) => commit.parsed.step === void 0 && commit.parsed.parsed !== void 0 && commit.parsed.parsed.from === void 0 && commit.parsed.parsed.to === name && isEmptyDiff(diffTrees$1(position.tree, commit.tree));
23946
+ const isAttemptAt = (commit, step) => commit.parsed.step === void 0 && commit.parsed.parsed !== void 0 && commit.parsed.parsed.from === void 0 && commit.parsed.parsed.to === step.name && (isEmptyDiff(diffTrees$1(position.tree, commit.tree)) || restsOnFail(step) && commit.tree.read(".gtd/run-failed.txt") !== void 0);
23830
23947
  const advance = (next) => {
23831
23948
  previousPosition = position;
23832
23949
  position = next;
23833
23950
  };
23834
- const replyFor = (request, verdicts) => request.kind === "judge" ? {
23951
+ const replyFor = (request, verdicts, shipped) => request.kind === "judge" ? {
23835
23952
  answers: answersFrom(verdicts, request.questions),
23836
23953
  truncated: judgeCuts.get(request) ?? []
23837
- } : void 0;
23954
+ } : request.kind === "ship" ? shipped : void 0;
23838
23955
  const judgeCuts = /* @__PURE__ */ new WeakMap();
23839
23956
  const resolveScoped = (memory, pick, configured, bundled) => {
23840
23957
  const own = /* @__PURE__ */ new Map();
@@ -23948,7 +24065,7 @@ const replay = async (raw) => {
23948
24065
  expectedNext = void 0;
23949
24066
  anchorPending = false;
23950
24067
  if (expected !== void 0 && expected.name !== step.name) return diverged(`gtd: commit ${short(expected.hash)} names "${expected.name}" as the next step, but replay reached "${step.name}"`, step);
23951
- const error = unknownOptions(step) ?? approveConflict(step) ?? modeWithoutFile(step) ?? checkIdentity(step);
24068
+ const error = unknownOptions(step) ?? onFailFault(step) ?? approveConflict(step) ?? modeWithoutFile(step) ?? checkIdentity(step);
23952
24069
  return error === void 0 ? void 0 : {
23953
24070
  kind: "failed",
23954
24071
  message: error
@@ -23974,7 +24091,7 @@ const replay = async (raw) => {
23974
24091
  expectNextOf(commit);
23975
24092
  skipPins();
23976
24093
  noteKept(step, commit);
23977
- return replyFor(step.request, commit.parsed.judge);
24094
+ return replyFor(step.request, commit.parsed.judge, commit.parsed.shipped);
23978
24095
  };
23979
24096
  const expectNextOf = (commit) => {
23980
24097
  const to = commit.parsed.parsed?.to;
@@ -24005,10 +24122,14 @@ const replay = async (raw) => {
24005
24122
  hash: "",
24006
24123
  tree: keeping ? position.tree : pending.tree
24007
24124
  });
24008
- return replyFor(step.request, pending.verdicts ?? []);
24125
+ return replyFor(step.request, pending.verdicts ?? [], pending.shipped);
24009
24126
  };
24010
24127
  const answer = (step) => {
24011
- while (cursor < commits.length && isAttemptAt(commits[cursor], step.name)) {
24128
+ while (cursor < commits.length && isAttemptAt(commits[cursor], step)) {
24129
+ if (!isEmptyDiff(diffTrees$1(position.tree, commits[cursor].tree))) advance({
24130
+ hash: commits[cursor].hash,
24131
+ tree: commits[cursor].tree
24132
+ });
24012
24133
  cursor++;
24013
24134
  skipPins();
24014
24135
  }
@@ -24021,13 +24142,25 @@ const replay = async (raw) => {
24021
24142
  landed,
24022
24143
  marks
24023
24144
  });
24145
+ if (restsOnFail(step) && input.pending.tree.read(".gtd/run-failed.txt") !== void 0) return finish({
24146
+ kind: "rest",
24147
+ rest: step,
24148
+ trace,
24149
+ landed,
24150
+ marks
24151
+ });
24024
24152
  return landPending(step, input.pending);
24025
24153
  };
24154
+ const bodyFault = (request) => {
24155
+ if (request.kind === "ship" && (typeof request.message !== "string" || request.message.trim() === "")) return `gtd: step "${scoped(request.name)}": a ship() message is a non-empty commit message`;
24156
+ 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)`;
24157
+ };
24026
24158
  const requestFault = (request) => {
24027
24159
  if (scoped(request.name).startsWith("gtd:")) return `gtd: step "${scoped(request.name)}": the "${ENGINE_PREFIX}" prefix is reserved for gtd's own beats — rename the step`;
24028
- 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)`;
24029
- if (request.kind !== "agent") return void 0;
24160
+ if (request.kind !== "agent") return bodyFault(request);
24030
24161
  const name = scoped(request.name);
24162
+ const optionFault = validatorOptionFault(name, request.options);
24163
+ if (optionFault !== void 0) return optionFault;
24031
24164
  if (request.options.keep === true) {
24032
24165
  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`;
24033
24166
  const refFault = name.includes("/") ? "it contains \"/\"" : refNameFault(name);
@@ -24199,6 +24332,7 @@ const compileConfig = (value, origin) => {
24199
24332
  return {
24200
24333
  modes,
24201
24334
  ...config.ui !== void 0 ? { ui: config.ui } : {},
24335
+ ...config.historyRemote !== void 0 ? { historyRemote: config.historyRemote } : {},
24202
24336
  skills: Object.assign(Object.create(null), config.skills),
24203
24337
  access: Object.assign(Object.create(null), config.access),
24204
24338
  diagnostics: []
@@ -24512,6 +24646,7 @@ const load = gen(function* () {
24512
24646
  return entry === void 0 ? void 0 : resolve(name, entry);
24513
24647
  },
24514
24648
  ...compiled.ui !== void 0 ? { ui: compiled.ui } : {},
24649
+ ...compiled.historyRemote !== void 0 ? { historyRemote: compiled.historyRemote } : {},
24515
24650
  warnings: diagnostics.filter((d) => d.severity === "warning"),
24516
24651
  fingerprint: pinned?.fingerprint ?? configFingerprint(nodeSyncIo, paths, loaded.file.dependencyFiles, host.root, module.read ?? readFromDisk),
24517
24652
  drift: pinned === void 0 ? [] : changedConfigFiles(pinned.fingerprint, configFingerprint(nodeSyncIo, paths, Object.keys(pinned.fingerprint).map((file) => paths.resolve(host.root, file)), host.root, readFromDisk))
@@ -25212,8 +25347,9 @@ const shellQuote = (value) => `'${value.replace(/'/g, "'\\''")}'`;
25212
25347
  * output rather than the exit code, so a genuinely rejected commit (a lint
25213
25348
  * error, a commit-msg hook) still fails.
25214
25349
  */
25215
- const commitAllowEmpty = (message, only) => {
25216
- const m = shellQuote(message);
25350
+ const commitAllowEmpty = (message, only) => commitWordAllowEmpty(shellQuote(message), only);
25351
+ /** `commitAllowEmpty` over a message that is already a shell word. */
25352
+ const commitWordAllowEmpty = (m, only) => {
25217
25353
  const paths = only === void 0 ? "" : ` -- ${shellQuote(only)}`;
25218
25354
  return [
25219
25355
  `if ! out=$(git commit --allow-empty -m ${m}${paths} 2>&1); then`,
@@ -25229,6 +25365,28 @@ const CHANGED_SINCE_LAND = "gtd: the working tree changed since `gtd land` — r
25229
25365
  const commitAll = (message) => `git add -A || exit 1\n${commitAllowEmpty(message)}`;
25230
25366
  /** Commits `path` alone, as the worktree has it; every other pending change stays pending. */
25231
25367
  const commitPath = (path, message) => `git add -A -- ${shellQuote(path)} || exit 1\n${commitAllowEmpty(message, path)}`;
25368
+ /**
25369
+ * A `run()` body that records its own failure instead of failing the beat: the
25370
+ * status and the last 50 output lines go to `path`, and the script exits zero
25371
+ * so the turn lands. The file is COMMITTED (and force-pushed with a history
25372
+ * remote), hence the cap.
25373
+ */
25374
+ const failRestScript = (body, path) => {
25375
+ const p = shellQuote(path);
25376
+ const slash = path.lastIndexOf("/");
25377
+ return [
25378
+ `rm -f -- ${p}`,
25379
+ `gtd_run_status=0`,
25380
+ `gtd_run_out="$( {`,
25381
+ body,
25382
+ `} 2>&1 )" || gtd_run_status=$?`,
25383
+ `printf '%s\\n' "$gtd_run_out"`,
25384
+ `if [ "$gtd_run_status" -ne 0 ]; then`,
25385
+ ` mkdir -p ${shellQuote(path.slice(0, slash))}`,
25386
+ ` printf 'exit %s\\n%s\\n' "$gtd_run_status" "$(printf '%s\\n' "$gtd_run_out" | tail -n 50)" >${p}`,
25387
+ `fi`
25388
+ ].join("\n");
25389
+ };
25232
25390
  /** A pin beat's script. A failed `gtd bundle` leaves no half-written pin behind. */
25233
25391
  const pinScript = (path) => {
25234
25392
  const p = shellQuote(path);
@@ -25304,6 +25462,62 @@ const keepTurn = (refPrefix, message) => {
25304
25462
  `git reset --hard HEAD`
25305
25463
  ].join("\n");
25306
25464
  };
25465
+ /** `git switch -C <branch> [<start>]`: create or reset the branch, carrying pending changes over. */
25466
+ const switchBranchTo = (branch, start) => `git switch -q -C ${shellQuote(branch)}${start === void 0 ? "" : ` ${shellQuote(start)}`}`;
25467
+ /** `git switch <branch>`: an existing branch, carrying pending changes over. */
25468
+ const switchTo = (branch) => `git switch -q ${shellQuote(branch)}`;
25469
+ /** `switchTo` that, when pending changes block it, stays put and says so: the landing already happened. */
25470
+ const switchBack = (branch) => [
25471
+ `if ! git switch -q ${shellQuote(branch)} 2>/dev/null; then`,
25472
+ ` printf '%s\\n' ${shellQuote(`gtd: pending changes keep HEAD off ${branch}; switch to it once they are committed or stashed`)}`,
25473
+ `fi`
25474
+ ].join("\n");
25475
+ const deleteBranch = (branch) => `git branch -q -D ${shellQuote(branch)}`;
25476
+ /**
25477
+ * Force-pushes one ref to a remote. A failed push exits non-zero with the retry
25478
+ * on stderr: it runs after the closing commit has landed, so the commit stands.
25479
+ */
25480
+ const pushRef = (remote, ref) => {
25481
+ const retry = `git push --force ${shellQuote(remote)} ${shellQuote(`${ref}:${ref}`)}`;
25482
+ return [
25483
+ `if ! ${retry}; then`,
25484
+ ` printf '%s\\n' ${shellQuote(`gtd: pushing ${ref} failed — retry: ${retry}`)} >&2`,
25485
+ ` exit 1`,
25486
+ `fi`
25487
+ ].join("\n");
25488
+ };
25489
+ /**
25490
+ * A ship step's landing, in one block because the landing commit's
25491
+ * `Gtd-Shipped` trailer names the squash it makes first. The squash carries
25492
+ * the staged tree with `.gtd/` as `<branch>` has it, built in a scratch index; `<branch>` moves by
25493
+ * compare-and-swap against `parent`, so a branch moved since the decision
25494
+ * fails the script before the landing commits. `commit-tree` ignores
25495
+ * `commit.gpgSign`, so it is read here.
25496
+ */
25497
+ const shipCommit = (branch, parent, shipMessage, historyRef, message) => [
25498
+ `gtd_ship_parent=${shellQuote(parent)}`,
25499
+ `gtd_ship_branch=${shellQuote(branch)}`,
25500
+ `gtd_ship_history=${shellQuote(historyRef)}`,
25501
+ `gtd_ship_message=${shellQuote(shipMessage)}`,
25502
+ `gtd_land_message=${shellQuote(message)}`,
25503
+ `git add -A || exit 1`,
25504
+ `gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
25505
+ `if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
25506
+ `gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
25507
+ `gtd_tree=$(git write-tree) || exit 1`,
25508
+ `gtd_index=$(git rev-parse --git-path gtd-ship-index) || exit 1`,
25509
+ `rm -f -- "$gtd_index"`,
25510
+ `GIT_INDEX_FILE="$gtd_index" git read-tree "$gtd_tree" || exit 1`,
25511
+ `GIT_INDEX_FILE="$gtd_index" git rm -r -q --cached --ignore-unmatch -- .gtd || exit 1`,
25512
+ `git ls-tree -r "$gtd_ship_parent" -- .gtd | GIT_INDEX_FILE="$gtd_index" git update-index --index-info || exit 1`,
25513
+ `gtd_ship_tree=$(GIT_INDEX_FILE="$gtd_index" git write-tree) || exit 1`,
25514
+ `rm -f -- "$gtd_index"`,
25515
+ `gtd_ship_trailers=$(printf "Gtd-History: %s\\nGtd-Tip: %s" "$gtd_ship_history" "$gtd_head")`,
25516
+ `gtd_shipped=$(git commit-tree "$gtd_sign" "$gtd_ship_tree" -p "$gtd_ship_parent" -m "$gtd_ship_message" -m "$gtd_ship_trailers") || exit 1`,
25517
+ `git update-ref -m "gtd: ship" "refs/heads/$gtd_ship_branch" "$gtd_shipped" "$gtd_ship_parent" || exit 1`,
25518
+ `gtd_message=$(printf "%s\\nGtd-Shipped: %s" "$gtd_land_message" "$gtd_shipped")`,
25519
+ commitWordAllowEmpty(`"$gtd_message"`)
25520
+ ].join("\n");
25307
25521
  const updateRef = (ref, hash) => `git update-ref ${shellQuote(ref)} ${shellQuote(hash)}`;
25308
25522
  /** `git update-ref -d <ref>` — idempotent: deleting a missing ref is already a no-op in real git. */
25309
25523
  const deleteRef = (ref) => `git update-ref -d ${shellQuote(ref)}`;
@@ -25332,15 +25546,18 @@ const renderOutcome = (outcome) => {
25332
25546
  case "note": return printfLine$1("%s\n", [shellQuote(outcome.text)]);
25333
25547
  }
25334
25548
  };
25335
- const renderGitWrite = (write) => {
25336
- switch (write.kind) {
25337
- case "commitAll": return commitAll(write.message);
25338
- case "commitPath": return commitPath(write.path, write.message);
25339
- case "commitClean": return commitClean(write.message);
25340
- case "updateRef": return updateRef(write.ref, write.target);
25341
- case "keepTurn": return keepTurn(write.ref, write.message);
25342
- }
25549
+ const GIT_WRITES = {
25550
+ commitAll: (w) => commitAll(w.message),
25551
+ commitPath: (w) => commitPath(w.path, w.message),
25552
+ commitClean: (w) => commitClean(w.message),
25553
+ updateRef: (w) => updateRef(w.ref, w.target),
25554
+ keepTurn: (w) => keepTurn(w.ref, w.message),
25555
+ switchBranch: (w) => switchBranchTo(w.branch),
25556
+ switchTo: (w) => switchBack(w.branch),
25557
+ pushRef: (w) => pushRef(w.remote, w.ref),
25558
+ ship: (w) => shipCommit(w.branch, w.parent, w.shipMessage, w.historyRef, w.message)
25343
25559
  };
25560
+ const renderGitWrite = (write) => GIT_WRITES[write.kind](write);
25344
25561
  /**
25345
25562
  * Wraps `command` so a non-zero exit prints `prompt` plus the command's
25346
25563
  * captured output before propagating that exit code — mirrors `Emit.ts`'s
@@ -25722,15 +25939,19 @@ const validateScriptFor = (resolved, file) => gen(function* () {
25722
25939
  });
25723
25940
  // src/step/planStep.ts
25724
25941
  /**
25725
- * At the human review gate an unconditional `gtd uncheck '<file>'` runs
25726
- * ahead of the commit, resetting every checkbox before `git add -A` picks it
25727
- * up — a tick is read-progress, never sign-off.
25942
+ * Clear ticks at the human review gate, a tick is read-progress. Keep them when
25943
+ * the next step is an agent turn: the agent reads them.
25728
25944
  */
25729
- const uncheckStep = (snapshot) => snapshot.stepDef.actor === "human" && snapshot.stepDef.mode === "review" && snapshot.file !== void 0 ? [{
25945
+ const uncheckStep = (snapshot, keepTicks) => !keepTicks && snapshot.stepDef.actor === "human" && snapshot.stepDef.mode === "review" && snapshot.file !== void 0 ? [{
25730
25946
  kind: "uncheck",
25731
25947
  file: snapshot.file
25732
25948
  }] : [];
25733
25949
  const gitWriteOf = (landing, message) => {
25950
+ if (landing.ship !== void 0) return {
25951
+ kind: "ship",
25952
+ message,
25953
+ ...landing.ship
25954
+ };
25734
25955
  if (landing.only !== void 0) return {
25735
25956
  kind: "commitPath",
25736
25957
  path: landing.only,
@@ -25750,6 +25971,10 @@ const gitWriteOf = (landing, message) => {
25750
25971
  message
25751
25972
  };
25752
25973
  };
25974
+ const writeIf = (value, write) => value === void 0 ? [] : [{
25975
+ kind: "gitWrite",
25976
+ write: write(value)
25977
+ }];
25753
25978
  const planStep = (snapshot, opts = {}) => {
25754
25979
  const landing = snapshot.landing;
25755
25980
  if (landing.kind === "refusal") return {
@@ -25797,6 +26022,7 @@ const planStep = (snapshot, opts = {}) => {
25797
26022
  ...opts.judge !== void 0 && opts.judge.length > 0 ? { judge: opts.judge } : {}
25798
26023
  });
25799
26024
  const subject = message.split("\n")[0];
26025
+ const historyRef = landing.keepHistory ?? landing.ship?.historyRef ?? landing.recordedHistory;
25800
26026
  const outcome = landing.to === snapshot.state ? {
25801
26027
  kind: "outcome",
25802
26028
  outcome: {
@@ -25817,20 +26043,37 @@ const planStep = (snapshot, opts = {}) => {
25817
26043
  to: landing.to,
25818
26044
  subject,
25819
26045
  steps: [
25820
- ...uncheckStep(snapshot),
26046
+ ...writeIf(landing.startBranch, (branch) => ({
26047
+ kind: "switchBranch",
26048
+ branch
26049
+ })),
26050
+ ...uncheckStep(snapshot, landing.keepTicks === true),
25821
26051
  {
25822
26052
  kind: "gitWrite",
25823
26053
  write: gitWriteOf(landing, message)
25824
26054
  },
25825
- ...landing.keepHistory !== void 0 ? [{
25826
- kind: "gitWrite",
25827
- write: {
25828
- kind: "updateRef",
25829
- ref: landing.keepHistory,
25830
- target: "HEAD"
26055
+ ...writeIf(historyRef, (ref) => ({
26056
+ kind: "updateRef",
26057
+ ref,
26058
+ target: "HEAD"
26059
+ })),
26060
+ ...writeIf(landing.leaveBranch, (branch) => ({
26061
+ kind: "switchTo",
26062
+ branch
26063
+ })),
26064
+ outcome,
26065
+ ...landing.note !== void 0 ? [{
26066
+ kind: "outcome",
26067
+ outcome: {
26068
+ kind: "note",
26069
+ text: landing.note
25831
26070
  }
25832
26071
  }] : [],
25833
- outcome
26072
+ ...writeIf(historyRef !== void 0 ? landing.pushHistory : void 0, (remote) => ({
26073
+ kind: "pushRef",
26074
+ remote,
26075
+ ref: historyRef
26076
+ }))
25834
26077
  ]
25835
26078
  };
25836
26079
  };
@@ -25850,6 +26093,42 @@ const accessRefusal = (changes, write) => {
25850
26093
  `allowed: ${write.length === 0 ? "(nothing)" : write.join(", ")}`
25851
26094
  ].join("\n");
25852
26095
  };
26096
+ // src/step/startBranch.ts
26097
+ const GTD_BRANCH_PREFIX = "gtd/";
26098
+ /** `<b>` for a `gtd/<b>` branch name; `undefined` for any other. */
26099
+ const userBranchOf = (branch) => branch.startsWith("gtd/") ? branch.slice(4) : void 0;
26100
+ /**
26101
+ * Where a new process starts: on `gtd/<b>`, so `<b>` never carries its commits.
26102
+ * An existing `gtd/<b>` is reused only when its tip is contained in `<b>` or in
26103
+ * a kept history ref (a squash onto `<b>` never makes it an ancestor).
26104
+ */
26105
+ const startBranchFor = (commandLabel) => gen(function* () {
26106
+ const git = yield* GitService;
26107
+ const current = yield* git.currentBranch();
26108
+ if (isNone(current) || current.value.startsWith("gtd/")) return { kind: "inPlace" };
26109
+ if (!(yield* git.hasCommits())) return { kind: "inPlace" };
26110
+ const branch = `${GTD_BRANCH_PREFIX}${current.value}`;
26111
+ const existing = yield* git.readRefOption(`refs/heads/${branch}`);
26112
+ if (isSome(existing)) {
26113
+ const head = yield* git.resolveRef("HEAD");
26114
+ let contained = yield* git.isAncestor(existing.value, head);
26115
+ if (!contained) {
26116
+ const kept = yield* git.listRefs(PROCESS_HISTORY_PREFIX);
26117
+ for (const k of kept) if (yield* git.isAncestor(existing.value, k.hash)) {
26118
+ contained = true;
26119
+ break;
26120
+ }
26121
+ }
26122
+ if (!contained) return {
26123
+ kind: "refusal",
26124
+ message: `${commandLabel}: branch ${branch} holds commits that are neither on ${current.value} nor in a kept history ref — merge or delete it first`
26125
+ };
26126
+ }
26127
+ return {
26128
+ kind: "switch",
26129
+ branch
26130
+ };
26131
+ });
25853
26132
  // src/step/planStart.ts
25854
26133
  const refusal = (message) => ({
25855
26134
  kind: "refusal",
@@ -25890,29 +26169,42 @@ const planStart = (current, actor, start) => gen(function* () {
25890
26169
  const base = template === void 0 ? void 0 : yield* resolveBase(commandLabel, template);
25891
26170
  if (typeof base === "object") return base;
25892
26171
  const subject = formatSubject(actor, firstStep);
26172
+ const message = formatCommitMessage({
26173
+ actor,
26174
+ to: firstStep,
26175
+ workflow: name,
26176
+ ...base !== void 0 ? { reviewBase: base } : {},
26177
+ vars
26178
+ });
26179
+ const onBranch = yield* startBranchFor(commandLabel);
26180
+ if (onBranch.kind === "refusal") return refusal(onBranch.message);
25893
26181
  return {
25894
26182
  kind: "start",
25895
26183
  workflow: name,
25896
26184
  subject,
25897
- steps: [{
25898
- kind: "gitWrite",
25899
- write: {
25900
- kind: "commitAll",
25901
- message: formatCommitMessage({
25902
- actor,
25903
- to: firstStep,
25904
- workflow: name,
25905
- ...base !== void 0 ? { reviewBase: base } : {},
25906
- vars
25907
- })
25908
- }
25909
- }, {
25910
- kind: "outcome",
25911
- outcome: {
25912
- kind: "commit",
25913
- subject
26185
+ steps: [
26186
+ ...onBranch.kind === "switch" ? [{
26187
+ kind: "gitWrite",
26188
+ write: {
26189
+ kind: "switchBranch",
26190
+ branch: onBranch.branch
26191
+ }
26192
+ }] : [],
26193
+ {
26194
+ kind: "gitWrite",
26195
+ write: {
26196
+ kind: "commitAll",
26197
+ message
26198
+ }
26199
+ },
26200
+ {
26201
+ kind: "outcome",
26202
+ outcome: {
26203
+ kind: "commit",
26204
+ subject
26205
+ }
25914
26206
  }
25915
- }]
26207
+ ]
25916
26208
  };
25917
26209
  });
25918
26210
  /**
@@ -26156,14 +26448,32 @@ const runAtTip = (git, def, tip) => gen(function* () {
26156
26448
  if (closing.length === 0) closing = yield* git.commitHistory(void 0, tip.hash);
26157
26449
  return yield* runOf([...before, ...closing], { ...location }, tip.hash);
26158
26450
  });
26159
- const runAtHead = (git, def) => gen(function* () {
26160
- return yield* runAtTip(git, def, (yield* git.subjectHistory(1, 0))[0]);
26161
- });
26162
- /** The process HEAD closes or sits inside — `gtd summary`'s run. */
26451
+ /**
26452
+ * The process HEAD closes or sits inside — `gtd summary`'s run. On `<b>`, where
26453
+ * a finished process leaves HEAD, it is the one `gtd/<b>`'s tip closes, while
26454
+ * `<b>` holds nothing since: its start, or its last ship.
26455
+ */
26163
26456
  const summaryRun = gen(function* () {
26164
26457
  const git = yield* GitService;
26165
26458
  const def = (yield* (yield* ConfigService).load).workflow;
26166
- return yield* runAtHead(git, def);
26459
+ const head = (yield* git.subjectHistory(1, 0))[0];
26460
+ const left = head === void 0 ? void 0 : yield* leftProcessTip(git, head);
26461
+ return yield* runAtTip(git, def, left ?? head);
26462
+ });
26463
+ const leftProcessTip = (git, head) => gen(function* () {
26464
+ if (parseCommitMessage(head.subject).parsed !== void 0) return void 0;
26465
+ const current = yield* git.currentBranch();
26466
+ if (isNone(current) || userBranchOf(current.value) !== void 0) return void 0;
26467
+ const tip = yield* git.readRefOption(`refs/heads/${GTD_BRANCH_PREFIX}${current.value}`);
26468
+ if (isNone(tip)) return void 0;
26469
+ const message = yield* git.lastCommitMessage(tip.value);
26470
+ if (parseCommitMessage(message).parsed?.to === void 0) return void 0;
26471
+ const since = parseCommitMessage(yield* git.lastCommitMessage(head.hash)).tip ?? head.hash;
26472
+ if (!(yield* git.isAncestor(since, tip.value))) return void 0;
26473
+ return {
26474
+ hash: tip.value,
26475
+ subject: message.split("\n")[0]
26476
+ };
26167
26477
  });
26168
26478
  const commitTree = (workspace, hash) => {
26169
26479
  let entries;
@@ -26291,13 +26601,21 @@ const promptDef = (common, request, skills, access) => ({
26291
26601
  ...optional("system", request.options.system),
26292
26602
  ...optional("allowEmpty", request.options.allowEmpty),
26293
26603
  ...optional("keep", request.options.keep),
26604
+ ...optional("original", request.original),
26294
26605
  ...optional("skills", skills),
26295
26606
  ...optional("access", access)
26296
26607
  });
26297
26608
  const scriptDef = (common, request) => ({
26298
26609
  ...common,
26299
26610
  kind: "script",
26300
- content: request.body
26611
+ content: request.options.onFail === "rest" ? failRestScript(request.body, RUN_FAILED_PATH) : request.body,
26612
+ ...optional("onFail", request.options.onFail)
26613
+ });
26614
+ const SHIP_CONTENT = noteOutcome("gtd: landing this step ships the process onto your branch");
26615
+ const shipDef = (common) => ({
26616
+ ...common,
26617
+ kind: "script",
26618
+ content: SHIP_CONTENT
26301
26619
  });
26302
26620
  const judgeDef = (common, request, step) => {
26303
26621
  const message = request.options.message ?? DEFAULT_JUDGE_MESSAGE;
@@ -26321,6 +26639,7 @@ const stepDefOf = (step) => {
26321
26639
  switch (request.kind) {
26322
26640
  case "agent": return promptDef(common, request, step.skills, step.access);
26323
26641
  case "run": return scriptDef(common, request);
26642
+ case "ship": return shipDef(common);
26324
26643
  case "judge": return judgeDef(common, request, step);
26325
26644
  case "human": return messageDef(common, request, step);
26326
26645
  }
@@ -26474,6 +26793,7 @@ const restAt = (ref, startOverrides = {}) => gen(function* () {
26474
26793
  path: e.path
26475
26794
  })) : [];
26476
26795
  if (ref === void 0 && config.drift.length > 0) yield* (yield* Narrator).warn(`gtd: notice: the live config differs from the pinned one (${config.drift.join(", ")}); \`gtd repin\` to switch`);
26796
+ const shipBlocked = ref === void 0 ? yield* shipBlockedAt(setup, pin, step, changes) : void 0;
26477
26797
  return {
26478
26798
  def,
26479
26799
  workflowName: resolved.name,
@@ -26492,7 +26812,8 @@ const restAt = (ref, startOverrides = {}) => gen(function* () {
26492
26812
  memoryResumed: memory.resumed,
26493
26813
  hints: hintsOf(stepDef),
26494
26814
  cost: costTotals(run.costEntries),
26495
- setup
26815
+ setup,
26816
+ ...shipBlocked !== void 0 ? { shipBlocked } : {}
26496
26817
  };
26497
26818
  });
26498
26819
  const currentRest = restAt(void 0);
@@ -26621,15 +26942,20 @@ const renderRest = (rest) => succeed$1({
26621
26942
  kind: rest.stepDef.kind,
26622
26943
  content: rest.stepDef.content,
26623
26944
  ...rest.hints,
26945
+ ...failedRunStalled(rest) ? { failure: rest.setup.workspace.readSync(".gtd/run-failed.txt") ?? "" } : {},
26946
+ ...rest.shipBlocked !== void 0 ? { blocked: rest.shipBlocked } : {},
26624
26947
  ...rest.memory !== void 0 ? { memory: rest.memory } : {},
26625
26948
  memoryResumed: rest.memoryResumed,
26949
+ ...rest.stepDef.original !== void 0 ? { original: rest.stepDef.original } : {},
26626
26950
  ...rest.pin !== void 0 ? { pin: rest.pin } : {}
26627
26951
  });
26628
26952
  /**
26629
26953
  * Derived, restart-proof stall detection: the tree is clean, HEAD is an empty
26630
26954
  * attempt at this very agent rest, and another dispatch would repeat it.
26631
26955
  */
26632
- const stalledAt = (rest) => rest.changes.length === 0 && rest.stepDef.kind === "prompt" && rest.stepDef.allowEmpty !== true && rest.run.headTurn?.state === rest.state && rest.run.headTurn.actor === rest.actor && !rest.run.headTurn.step && rest.run.headTurn.empty;
26956
+ const stalledAt = (rest) => failedRunStalled(rest) || rest.shipBlocked !== void 0 || rest.changes.length === 0 && rest.stepDef.kind === "prompt" && rest.stepDef.allowEmpty !== true && rest.run.headTurn?.state === rest.state && rest.run.headTurn.actor === rest.actor && !rest.run.headTurn.step && rest.run.headTurn.empty;
26957
+ /** A `run(…, { onFail: "rest" })` step whose last turn recorded a failure, on a clean tree. */
26958
+ const failedRunStalled = (rest) => rest.changes.length === 0 && rest.stepDef.onFail === "rest" && rest.run.headTurn?.state === rest.state && !rest.run.headTurn.step && rest.setup.workspace.readSync(".gtd/run-failed.txt") !== void 0;
26633
26959
  /** No process is underway: the default workflow's first step, first visit — a dirty tree there is a turn not yet landed, not a process. */
26634
26960
  const noProcessUnderway = (rest) => rest.pin === void 0 && !underwayAt(rest.run, rest.step, rest.def);
26635
26961
  /** `idle` means exactly one thing: no process underway, clean tree. */
@@ -26667,7 +26993,7 @@ const cleanLanding = (rest) => {
26667
26993
  settled: false
26668
26994
  };
26669
26995
  };
26670
- /** At the human review gate the pending tree lands with every tick cleared, as the landing script will. */
26996
+ /** At the human review gate the pending tree lands with every tick cleared; an agent-bound landing keeps them only if the unrewritten tree replays to the same step. */
26671
26997
  const reviewGateRewrite = (def) => {
26672
26998
  const reviewFormat = steeringFormatFor("review");
26673
26999
  if (!isHumanReviewGate(def) || def.file === void 0 || reviewFormat === void 0) return;
@@ -26676,6 +27002,15 @@ const reviewGateRewrite = (def) => {
26676
27002
  apply: (content) => reviewFormat.clearTicks(content)
26677
27003
  };
26678
27004
  };
27005
+ /** Whether the pending review file differs from HEAD's once both have their ticks cleared. */
27006
+ const reviewEditedBeyondTicks = (rest) => {
27007
+ const rewrite = reviewGateRewrite(rest.stepDef);
27008
+ if (rewrite === void 0) return false;
27009
+ const pending = rest.setup.workspace.readSync(rewrite.path);
27010
+ const committed = rest.setup.workspace.readCommittedSync(rewrite.path);
27011
+ if (pending === void 0 || committed === void 0) return true;
27012
+ return rewrite.apply(pending) !== rewrite.apply(committed);
27013
+ };
26679
27014
  const multilineRefusal = (vars) => {
26680
27015
  const multiline = multilineSetting(vars);
26681
27016
  return multiline === void 0 ? void 0 : {
@@ -26794,7 +27129,7 @@ const outcomeMarks = (outcome) => outcome.kind === "rest" || outcome.kind === "e
26794
27129
  * replay with the working tree as the pending turn. The target step is
26795
27130
  * computed by replaying, never matched.
26796
27131
  */
26797
- const decideLanding = (rest, verdicts, branch, approver) => gen(function* () {
27132
+ const decideLanding = (rest, verdicts, branch, approver, shipped) => gen(function* () {
26798
27133
  if (rest.pin !== void 0) return pinLanding(rest, rest.pin, branch);
26799
27134
  const unapproved = approvalRefusal(rest, approver);
26800
27135
  if (unapproved !== void 0) return unapproved;
@@ -26807,30 +27142,218 @@ const decideLanding = (rest, verdicts, branch, approver) => gen(function* () {
26807
27142
  message
26808
27143
  };
26809
27144
  }
27145
+ const landing = pendingTree(rest.setup.workspace, rest.setup.pendingWorktree(), reviewGateRewrite(rest.stepDef));
27146
+ if (rest.stepDef.onFail === "rest" && landing.read(".gtd/run-failed.txt") !== void 0) return {
27147
+ kind: "attempt",
27148
+ subject: formatSubject(rest.actor, rest.state)
27149
+ };
26810
27150
  const outcome = yield* promise(() => replayFor(rest.setup, {
26811
- tree: pendingTree(rest.setup.workspace, rest.setup.pendingWorktree(), reviewGateRewrite(rest.stepDef)),
26812
- ...verdicts !== void 0 ? { verdicts } : {}
27151
+ tree: landing,
27152
+ ...verdicts !== void 0 ? { verdicts } : {},
27153
+ ...shipped !== void 0 ? { shipped } : {}
26813
27154
  }));
26814
27155
  const to = yield* landingTarget(outcome, rest);
26815
27156
  if (typeof to !== "string") return to;
26816
- return landingTo(rest, to, branch, approver, outcomeMarks(outcome));
27157
+ const landed = landingTo(rest, to, branch, approver, outcomeMarks(outcome));
27158
+ if (landed.kind !== "commit" || !isHumanReviewGate(rest.stepDef) || outcome.kind !== "rest" || outcome.rest.request.kind !== "agent" || !reviewEditedBeyondTicks(rest)) return landed;
27159
+ const ticked = yield* promise(() => replayFor(rest.setup, {
27160
+ tree: pendingTree(rest.setup.workspace, rest.setup.pendingWorktree(), void 0),
27161
+ ...verdicts !== void 0 ? { verdicts } : {},
27162
+ ...shipped !== void 0 ? { shipped } : {}
27163
+ }));
27164
+ return ticked.kind === "rest" && ticked.rest.name === outcome.rest.name ? {
27165
+ ...landed,
27166
+ keepTicks: true
27167
+ } : landed;
27168
+ });
27169
+ /** The user's branch: `<b>` for a process on `gtd/<b>` while `<b>` still exists; any other branch as is. */
27170
+ const userBranch = (git) => gen(function* () {
27171
+ const current = yield* git.currentBranch();
27172
+ const name = isSome(current) ? userBranchOf(current.value) : void 0;
27173
+ if (name === void 0) return current;
27174
+ const there = yield* git.readRefOption(`refs/heads/${name}`);
27175
+ return isSome(there) ? some(name) : current;
27176
+ });
27177
+ /** `<b>` for a process on `gtd/<b>` while `<b>` exists: where a finishing landing leaves HEAD. */
27178
+ const returnBranch = gen(function* () {
27179
+ const git = yield* GitService;
27180
+ const current = yield* git.currentBranch();
27181
+ const name = isSome(current) ? userBranchOf(current.value) : void 0;
27182
+ if (name === void 0) return void 0;
27183
+ return isSome(yield* git.readRefOption(`refs/heads/${name}`)) ? name : void 0;
27184
+ });
27185
+ /** A starting landing moves onto `gtd/<b>` first. */
27186
+ const withBranchAndPush = (landing) => gen(function* () {
27187
+ if (landing.kind !== "commit") return landing;
27188
+ let out = landing;
27189
+ if (landing.spec.vars !== void 0) {
27190
+ const start = yield* startBranchFor("gtd land");
27191
+ if (start.kind === "refusal") return {
27192
+ kind: "refusal",
27193
+ message: start.message
27194
+ };
27195
+ if (start.kind === "switch") out = {
27196
+ ...out,
27197
+ startBranch: start.branch
27198
+ };
27199
+ }
27200
+ return yield* withHistory(out);
27201
+ });
27202
+ /** A closing landing switches back to `<b>`; it and a shipping one push their history ref. */
27203
+ const withHistory = (landing) => gen(function* () {
27204
+ let out = landing;
27205
+ const back = landing.keepHistory === void 0 ? void 0 : yield* returnBranch;
27206
+ if (back !== void 0) out = {
27207
+ ...out,
27208
+ leaveBranch: back
27209
+ };
27210
+ if (landing.keepHistory === void 0 && landing.ship === void 0 && landing.recordedHistory === void 0) return out;
27211
+ const remote = (yield* (yield* ConfigService).load).historyRemote;
27212
+ return remote === void 0 ? out : {
27213
+ ...out,
27214
+ pushHistory: remote
27215
+ };
26817
27216
  });
26818
27217
  /** Where landing the pending turn would leave the process, or `undefined` when it would land nothing. */
26819
- const previewLanding = (rest) => decideLanding(rest, void 0, none(), none()).pipe(map((landing) => landing.kind === "commit" ? landing.to : void 0), catch_(() => succeed$1(void 0)));
27218
+ const previewLanding = (rest) => decideLanding(rest, void 0, none(), none(), rest.step.request.kind === "ship" ? SHIPPED_STAND_IN : void 0).pipe(map((landing) => landing.kind === "commit" ? landing.to : void 0), catch_(() => succeed$1(void 0)));
27219
+ const snapshotOf = (rest, landing) => ({
27220
+ stepDef: rest.stepDef,
27221
+ state: rest.state,
27222
+ actor: rest.actor,
27223
+ changes: rest.changes,
27224
+ file: rest.hints.file,
27225
+ landing
27226
+ });
27227
+ const refusedSnapshot = (rest, message) => snapshotOf(rest, {
27228
+ kind: "refusal",
27229
+ message
27230
+ });
27231
+ const SHIPPED_STAND_IN = "0".repeat(40);
27232
+ const SHIP_EXCLUDED = ".gtd/";
27233
+ /** `tree` with file modes read from `ref` (the working tree when `undefined`), for the ship decision only: reading them costs a git call. */
27234
+ const withModes = (workspace, tree, ref) => {
27235
+ let modes;
27236
+ return {
27237
+ ...tree,
27238
+ mode: (path) => (modes ??= workspace.modeSync(ref)).get(path)
27239
+ };
27240
+ };
27241
+ const shippedPaths = (before, after) => {
27242
+ const diff = diffTrees$1(before, after);
27243
+ return [
27244
+ ...diff.added,
27245
+ ...diff.modified,
27246
+ ...diff.deleted
27247
+ ].filter((path) => !path.startsWith(SHIP_EXCLUDED));
27248
+ };
27249
+ const noTarget = (why) => ({
27250
+ kind: "skip",
27251
+ note: `gtd: no branch to ship to: ${why}`
27252
+ });
27253
+ /** `<b>` and its tip, for a process on `gtd/<b>`; why there is none otherwise. */
27254
+ const shipTarget = (git) => gen(function* () {
27255
+ const current = yield* git.currentBranch();
27256
+ if (isNone(current)) return noTarget("HEAD is detached");
27257
+ const branch = userBranchOf(current.value);
27258
+ if (branch === void 0) return noTarget(`${current.value} is no gtd/ branch`);
27259
+ if (userBranchOf(branch) !== void 0) return noTarget(`${current.value} records no user branch`);
27260
+ const parent = yield* git.readRefOption(`refs/heads/${branch}`);
27261
+ if (isNone(parent)) return noTarget(`${branch} does not exist`);
27262
+ return {
27263
+ kind: "ship",
27264
+ branch,
27265
+ parent: parent.value
27266
+ };
27267
+ });
27268
+ /**
27269
+ * The paths `<b>` changed outside this process that shipping `tree` would
27270
+ * revert. A path the working tree changed since HEAD counts as brought in:
27271
+ * whether it carries `<b>`'s change is the person's call, not a merge gtd redoes.
27272
+ */
27273
+ const revertedPaths = (git, workspace, parent, tree, changed, treeRef) => gen(function* () {
27274
+ const head = yield* git.resolveRef("HEAD");
27275
+ const shipped = parseCommitMessage(yield* git.lastCommitMessage(parent)).tip;
27276
+ if (shipped !== void 0 && (yield* git.isAncestor(shipped, head))) return [];
27277
+ const fork = yield* git.mergeBase(parent, head);
27278
+ const foreign = isNone(fork) ? [...changed] : shippedPaths(withModes(workspace, commitTree(workspace, fork.value), fork.value), withModes(workspace, commitTree(workspace, parent), parent));
27279
+ const committed = withModes(workspace, commitTree(workspace, head), head);
27280
+ const moded = withModes(workspace, tree, treeRef);
27281
+ return foreign.filter((path) => changed.has(path) && moded.read(path) === committed.read(path) && moded.mode(path) === committed.mode(path));
27282
+ });
27283
+ /**
27284
+ * Whether landing a ship step with `tree` would ship, and onto what. The squash
27285
+ * replaces the user's branch's tree with `tree`, so it is blocked while that
27286
+ * branch carries a change `tree` would revert: one made since the last ship
27287
+ * (whose `Gtd-Tip` HEAD descends from), or since it forked from HEAD.
27288
+ */
27289
+ const decideShip = (workspace, tree, treeRef) => gen(function* () {
27290
+ const git = yield* GitService;
27291
+ const target = yield* shipTarget(git);
27292
+ if (target.kind !== "ship") return target;
27293
+ const { branch, parent } = target;
27294
+ const changed = new Set(shippedPaths(withModes(workspace, commitTree(workspace, parent), parent), withModes(workspace, tree, treeRef)));
27295
+ const pending = parseCommitMessage(yield* git.lastCommitMessage(parent)).tip;
27296
+ if (changed.size === 0 && pending !== void 0 && pending === (yield* git.resolveRef("HEAD"))) return {
27297
+ kind: "recorded",
27298
+ hash: parent
27299
+ };
27300
+ const reverted = yield* revertedPaths(git, workspace, parent, tree, changed, treeRef);
27301
+ if (reverted.length > 0) return {
27302
+ kind: "blocked",
27303
+ message: `gtd: ${branch} changed outside this process, and shipping would revert ${reverted.join(", ")} — bring those changes into the working tree (\`git merge --squash --no-commit ${branch}\`, or by hand) and land`
27304
+ };
27305
+ return changed.size === 0 ? {
27306
+ kind: "skip",
27307
+ note: `gtd: nothing to ship: ${branch} already has this tree`
27308
+ } : target;
27309
+ });
27310
+ const shipBlockedAt = (setup, pin, step, changes) => gen(function* () {
27311
+ if (pin !== void 0 || step.request.kind !== "ship" || changes.length > 0) return void 0;
27312
+ const head = yield* (yield* GitService).resolveRef("HEAD");
27313
+ const decision = yield* decideShip(setup.workspace, commitTree(setup.workspace, head), head);
27314
+ return decision.kind === "blocked" ? decision.message : void 0;
27315
+ });
27316
+ const shipSnapshot = (rest, message, verdicts, branch, approver) => gen(function* () {
27317
+ const tree = pendingTree(rest.setup.workspace, rest.setup.pendingWorktree(), void 0);
27318
+ const decision = yield* decideShip(rest.setup.workspace, tree, void 0);
27319
+ if (decision.kind === "blocked") return refusedSnapshot(rest, decision.message);
27320
+ const shipping = decision.kind === "ship";
27321
+ const decided = yield* decideLanding(rest, verdicts, branch, approver, decision.kind === "recorded" ? decision.hash : shipping ? SHIPPED_STAND_IN : void 0);
27322
+ if (decided.kind !== "commit") return snapshotOf(rest, decided);
27323
+ if (decision.kind === "recorded") {
27324
+ const recorded = {
27325
+ ...decided,
27326
+ spec: {
27327
+ ...decided.spec,
27328
+ shipped: decision.hash
27329
+ },
27330
+ recordedHistory: processHistoryRef(rest.run.startHash)
27331
+ };
27332
+ return snapshotOf(rest, yield* withBranchAndPush(recorded));
27333
+ }
27334
+ const landing = shipping ? {
27335
+ ...decided,
27336
+ ship: {
27337
+ branch: decision.branch,
27338
+ parent: decision.parent,
27339
+ shipMessage: message,
27340
+ historyRef: processHistoryRef(rest.run.startHash)
27341
+ }
27342
+ } : {
27343
+ ...decided,
27344
+ note: decision.note
27345
+ };
27346
+ return snapshotOf(rest, yield* withBranchAndPush(landing));
27347
+ });
26820
27348
  /** Every fact the pure landing planner needs, read once: the rest, its steering file, and the landing decision. */
26821
27349
  const snapshotFromRest = (rest, verdicts) => gen(function* () {
26822
27350
  const git = yield* GitService;
26823
- const branch = yield* git.currentBranch();
27351
+ const branch = yield* userBranch(git);
26824
27352
  const approver = rest.stepDef.approve === true ? yield* git.authorIdent() : none();
26825
- const landing = yield* decideLanding(rest, verdicts, branch, approver);
26826
- return {
26827
- stepDef: rest.stepDef,
26828
- state: rest.state,
26829
- actor: rest.actor,
26830
- changes: rest.changes,
26831
- file: rest.hints.file,
26832
- landing
26833
- };
27353
+ const request = rest.step.request;
27354
+ if (rest.pin === void 0 && request.kind === "ship") return yield* shipSnapshot(rest, request.message, verdicts, branch, approver);
27355
+ const decided = yield* decideLanding(rest, verdicts, branch, approver);
27356
+ return snapshotOf(rest, yield* withBranchAndPush(decided));
26834
27357
  });
26835
27358
  const humanCommitsOf = (trace, entryCommit) => trace.filter((entry) => entry.actor === "human" && entry.hash !== entryCommit).map((entry) => ({
26836
27359
  hash: entry.hash,
@@ -27957,6 +28480,32 @@ const runStartListCommand = (json, out) => gen(function* () {
27957
28480
  out.write([...workflows.map((name) => `workflow ${name}\n`), ...doors.map(([n, d]) => `door ${doorSynopsis(n, d)} → ${d.workflow}\n`)].join(""));
27958
28481
  });
27959
28482
  /**
28483
+ * Back on `<b>` when the process ran on `gtd/<b>` and `<b>` moved only by its
28484
+ * ships (whose `Gtd-Tip` the tip descends from): the rewind stops at the last
28485
+ * ship, so the uncommitted changes are what it left unshipped.
28486
+ */
28487
+ const abandonTarget = (current, tip, startParent) => gen(function* () {
28488
+ const git = yield* GitService;
28489
+ const userBranch = isSome(current) ? userBranchOf(current.value) : void 0;
28490
+ const userTip = userBranch === void 0 ? none() : yield* git.readRefOption(`refs/heads/${userBranch}`);
28491
+ if (userBranch === void 0 || isNone(userTip)) return {
28492
+ leaves: void 0,
28493
+ rewindTo: startParent
28494
+ };
28495
+ if (userTip.value === startParent) return {
28496
+ leaves: userBranch,
28497
+ rewindTo: startParent
28498
+ };
28499
+ const shipped = parseCommitMessage(yield* git.lastCommitMessage(userTip.value)).tip;
28500
+ return shipped !== void 0 && (yield* git.isAncestor(shipped, tip)) ? {
28501
+ leaves: userBranch,
28502
+ rewindTo: userTip.value
28503
+ } : {
28504
+ leaves: void 0,
28505
+ rewindTo: startParent
28506
+ };
28507
+ });
28508
+ /**
27960
28509
  * Ends the process underway without completing it. Nothing is discarded: a
27961
28510
  * `git reset --mixed` to the process's start commit leaves every turn
27962
28511
  * commit's content in the working tree.
@@ -27971,7 +28520,8 @@ const runStartListCommand = (json, out) => gen(function* () {
27971
28520
  */
27972
28521
  const runAbandonCommand = (json, out) => gen(function* () {
27973
28522
  const git = yield* GitService;
27974
- const initial = (yield* (yield* ConfigService).load).workflow.initial;
28523
+ const config = yield* (yield* ConfigService).load;
28524
+ const initial = config.workflow.initial;
27975
28525
  const run = yield* currentRun;
27976
28526
  if (run.trace.length === 0) {
27977
28527
  const required = emitScripts([{
@@ -27983,6 +28533,10 @@ const runAbandonCommand = (json, out) => gen(function* () {
27983
28533
  const restState = run.trace[run.trace.length - 1].state;
27984
28534
  if (run.startParentHash === EMPTY_TREE) return yield* fail$4(/* @__PURE__ */ new Error(`gtd abandon: the process underway (resting at "${restState}") starts at the repository's first commit — there is no earlier commit to rewind to`));
27985
28535
  const tip = yield* git.resolveRef("HEAD");
28536
+ const historyRef = processHistoryRef(run.startHash);
28537
+ const current = yield* git.currentBranch();
28538
+ const { leaves, rewindTo } = yield* abandonTarget(current, tip, run.startParentHash);
28539
+ const remote = config.historyRemote;
27986
28540
  const steps = [
27987
28541
  {
27988
28542
  kind: "gitWrite",
@@ -27990,21 +28544,60 @@ const runAbandonCommand = (json, out) => gen(function* () {
27990
28544
  },
27991
28545
  {
27992
28546
  kind: "gitWrite",
27993
- command: mixedResetTo(run.startParentHash)
28547
+ command: updateRef(historyRef, tip)
28548
+ },
28549
+ {
28550
+ kind: "gitWrite",
28551
+ command: mixedResetTo(rewindTo)
27994
28552
  },
27995
28553
  {
27996
28554
  kind: "gitWrite",
27997
28555
  command: unpinScript(PIN_PATH)
27998
28556
  },
28557
+ ...leaves !== void 0 ? [
28558
+ {
28559
+ kind: "gitWrite",
28560
+ command: switchTo(leaves)
28561
+ },
28562
+ {
28563
+ kind: "gitWrite",
28564
+ command: deleteBranch(getOrThrow$1(current))
28565
+ },
28566
+ {
28567
+ kind: "gitWrite",
28568
+ command: updateRef(RESUME_BRANCH_REF, tip)
28569
+ }
28570
+ ] : [{
28571
+ kind: "gitWrite",
28572
+ command: deleteRef(RESUME_BRANCH_REF)
28573
+ }],
27999
28574
  {
28000
28575
  kind: "outcome",
28001
- command: abandonedOutcome(restState, run.startParentHash, initial)
28002
- }
28576
+ command: abandonedOutcome(restState, rewindTo, initial)
28577
+ },
28578
+ ...remote !== void 0 ? [{
28579
+ kind: "gitWrite",
28580
+ command: pushRef(remote, historyRef)
28581
+ }] : []
28003
28582
  ];
28004
28583
  const required = emitScripts(steps).required;
28005
28584
  yield* writeScript(json, out, combinedScript(required, ""));
28006
28585
  });
28007
28586
  /**
28587
+ * `gtd/<b>` when the abandon left it for `<b>` and HEAD is still on `<b>`. A
28588
+ * `gtd/<b>` made since is reset only when the retained tip contains it — the
28589
+ * `-C` switch would orphan anything else.
28590
+ */
28591
+ const resumeBranch = (git, tip) => gen(function* () {
28592
+ if (isNone(yield* git.readRefOption("refs/worktree/gtd/history-branch"))) return void 0;
28593
+ const current = yield* git.currentBranch();
28594
+ if (isNone(current) || current.value.startsWith("gtd/")) return void 0;
28595
+ const branch = `${GTD_BRANCH_PREFIX}${current.value}`;
28596
+ const existing = yield* git.readRefOption(`refs/heads/${branch}`);
28597
+ if (isSome(existing) && !(yield* git.isAncestor(existing.value, tip))) return yield* fail$4(/* @__PURE__ */ new Error(`gtd restore: branch ${branch} holds commits the retained tip ${tip.slice(0, 7)} does not — merge or delete it first`));
28598
+ return branch;
28599
+ });
28600
+ /**
28008
28601
  * `gtd restore`: hard-reset HEAD back to the tip `gtd abandon` retained.
28009
28602
  *
28010
28603
  * Guarded by `restorability` so it never discards work it didn't create:
@@ -28018,18 +28611,24 @@ const runRestoreCommand = (json, out) => gen(function* () {
28018
28611
  if (isNone(retained)) return yield* fail$4(/* @__PURE__ */ new Error("gtd restore: no retained history to restore."));
28019
28612
  const tip = retained.value;
28020
28613
  const headHash = yield* git.resolveRef("HEAD");
28021
- const check = yield* restorability(git, headHash, tip);
28614
+ const shippedTip = parseCommitMessage(yield* git.lastCommitMessage(headHash)).tip;
28615
+ const check = shippedTip !== void 0 && (yield* git.isAncestor(shippedTip, tip)) ? { ok: true } : yield* restorability(git, headHash, tip);
28022
28616
  if (!check.ok) return yield* fail$4(/* @__PURE__ */ new Error(`gtd restore: ${check.reason} — HEAD ${headHash.slice(0, 7)} is ahead of the retained tip ${tip.slice(0, 7)}.`));
28023
28617
  const after = yield* restAt(tip);
28618
+ const resumeOn = yield* resumeBranch(git, tip);
28024
28619
  const steps = [
28025
28620
  {
28026
28621
  kind: "gitWrite",
28027
- command: hardResetTo(tip)
28622
+ command: resumeOn === void 0 ? hardResetTo(tip) : switchBranchTo(resumeOn, tip)
28028
28623
  },
28029
28624
  {
28030
28625
  kind: "gitWrite",
28031
28626
  command: deleteRef(HISTORY_REF)
28032
28627
  },
28628
+ {
28629
+ kind: "gitWrite",
28630
+ command: deleteRef(RESUME_BRANCH_REF)
28631
+ },
28033
28632
  {
28034
28633
  kind: "outcome",
28035
28634
  command: restoredOutcome(tip, after.state)
@@ -28249,6 +28848,18 @@ const computeStatusChanges = (changes) => changes.map((change) => ({
28249
28848
  }));
28250
28849
  /** The step the pending change would land the process at. */
28251
28850
  const computeNextMatch = (rest) => map(rest.changes.length === 0 ? succeed$1(void 0) : previewLanding(rest), (target) => target === void 0 ? null : { target });
28851
+ /** A validator retry's content is the correction; a session that cannot resume needs the original prompt too, and one that can hands a driver that loses it `fresh`. */
28852
+ const retryContent = (kind, rendered, resumes) => {
28853
+ if (kind !== "prompt" || rendered.original === void 0) return { rendered };
28854
+ const joined = `${rendered.original}\n\n${rendered.content}`;
28855
+ return resumes ? {
28856
+ rendered,
28857
+ fresh: joined
28858
+ } : { rendered: {
28859
+ ...rendered,
28860
+ content: joined
28861
+ } };
28862
+ };
28252
28863
  /** Everything one beat needs beyond the resolved rest itself, gathered once so plain/`--json` can never describe different rests for the same beat — built as `wire`'s `Demand` (what to do) and `BeatStatus` (what no driver branches on), then flattened by `beatDocument` into the one wire-shaped object plain/`--json` both render from. */
28253
28864
  const gatherBeatDocument = (rest, rendered) => gen(function* () {
28254
28865
  const kind = restBeatKind(rest);
@@ -28256,9 +28867,11 @@ const gatherBeatDocument = (rest, rendered) => gen(function* () {
28256
28867
  const resolvedValidate = kind === "prompt" ? yield* resolveValidateScript(rest).pipe(catch_(() => succeed$1(void 0))) : void 0;
28257
28868
  const validate = resolvedValidate !== void 0 && resolvedValidate.script.length > 0 ? resolvedValidate.script : void 0;
28258
28869
  const log = yield* loopLogPath;
28870
+ const retry = retryContent(kind, rendered, session?.resume === true);
28259
28871
  const demand = demandOf({
28260
- rendered,
28872
+ rendered: retry.rendered,
28261
28873
  kind,
28874
+ ...retry.fresh !== void 0 ? { fresh: retry.fresh } : {},
28262
28875
  ...session !== void 0 ? { session: {
28263
28876
  id: session.sessionId,
28264
28877
  resume: session.resume
@@ -28744,7 +29357,7 @@ gtd writes nothing itself. Plain output names the commit it would make; --json=s
28744
29357
  },
28745
29358
  group: "Start and stop",
28746
29359
  summary: "Start a workflow or door; bare, list them",
28747
- details: `Start a new process: print the script that makes its first commit, which also commits whatever the working tree holds.
29360
+ details: `Start a new process: print the script that makes its first commit, which also commits whatever the working tree holds. The process runs on the branch \`gtd/<b>\` (created, or reused when nothing on it would be lost), never on \`<b>\`; a branch already named \`gtd/…\` and a detached HEAD start in place.
28748
29361
 
28749
29362
  <name> is a door — a named shortcut whose positional args set the workflow's settings — or else a workflow the config exports, which takes no args. A door shadows a workflow of the same name. --var pins a setting either way.
28750
29363
 
@@ -28758,7 +29371,7 @@ With no <name>, list every workflow and door it can start. An unknown <name>, th
28758
29371
  arity: "none",
28759
29372
  group: "Start and stop",
28760
29373
  summary: "Stop the process, keeping its changes",
28761
- details: `Stop the process underway without finishing it. The printed script moves HEAD back to the commit the process started from and keeps everything it produced as uncommitted changes; \`gtd restore\` undoes it. With no process underway the script does nothing.
29374
+ details: `Stop the process underway without finishing it. The printed script moves HEAD back to the commit the process started from, or to its last ship, and keeps everything it produced as uncommitted changes, switches back to \`<b>\` and deletes \`gtd/<b>\`; it keeps the abandoned tip at \`refs/gtd/history/<entry>\` (pushed to \`historyRemote\` when set). \`gtd restore\` undoes it. With no process underway the script does nothing.
28762
29375
 
28763
29376
  s=$(gtd abandon --json=script) && sh -c "$s"`
28764
29377
  },
@@ -28768,7 +29381,7 @@ With no <name>, list every workflow and door it can start. An unknown <name>, th
28768
29381
  arity: "none",
28769
29382
  group: "Start and stop",
28770
29383
  summary: "Bring back the last abandoned process",
28771
- details: `Bring back the process the last \`gtd abandon\` stopped: the printed script resets HEAD to that process's last commit. Refuses on a dirty working tree, when nothing was abandoned, or when commits made since would be lost.`
29384
+ details: `Bring back the process the last \`gtd abandon\` stopped: the printed script puts that process's last commit back, on a recreated \`gtd/<b>\` when the process ran on one. Refuses on a dirty working tree, when nothing was abandoned, or when commits made since would be lost.`
28772
29385
  },
28773
29386
  {
28774
29387
  token: "repin",
@@ -28786,7 +29399,7 @@ With no <name>, list every workflow and door it can start. An unknown <name>, th
28786
29399
  arity: "none",
28787
29400
  group: "Inspect",
28788
29401
  summary: "Print the prompt for the closing message",
28789
- details: `Print the prompt for an agent to write the closing message of the process HEAD closes or sits inside: its first commit, each commit a person made, the diff range to read, and its cost. Writes nothing — what to do with the answer (a squash message, a PR body) is the driver's call.
29402
+ details: `Print the prompt for an agent to write the closing message of the process HEAD closes or sits inside (back on a user branch, the one its gtd/ branch closes): its first commit, each commit a person made, the diff range to read, and its cost. Writes nothing — what to do with the answer (a squash message, a PR body) is the driver's call.
28790
29403
 
28791
29404
  Refuses when the workflow declares no summary template, or when the process has no commits.`
28792
29405
  },
@@ -28926,7 +29539,7 @@ The fixed answerer reads --answers and leaves out the questions it does not cove
28926
29539
  arity: { name: "file" },
28927
29540
  group: "Driver plumbing",
28928
29541
  summary: "Untick a review file's boxes",
28929
- details: `Reset every ticked review box (- [x]) in <file> to - [ ], writing the file only when it changes; a missing file writes nothing. Reads no workflow and runs from any directory — the landing script at a review gate calls it, so no tick is ever committed.`
29542
+ details: `Reset every ticked review box (- [x]) in <file> to - [ ], writing the file only when it changes; a missing file writes nothing. Reads no workflow and runs from any directory — the landing script at a review gate calls it unless the next step is an agent turn, which keeps the ticks.`
28930
29543
  },
28931
29544
  {
28932
29545
  token: "lsp",
@@ -29476,6 +30089,9 @@ function makeHash(message, parent, tree) {
29476
30089
  const treeStr = [...tree.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}:${v}`).join("\n");
29477
30090
  return sha1(`${message}\n${parent ?? "null"}\n${treeStr}`);
29478
30091
  }
30092
+ /** An executable file's content carries this prefix inside the twin, so every tree copy keeps the mode for free; only the read edges strip it. */
30093
+ const EXEC = "\0exec\0";
30094
+ const plain = (content) => content.startsWith(EXEC) ? content.slice(6) : content;
29479
30095
  var InMemRepo = class InMemRepo {
29480
30096
  testDouble = TEST_DOUBLE_SENTINEL;
29481
30097
  commits = /* @__PURE__ */ new Map();
@@ -29589,6 +30205,71 @@ var InMemRepo = class InMemRepo {
29589
30205
  this.worktree = new Map(tree);
29590
30206
  this.index = new Map(tree);
29591
30207
  }
30208
+ /** `git switch -C <name> [<start>]`: pending changes ride along only when the tip's tree is unchanged. */
30209
+ switchBranchTo(name, start) {
30210
+ const hash = start === void 0 ? this.head : this.resolveRef(start);
30211
+ if (hash === null) throw new Error(`invalid reference: ${start ?? "HEAD"}`);
30212
+ this.moveToBranch(name, hash, true);
30213
+ }
30214
+ /** `git switch <name>`: a pending change rides along when its path is the same in both trees. */
30215
+ switchTo(name) {
30216
+ const hash = this.branches.get(name);
30217
+ if (hash === void 0) throw new Error(`invalid reference: ${name}`);
30218
+ if (hash === this.head) return this.moveToBranch(name, hash, false);
30219
+ const from = this.headTree();
30220
+ const to = this.getCommit(hash)?.files ?? /* @__PURE__ */ new Map();
30221
+ const pending = [.../* @__PURE__ */ new Set([
30222
+ ...from.keys(),
30223
+ ...this.index.keys(),
30224
+ ...this.worktree.keys()
30225
+ ])].filter((path) => this.index.get(path) !== from.get(path) || this.worktree.get(path) !== from.get(path));
30226
+ if (pending.some((path) => to.get(path) !== from.get(path))) throw new Error(`cannot switch to '${name}': local changes would be overwritten`);
30227
+ const carry = (side) => {
30228
+ const out = new Map(to);
30229
+ for (const path of pending) {
30230
+ const content = side.get(path);
30231
+ if (content === void 0) out.delete(path);
30232
+ else out.set(path, content);
30233
+ }
30234
+ return out;
30235
+ };
30236
+ this.worktree = carry(this.worktree);
30237
+ this.index = carry(this.index);
30238
+ this.head = hash;
30239
+ this.currentBranch = name;
30240
+ }
30241
+ moveToBranch(name, hash, reset) {
30242
+ if (hash !== this.head) {
30243
+ if (this.statusPorcelain().trim() !== "") throw new Error(`cannot switch to '${name}': the working tree has uncommitted changes`);
30244
+ const tree = this.getCommit(hash)?.files ?? /* @__PURE__ */ new Map();
30245
+ this.head = hash;
30246
+ this.worktree = new Map(tree);
30247
+ this.index = new Map(tree);
30248
+ }
30249
+ if (reset || !this.branches.has(name)) this.branches.set(name, hash);
30250
+ this.currentBranch = name;
30251
+ }
30252
+ deleteBranch(name) {
30253
+ if (name === this.currentBranch) throw new Error(`cannot delete branch '${name}' checked out`);
30254
+ if (!this.branches.delete(name)) throw new Error(`branch '${name}' not found`);
30255
+ }
30256
+ remotes = /* @__PURE__ */ new Map();
30257
+ /** Test setup: a remote that accepts pushes. */
30258
+ addRemote(name) {
30259
+ this.remotes.set(name, /* @__PURE__ */ new Map());
30260
+ }
30261
+ /** What a remote holds after pushes: ref → hash. */
30262
+ remoteRefs(name) {
30263
+ return this.remotes.get(name) ?? /* @__PURE__ */ new Map();
30264
+ }
30265
+ /** `git push --force <remote> <ref>:<ref>`; returns git's complaint, or `undefined`. */
30266
+ pushRef(remote, ref) {
30267
+ const there = this.remotes.get(remote);
30268
+ if (there === void 0) return `fatal: '${remote}' does not appear to be a git repository`;
30269
+ const hash = this.resolveRef(ref);
30270
+ if (hash === null) return `error: src refspec ${ref} does not match any`;
30271
+ there.set(ref, hash);
30272
+ }
29592
30273
  /** Refs under `prefix`, newest tip first; the commit sequence number stands in for the committer date. */
29593
30274
  listRefs(prefix) {
29594
30275
  return [...this.refs.entries()].filter(([ref]) => ref.startsWith(prefix)).map(([ref, hash]) => ({
@@ -29669,7 +30350,8 @@ var InMemRepo = class InMemRepo {
29669
30350
  if (!hash) return null;
29670
30351
  const c = this.getCommit(hash);
29671
30352
  if (!c) return null;
29672
- return c.files.get(path) ?? null;
30353
+ const content = c.files.get(path);
30354
+ return content === void 0 ? null : plain(content);
29673
30355
  }
29674
30356
  /**
29675
30357
  * `base` (default HEAD) mirrors the port's own optional base. Tree-vs-worktree
@@ -29693,7 +30375,18 @@ var InMemRepo = class InMemRepo {
29693
30375
  }
29694
30376
  /** The worktree content of `path`, or `undefined` when absent. A LIVE read — never a snapshot. */
29695
30377
  readFile(path) {
29696
- return this.worktree.get(path);
30378
+ const content = this.worktree.get(path);
30379
+ return content === void 0 ? void 0 : plain(content);
30380
+ }
30381
+ /** `chmod +x` / `-x` on a working-tree file. */
30382
+ setExecutable(path, on) {
30383
+ const content = this.readFile(path);
30384
+ if (content !== void 0) this.worktree.set(path, on ? EXEC + content : content);
30385
+ }
30386
+ /** Every path's file mode at `ref`, or in the working tree when `ref` is `undefined`. */
30387
+ modesAt(ref) {
30388
+ const tree = ref === void 0 ? this.worktree : this.treeAt(ref);
30389
+ return new Map([...tree].map(([path, c]) => [path, c.startsWith(EXEC) ? "100755" : "100644"]));
29697
30390
  }
29698
30391
  /** True when `path` is exactly a worktree file, or a directory prefix of one (any worktree key starts with `path/`). */
29699
30392
  hasPath(path) {
@@ -29821,6 +30514,27 @@ var InMemRepo = class InMemRepo {
29821
30514
  this.index = new Map(tree);
29822
30515
  this.resetHard();
29823
30516
  }
30517
+ /** Twin of `GitScript.shipCommit`: the staged tree, with `.gtd/` as `branch` has it, onto `branch`, then the landing commit naming it. */
30518
+ shipCommit(branch, parent, shipMessage, historyRef, message) {
30519
+ this.stageAll();
30520
+ if (this.head === null) return "fatal: Needed a single revision";
30521
+ if (this.signingFails) return "error: gpg failed to sign the data";
30522
+ const kept = [...this.commits.get(parent)?.files ?? []];
30523
+ const tree = new Map([...[...this.index].filter(([path]) => !path.startsWith(".gtd/")), ...kept.filter(([path]) => path.startsWith(".gtd/"))]);
30524
+ const shipped = `${shipMessage}\n\nGtd-History: ${historyRef}\nGtd-Tip: ${this.head}\n`;
30525
+ const hash = makeHash(shipped, parent, tree);
30526
+ if (this.branches.get(branch) !== parent) return `fatal: update_ref failed for ref 'refs/heads/${branch}': cannot lock ref 'refs/heads/${branch}': is at ${this.branches.get(branch) ?? "nothing"} but expected ${parent}`;
30527
+ this.commits.set(hash, {
30528
+ hash,
30529
+ message: shipped,
30530
+ files: tree,
30531
+ parent,
30532
+ seq: this.nextSeq++,
30533
+ signed: this.signingEnabled
30534
+ });
30535
+ this.branches.set(branch, hash);
30536
+ return this.commitAsIs(`${message}\nGtd-Shipped: ${hash}`);
30537
+ }
29824
30538
  /** Discard every pending change, tracked or untracked: stage everything, then hard-reset (which drops the freshly-staged untracked paths too). */
29825
30539
  discardPending() {
29826
30540
  this.index = new Map(this.worktree);
@@ -29880,7 +30594,8 @@ var InMemRepo = class InMemRepo {
29880
30594
  this.worktree = newWorktree;
29881
30595
  }
29882
30596
  writeFile(path, content) {
29883
- this.worktree.set(path, content);
30597
+ const was = this.worktree.get(path);
30598
+ this.worktree.set(path, was?.startsWith(EXEC) ? EXEC + content : content);
29884
30599
  }
29885
30600
  deleteFile(path) {
29886
30601
  this.worktree.delete(path);
@@ -30108,6 +30823,7 @@ const makeInMemoryWorkspaceOps = (repo, root) => {
30108
30823
  },
30109
30824
  treeSync: treeOf,
30110
30825
  episodeTrees: (base, commits) => succeed$1(new Map([...base !== void 0 ? [base] : [], ...commits].map((h) => [h, treeOf(h)]))),
30826
+ modeSync: (ref) => repo.modesAt(ref),
30111
30827
  worktreeSync: () => new Map(repo.pathsUnder("").filter((path) => !isGitDirKey(path)).map((path) => [path, repo.readFile(path)])),
30112
30828
  atPath: (path) => readAt(toKey(path)),
30113
30829
  writeAtPath: (path, content) => try_({
@@ -30610,6 +31326,18 @@ const recognizeKeepTurn = (repo, block) => {
30610
31326
  status: 1
30611
31327
  };
30612
31328
  };
31329
+ const attempt = (apply) => {
31330
+ try {
31331
+ apply();
31332
+ return { kind: "applied" };
31333
+ } catch (e) {
31334
+ return {
31335
+ kind: "failed",
31336
+ error: `fatal: ${e instanceof Error ? e.message : String(e)}`,
31337
+ status: 128
31338
+ };
31339
+ }
31340
+ };
30613
31341
  const recognizeGitBuilders = (repo, block) => {
30614
31342
  if (block === discardPending()) {
30615
31343
  repo.discardPending();
@@ -30687,6 +31415,58 @@ const recognizeGitBuilders = (repo, block) => {
30687
31415
  }
30688
31416
  return;
30689
31417
  }
31418
+ if (block.startsWith("git switch -q -C ")) {
31419
+ const [branch, start] = extractQuotedTokens(block);
31420
+ if (branch !== void 0 && switchBranchTo(branch, start) === block) return attempt(() => repo.switchBranchTo(branch, start));
31421
+ return;
31422
+ }
31423
+ if (block.startsWith("if ! git switch -q ")) {
31424
+ const [branch, , note] = extractQuotedTokens(block);
31425
+ if (branch !== void 0 && switchBack(branch) === block) try {
31426
+ repo.switchTo(branch);
31427
+ return { kind: "applied" };
31428
+ } catch {
31429
+ return {
31430
+ kind: "noop",
31431
+ output: `${note}\n`
31432
+ };
31433
+ }
31434
+ return;
31435
+ }
31436
+ if (block.startsWith("git switch -q ")) {
31437
+ const [branch] = extractQuotedTokens(block);
31438
+ if (branch !== void 0 && switchTo(branch) === block) return attempt(() => repo.switchTo(branch));
31439
+ return;
31440
+ }
31441
+ if (block.startsWith("git branch -q -D ")) {
31442
+ const [branch] = extractQuotedTokens(block);
31443
+ if (branch !== void 0 && deleteBranch(branch) === block) return attempt(() => repo.deleteBranch(branch));
31444
+ return;
31445
+ }
31446
+ if (block.startsWith("if ! git push --force ")) {
31447
+ const [remote, refspec] = extractQuotedTokens(block);
31448
+ const ref = refspec?.split(":")[0];
31449
+ if (remote !== void 0 && ref !== void 0 && pushRef(remote, ref) === block) {
31450
+ const failure = repo.pushRef(remote, ref);
31451
+ if (failure === void 0) return { kind: "applied" };
31452
+ return {
31453
+ kind: "failed",
31454
+ error: `${failure}\n${extractQuotedTokens(block)[3]}`,
31455
+ status: 1
31456
+ };
31457
+ }
31458
+ return;
31459
+ }
31460
+ if (block.startsWith("gtd_ship_parent=")) {
31461
+ const [parent, branch, history, shipMessage, message] = extractQuotedTokens(block);
31462
+ if (parent === void 0 || branch === void 0 || history === void 0 || shipMessage === void 0 || message === void 0 || shipCommit(branch, parent, shipMessage, history, message) !== block) return;
31463
+ const failure = repo.shipCommit(branch, parent, shipMessage, history, message);
31464
+ return failure === void 0 ? { kind: "applied" } : {
31465
+ kind: "failed",
31466
+ error: failure,
31467
+ status: 1
31468
+ };
31469
+ }
30690
31470
  if (block.startsWith("git update-ref -d ")) {
30691
31471
  const [ref] = extractQuotedTokens(block);
30692
31472
  if (ref !== void 0 && deleteRef(ref) === block) {
@@ -30793,6 +31573,31 @@ const recognizeModeCommand = (repo, commands, block) => {
30793
31573
  } : scripted;
30794
31574
  return recognizeScriptedCommand(repo, /* @__PURE__ */ new Map([[command, bound]]), command);
30795
31575
  };
31576
+ const FAIL_REST_MIDDLE = "\n} 2>&1 )\" || gtd_run_status=$?\n";
31577
+ /** A `run(…, { onFail: "rest" })` body: the inner script's failure is recorded in the tree and the block itself succeeds. */
31578
+ const recognizeFailRest = (repo, commands, block) => {
31579
+ if (!block.startsWith("rm -f -- '") || !block.includes("gtd_run_out=")) return void 0;
31580
+ const [path] = extractQuotedTokens(block);
31581
+ const headerEnd = block.indexOf("gtd_run_out=\"$( {\n");
31582
+ const bodyEnd = block.indexOf(FAIL_REST_MIDDLE);
31583
+ if (path === void 0 || headerEnd === -1 || bodyEnd === -1) return void 0;
31584
+ const body = block.slice(headerEnd + 18, bodyEnd);
31585
+ if (failRestScript(body, path) !== block) return void 0;
31586
+ if (repo.readFile(path) !== void 0) repo.deleteFile(path);
31587
+ const result = applyEmittedScript(repo, commands, body);
31588
+ if (!result.ok && result.error?.startsWith(UNRECOGNIZED_PREFIX)) return {
31589
+ kind: "failed",
31590
+ error: `${result.output}${result.error}`
31591
+ };
31592
+ if (!result.ok) {
31593
+ const lines = result.output.replace(/\n+$/, "").split("\n");
31594
+ repo.writeFile(path, `exit ${result.status}\n${lines.slice(-50).join("\n")}\n`);
31595
+ }
31596
+ return {
31597
+ kind: "noop",
31598
+ output: result.output
31599
+ };
31600
+ };
30796
31601
  const FAILURE_PROMPT_HEADER = "gtd_validate_status=0\ngtd_validate_out=\"$( {\n";
30797
31602
  const FAILURE_PROMPT_MIDDLE = "\n} 2>&1 )\" || gtd_validate_status=$?\nif [ \"$gtd_validate_status\" -ne 0 ]; then\n printf '%s\\n\\n%s\\n' ";
30798
31603
  const FAILURE_PROMPT_FOOTER = " \"$gtd_validate_out\"\n exit \"$gtd_validate_status\"\nfi";
@@ -30945,7 +31750,7 @@ const recognizeGtdCheck = (repo, block) => {
30945
31750
  };
30946
31751
  };
30947
31752
  const GTD_UNCHECK_RE = /^gtd uncheck (.+)$/;
30948
- /** `gtd uncheck <file>` — the review-gate reset prepended ahead of the human's commit, so no tick ever reaches it. Re-runs the REAL `clearTicks`, writing back only on an actual change. */
31753
+ /** `gtd uncheck <file>` — the review-gate reset prepended ahead of the human's commit, absent when the next step is an agent turn. Re-runs the REAL `clearTicks`, writing back only on an actual change. */
30949
31754
  const recognizeGtdUncheck = (repo, block) => {
30950
31755
  const match = GTD_UNCHECK_RE.exec(block);
30951
31756
  if (!match) return void 0;
@@ -31083,6 +31888,7 @@ const recognizersFor = (repo, commands, git) => [
31083
31888
  (block) => git ? recognizeGitBuilders(repo, block) : void 0,
31084
31889
  (block) => recognizeWriteLines(repo, block),
31085
31890
  (block) => recognizePinScripts(repo, block),
31891
+ (block) => recognizeFailRest(repo, commands, block),
31086
31892
  (block) => recognizeFailurePromptWrapper(repo, commands, block),
31087
31893
  (block) => recognizePresentationSubshell(repo, commands, block, git),
31088
31894
  (block) => recognizeModeContradictionSkipNotice(block),