@pmelab/gtd 64.2.0 → 65.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 (59) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +36 -26
  3. package/claude/hooks/assist.ts +9 -35
  4. package/claude/hooks/doors.ts +4 -4
  5. package/claude/hooks/git.ts +56 -0
  6. package/claude/hooks/handoff.ts +11 -11
  7. package/claude/hooks/jobs.ts +8 -8
  8. package/claude/hooks/pr.ts +8 -8
  9. package/claude/hooks/register.tsx +16 -78
  10. package/claude/hooks/tools.ts +7 -20
  11. package/claude/hooks/wording.ts +3 -3
  12. package/dist/chunks/{flows-dnIE_qbe.mjs → flows-DGs0W3HI.mjs} +19 -2
  13. package/dist/chunks/{testkit-BwCH8ky6.mjs → testkit-CgrLmpdJ.mjs} +741 -80
  14. package/dist/chunks/{wire-CaPJ9Tme.mjs → wire-BNS8ciNu.mjs} +5 -3
  15. package/dist/flows.mjs +1 -1
  16. package/dist/gtd.bundle.mjs +565 -114
  17. package/dist/gtd.core.mjs +132 -129
  18. package/dist/testkit/gherkin.mjs +1 -1
  19. package/dist/testkit.mjs +2 -2
  20. package/dist/web/main.js +2 -2
  21. package/dist/wire.mjs +1 -1
  22. package/dist/workflow-kit.mjs +1 -1
  23. package/package.json +1 -1
  24. package/src/cli/Cli.ts +5 -5
  25. package/src/cli/History.test.ts +10 -8
  26. package/src/cli/Metrics.test.ts +2 -1
  27. package/src/cli/Program.test.ts +4 -2
  28. package/src/cli/Program.ts +87 -5
  29. package/src/edge/Edge.test.ts +37 -16
  30. package/src/edge/Edge.ts +296 -31
  31. package/src/emit/GitScript.ts +116 -15
  32. package/src/emit/index.ts +7 -0
  33. package/src/flows/config.ts +7 -1
  34. package/src/flows/index.test.ts +1 -0
  35. package/src/flows/index.ts +2 -0
  36. package/src/flows/runtime.ts +32 -1
  37. package/src/platform/RetainedHistory.ts +3 -0
  38. package/src/platform/index.ts +1 -0
  39. package/src/replay/Replay.test.ts +9 -0
  40. package/src/replay/Replay.ts +59 -15
  41. package/src/replay/Trailers.ts +14 -0
  42. package/src/replay/index.ts +1 -1
  43. package/src/step/LandStep.ts +12 -0
  44. package/src/step/RepoSnapshot.ts +11 -0
  45. package/src/step/index.ts +1 -0
  46. package/src/step/planStart.test.ts +4 -3
  47. package/src/step/planStart.ts +7 -0
  48. package/src/step/planStep.ts +17 -12
  49. package/src/step/startBranch.ts +54 -0
  50. package/src/twins/EmittedScriptRecognizer.ts +117 -0
  51. package/src/twins/EmittedScripts.test.ts +147 -1
  52. package/src/twins/InMemRepo.ts +116 -0
  53. package/src/wire/Demand.ts +22 -10
  54. package/src/wire/constants.ts +3 -0
  55. package/src/wire/types.ts +4 -0
  56. package/src/workflow/Workflow.ts +1 -0
  57. package/src/workflow/compile.ts +2 -0
  58. package/src/workflow/load.ts +2 -0
  59. package/claude/hooks/ship.ts +0 -730
@@ -1,5 +1,5 @@
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";
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-DGs0W3HI.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-BNS8ciNu.mjs";
3
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";
4
4
  import { createRequire, isBuiltin } from "node:module";
5
5
  import * as Crypto 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
  *
@@ -22479,6 +22517,8 @@ const loopLogPath = gen(function* () {
22479
22517
  // src/platform/RetainedHistory.ts
22480
22518
  /** Per-worktree (`refs/worktree/gtd/*`) so linked worktrees sharing one `.git` don't clobber each other's retained history. */
22481
22519
  const HISTORY_REF = "refs/worktree/gtd/history";
22520
+ /** Set by an abandon that left `gtd/<b>` for `<b>`: only then does restore recreate `gtd/<b>`. */
22521
+ const RESUME_BRANCH_REF = "refs/worktree/gtd/history-branch";
22482
22522
  /** One ref per finished process, kept past the branch's deletion; distinct from the per-worktree `HISTORY_REF`. */
22483
22523
  const PROCESS_HISTORY_PREFIX = "refs/gtd/history/";
22484
22524
  const processHistoryRef = (hash) => `${PROCESS_HISTORY_PREFIX}${hash}`;
@@ -23514,6 +23554,12 @@ const TRAILER_READERS = {
23514
23554
  "Gtd-Anchor": (value, into) => {
23515
23555
  if (HASH_RE.test(value)) into.anchor ??= value;
23516
23556
  },
23557
+ "Gtd-Shipped": (value, into) => {
23558
+ if (HASH_RE.test(value)) into.shipped ??= value;
23559
+ },
23560
+ "Gtd-Tip": (value, into) => {
23561
+ if (HASH_RE.test(value)) into.tip ??= value;
23562
+ },
23517
23563
  "Gtd-Format": (value, into) => {
23518
23564
  if (FORMAT_RE.test(value)) into.format ??= Number(value);
23519
23565
  },
@@ -23538,6 +23584,8 @@ const parseCommitMessage = (message) => {
23538
23584
  approved: void 0,
23539
23585
  pin: void 0,
23540
23586
  anchor: void 0,
23587
+ shipped: void 0,
23588
+ tip: void 0,
23541
23589
  judge: [],
23542
23590
  marks: [],
23543
23591
  vars: {},
@@ -23579,7 +23627,7 @@ const namesUnpin = (commit) => {
23579
23627
  const unpin = pinStateIn(commit.parsed.format, "remove");
23580
23628
  return unpin !== void 0 && commit.parsed.parsed?.to === unpin;
23581
23629
  };
23582
- const actorOfKind = (kind) => kind === "run" ? "check" : kind === "agent" ? "agent" : kind === "human" ? "human" : "judge";
23630
+ const actorOfKind = (kind) => kind === "run" || kind === "ship" ? "check" : kind === "agent" ? "agent" : kind === "human" ? "human" : "judge";
23583
23631
  const memoryScopeOf = (name) => {
23584
23632
  const dot = name.lastIndexOf(".");
23585
23633
  return dot === -1 ? "" : name.slice(0, dot);
@@ -23713,9 +23761,12 @@ const KNOWN_OPTIONS = {
23713
23761
  "acceptClean",
23714
23762
  "approve"
23715
23763
  ]),
23716
- run: new Set(STEERING_OPTIONS),
23764
+ run: /* @__PURE__ */ new Set([...STEERING_OPTIONS, "onFail"]),
23765
+ ship: /* @__PURE__ */ new Set(["label", "base"]),
23717
23766
  judge: /* @__PURE__ */ new Set([...STEERING_OPTIONS, "message"])
23718
23767
  };
23768
+ const restsOnFail = (step) => step.request.kind === "run" && step.request.options.onFail === "rest";
23769
+ 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
23770
  const unknownOptions = (step) => {
23720
23771
  const unknown = Object.keys(step.request.options).filter((key) => !KNOWN_OPTIONS[step.kind].has(key));
23721
23772
  if (unknown.length === 0) return void 0;
@@ -23826,15 +23877,15 @@ const replay = async (raw) => {
23826
23877
  return calls;
23827
23878
  };
23828
23879
  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));
23880
+ 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
23881
  const advance = (next) => {
23831
23882
  previousPosition = position;
23832
23883
  position = next;
23833
23884
  };
23834
- const replyFor = (request, verdicts) => request.kind === "judge" ? {
23885
+ const replyFor = (request, verdicts, shipped) => request.kind === "judge" ? {
23835
23886
  answers: answersFrom(verdicts, request.questions),
23836
23887
  truncated: judgeCuts.get(request) ?? []
23837
- } : void 0;
23888
+ } : request.kind === "ship" ? shipped : void 0;
23838
23889
  const judgeCuts = /* @__PURE__ */ new WeakMap();
23839
23890
  const resolveScoped = (memory, pick, configured, bundled) => {
23840
23891
  const own = /* @__PURE__ */ new Map();
@@ -23948,7 +23999,7 @@ const replay = async (raw) => {
23948
23999
  expectedNext = void 0;
23949
24000
  anchorPending = false;
23950
24001
  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);
24002
+ const error = unknownOptions(step) ?? onFailFault(step) ?? approveConflict(step) ?? modeWithoutFile(step) ?? checkIdentity(step);
23952
24003
  return error === void 0 ? void 0 : {
23953
24004
  kind: "failed",
23954
24005
  message: error
@@ -23974,7 +24025,7 @@ const replay = async (raw) => {
23974
24025
  expectNextOf(commit);
23975
24026
  skipPins();
23976
24027
  noteKept(step, commit);
23977
- return replyFor(step.request, commit.parsed.judge);
24028
+ return replyFor(step.request, commit.parsed.judge, commit.parsed.shipped);
23978
24029
  };
23979
24030
  const expectNextOf = (commit) => {
23980
24031
  const to = commit.parsed.parsed?.to;
@@ -24005,10 +24056,14 @@ const replay = async (raw) => {
24005
24056
  hash: "",
24006
24057
  tree: keeping ? position.tree : pending.tree
24007
24058
  });
24008
- return replyFor(step.request, pending.verdicts ?? []);
24059
+ return replyFor(step.request, pending.verdicts ?? [], pending.shipped);
24009
24060
  };
24010
24061
  const answer = (step) => {
24011
- while (cursor < commits.length && isAttemptAt(commits[cursor], step.name)) {
24062
+ while (cursor < commits.length && isAttemptAt(commits[cursor], step)) {
24063
+ if (!isEmptyDiff(diffTrees$1(position.tree, commits[cursor].tree))) advance({
24064
+ hash: commits[cursor].hash,
24065
+ tree: commits[cursor].tree
24066
+ });
24012
24067
  cursor++;
24013
24068
  skipPins();
24014
24069
  }
@@ -24021,12 +24076,22 @@ const replay = async (raw) => {
24021
24076
  landed,
24022
24077
  marks
24023
24078
  });
24079
+ if (restsOnFail(step) && input.pending.tree.read(".gtd/run-failed.txt") !== void 0) return finish({
24080
+ kind: "rest",
24081
+ rest: step,
24082
+ trace,
24083
+ landed,
24084
+ marks
24085
+ });
24024
24086
  return landPending(step, input.pending);
24025
24087
  };
24088
+ const bodyFault = (request) => {
24089
+ 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`;
24090
+ 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)`;
24091
+ };
24026
24092
  const requestFault = (request) => {
24027
24093
  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;
24094
+ if (request.kind !== "agent") return bodyFault(request);
24030
24095
  const name = scoped(request.name);
24031
24096
  if (request.options.keep === true) {
24032
24097
  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`;
@@ -24199,6 +24264,7 @@ const compileConfig = (value, origin) => {
24199
24264
  return {
24200
24265
  modes,
24201
24266
  ...config.ui !== void 0 ? { ui: config.ui } : {},
24267
+ ...config.historyRemote !== void 0 ? { historyRemote: config.historyRemote } : {},
24202
24268
  skills: Object.assign(Object.create(null), config.skills),
24203
24269
  access: Object.assign(Object.create(null), config.access),
24204
24270
  diagnostics: []
@@ -24512,6 +24578,7 @@ const load = gen(function* () {
24512
24578
  return entry === void 0 ? void 0 : resolve(name, entry);
24513
24579
  },
24514
24580
  ...compiled.ui !== void 0 ? { ui: compiled.ui } : {},
24581
+ ...compiled.historyRemote !== void 0 ? { historyRemote: compiled.historyRemote } : {},
24515
24582
  warnings: diagnostics.filter((d) => d.severity === "warning"),
24516
24583
  fingerprint: pinned?.fingerprint ?? configFingerprint(nodeSyncIo, paths, loaded.file.dependencyFiles, host.root, module.read ?? readFromDisk),
24517
24584
  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 +25279,9 @@ const shellQuote = (value) => `'${value.replace(/'/g, "'\\''")}'`;
25212
25279
  * output rather than the exit code, so a genuinely rejected commit (a lint
25213
25280
  * error, a commit-msg hook) still fails.
25214
25281
  */
25215
- const commitAllowEmpty = (message, only) => {
25216
- const m = shellQuote(message);
25282
+ const commitAllowEmpty = (message, only) => commitWordAllowEmpty(shellQuote(message), only);
25283
+ /** `commitAllowEmpty` over a message that is already a shell word. */
25284
+ const commitWordAllowEmpty = (m, only) => {
25217
25285
  const paths = only === void 0 ? "" : ` -- ${shellQuote(only)}`;
25218
25286
  return [
25219
25287
  `if ! out=$(git commit --allow-empty -m ${m}${paths} 2>&1); then`,
@@ -25229,6 +25297,28 @@ const CHANGED_SINCE_LAND = "gtd: the working tree changed since `gtd land` — r
25229
25297
  const commitAll = (message) => `git add -A || exit 1\n${commitAllowEmpty(message)}`;
25230
25298
  /** Commits `path` alone, as the worktree has it; every other pending change stays pending. */
25231
25299
  const commitPath = (path, message) => `git add -A -- ${shellQuote(path)} || exit 1\n${commitAllowEmpty(message, path)}`;
25300
+ /**
25301
+ * A `run()` body that records its own failure instead of failing the beat: the
25302
+ * status and the last 50 output lines go to `path`, and the script exits zero
25303
+ * so the turn lands. The file is COMMITTED (and force-pushed with a history
25304
+ * remote), hence the cap.
25305
+ */
25306
+ const failRestScript = (body, path) => {
25307
+ const p = shellQuote(path);
25308
+ const slash = path.lastIndexOf("/");
25309
+ return [
25310
+ `rm -f -- ${p}`,
25311
+ `gtd_run_status=0`,
25312
+ `gtd_run_out="$( {`,
25313
+ body,
25314
+ `} 2>&1 )" || gtd_run_status=$?`,
25315
+ `printf '%s\\n' "$gtd_run_out"`,
25316
+ `if [ "$gtd_run_status" -ne 0 ]; then`,
25317
+ ` mkdir -p ${shellQuote(path.slice(0, slash))}`,
25318
+ ` printf 'exit %s\\n%s\\n' "$gtd_run_status" "$(printf '%s\\n' "$gtd_run_out" | tail -n 50)" >${p}`,
25319
+ `fi`
25320
+ ].join("\n");
25321
+ };
25232
25322
  /** A pin beat's script. A failed `gtd bundle` leaves no half-written pin behind. */
25233
25323
  const pinScript = (path) => {
25234
25324
  const p = shellQuote(path);
@@ -25304,6 +25394,62 @@ const keepTurn = (refPrefix, message) => {
25304
25394
  `git reset --hard HEAD`
25305
25395
  ].join("\n");
25306
25396
  };
25397
+ /** `git switch -C <branch> [<start>]`: create or reset the branch, carrying pending changes over. */
25398
+ const switchBranchTo = (branch, start) => `git switch -q -C ${shellQuote(branch)}${start === void 0 ? "" : ` ${shellQuote(start)}`}`;
25399
+ /** `git switch <branch>`: an existing branch, carrying pending changes over. */
25400
+ const switchTo = (branch) => `git switch -q ${shellQuote(branch)}`;
25401
+ /** `switchTo` that, when pending changes block it, stays put and says so: the landing already happened. */
25402
+ const switchBack = (branch) => [
25403
+ `if ! git switch -q ${shellQuote(branch)} 2>/dev/null; then`,
25404
+ ` printf '%s\\n' ${shellQuote(`gtd: pending changes keep HEAD off ${branch}; switch to it once they are committed or stashed`)}`,
25405
+ `fi`
25406
+ ].join("\n");
25407
+ const deleteBranch = (branch) => `git branch -q -D ${shellQuote(branch)}`;
25408
+ /**
25409
+ * Force-pushes one ref to a remote. A failed push exits non-zero with the retry
25410
+ * on stderr: it runs after the closing commit has landed, so the commit stands.
25411
+ */
25412
+ const pushRef = (remote, ref) => {
25413
+ const retry = `git push --force ${shellQuote(remote)} ${shellQuote(`${ref}:${ref}`)}`;
25414
+ return [
25415
+ `if ! ${retry}; then`,
25416
+ ` printf '%s\\n' ${shellQuote(`gtd: pushing ${ref} failed — retry: ${retry}`)} >&2`,
25417
+ ` exit 1`,
25418
+ `fi`
25419
+ ].join("\n");
25420
+ };
25421
+ /**
25422
+ * A ship step's landing, in one block because the landing commit's
25423
+ * `Gtd-Shipped` trailer names the squash it makes first. The squash carries
25424
+ * the staged tree with `.gtd/` as `<branch>` has it, built in a scratch index; `<branch>` moves by
25425
+ * compare-and-swap against `parent`, so a branch moved since the decision
25426
+ * fails the script before the landing commits. `commit-tree` ignores
25427
+ * `commit.gpgSign`, so it is read here.
25428
+ */
25429
+ const shipCommit = (branch, parent, shipMessage, historyRef, message) => [
25430
+ `gtd_ship_parent=${shellQuote(parent)}`,
25431
+ `gtd_ship_branch=${shellQuote(branch)}`,
25432
+ `gtd_ship_history=${shellQuote(historyRef)}`,
25433
+ `gtd_ship_message=${shellQuote(shipMessage)}`,
25434
+ `gtd_land_message=${shellQuote(message)}`,
25435
+ `git add -A || exit 1`,
25436
+ `gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
25437
+ `if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
25438
+ `gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
25439
+ `gtd_tree=$(git write-tree) || exit 1`,
25440
+ `gtd_index=$(git rev-parse --git-path gtd-ship-index) || exit 1`,
25441
+ `rm -f -- "$gtd_index"`,
25442
+ `GIT_INDEX_FILE="$gtd_index" git read-tree "$gtd_tree" || exit 1`,
25443
+ `GIT_INDEX_FILE="$gtd_index" git rm -r -q --cached --ignore-unmatch -- .gtd || exit 1`,
25444
+ `git ls-tree -r "$gtd_ship_parent" -- .gtd | GIT_INDEX_FILE="$gtd_index" git update-index --index-info || exit 1`,
25445
+ `gtd_ship_tree=$(GIT_INDEX_FILE="$gtd_index" git write-tree) || exit 1`,
25446
+ `rm -f -- "$gtd_index"`,
25447
+ `gtd_ship_trailers=$(printf "Gtd-History: %s\\nGtd-Tip: %s" "$gtd_ship_history" "$gtd_head")`,
25448
+ `gtd_shipped=$(git commit-tree "$gtd_sign" "$gtd_ship_tree" -p "$gtd_ship_parent" -m "$gtd_ship_message" -m "$gtd_ship_trailers") || exit 1`,
25449
+ `git update-ref -m "gtd: ship" "refs/heads/$gtd_ship_branch" "$gtd_shipped" "$gtd_ship_parent" || exit 1`,
25450
+ `gtd_message=$(printf "%s\\nGtd-Shipped: %s" "$gtd_land_message" "$gtd_shipped")`,
25451
+ commitWordAllowEmpty(`"$gtd_message"`)
25452
+ ].join("\n");
25307
25453
  const updateRef = (ref, hash) => `git update-ref ${shellQuote(ref)} ${shellQuote(hash)}`;
25308
25454
  /** `git update-ref -d <ref>` — idempotent: deleting a missing ref is already a no-op in real git. */
25309
25455
  const deleteRef = (ref) => `git update-ref -d ${shellQuote(ref)}`;
@@ -25332,15 +25478,18 @@ const renderOutcome = (outcome) => {
25332
25478
  case "note": return printfLine$1("%s\n", [shellQuote(outcome.text)]);
25333
25479
  }
25334
25480
  };
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
- }
25481
+ const GIT_WRITES = {
25482
+ commitAll: (w) => commitAll(w.message),
25483
+ commitPath: (w) => commitPath(w.path, w.message),
25484
+ commitClean: (w) => commitClean(w.message),
25485
+ updateRef: (w) => updateRef(w.ref, w.target),
25486
+ keepTurn: (w) => keepTurn(w.ref, w.message),
25487
+ switchBranch: (w) => switchBranchTo(w.branch),
25488
+ switchTo: (w) => switchBack(w.branch),
25489
+ pushRef: (w) => pushRef(w.remote, w.ref),
25490
+ ship: (w) => shipCommit(w.branch, w.parent, w.shipMessage, w.historyRef, w.message)
25343
25491
  };
25492
+ const renderGitWrite = (write) => GIT_WRITES[write.kind](write);
25344
25493
  /**
25345
25494
  * Wraps `command` so a non-zero exit prints `prompt` plus the command's
25346
25495
  * captured output before propagating that exit code — mirrors `Emit.ts`'s
@@ -25731,6 +25880,11 @@ const uncheckStep = (snapshot) => snapshot.stepDef.actor === "human" && snapshot
25731
25880
  file: snapshot.file
25732
25881
  }] : [];
25733
25882
  const gitWriteOf = (landing, message) => {
25883
+ if (landing.ship !== void 0) return {
25884
+ kind: "ship",
25885
+ message,
25886
+ ...landing.ship
25887
+ };
25734
25888
  if (landing.only !== void 0) return {
25735
25889
  kind: "commitPath",
25736
25890
  path: landing.only,
@@ -25750,6 +25904,10 @@ const gitWriteOf = (landing, message) => {
25750
25904
  message
25751
25905
  };
25752
25906
  };
25907
+ const writeIf = (value, write) => value === void 0 ? [] : [{
25908
+ kind: "gitWrite",
25909
+ write: write(value)
25910
+ }];
25753
25911
  const planStep = (snapshot, opts = {}) => {
25754
25912
  const landing = snapshot.landing;
25755
25913
  if (landing.kind === "refusal") return {
@@ -25797,6 +25955,7 @@ const planStep = (snapshot, opts = {}) => {
25797
25955
  ...opts.judge !== void 0 && opts.judge.length > 0 ? { judge: opts.judge } : {}
25798
25956
  });
25799
25957
  const subject = message.split("\n")[0];
25958
+ const historyRef = landing.keepHistory ?? landing.ship?.historyRef;
25800
25959
  const outcome = landing.to === snapshot.state ? {
25801
25960
  kind: "outcome",
25802
25961
  outcome: {
@@ -25817,20 +25976,37 @@ const planStep = (snapshot, opts = {}) => {
25817
25976
  to: landing.to,
25818
25977
  subject,
25819
25978
  steps: [
25979
+ ...writeIf(landing.startBranch, (branch) => ({
25980
+ kind: "switchBranch",
25981
+ branch
25982
+ })),
25820
25983
  ...uncheckStep(snapshot),
25821
25984
  {
25822
25985
  kind: "gitWrite",
25823
25986
  write: gitWriteOf(landing, message)
25824
25987
  },
25825
- ...landing.keepHistory !== void 0 ? [{
25826
- kind: "gitWrite",
25827
- write: {
25828
- kind: "updateRef",
25829
- ref: landing.keepHistory,
25830
- target: "HEAD"
25988
+ ...writeIf(historyRef, (ref) => ({
25989
+ kind: "updateRef",
25990
+ ref,
25991
+ target: "HEAD"
25992
+ })),
25993
+ ...writeIf(landing.leaveBranch, (branch) => ({
25994
+ kind: "switchTo",
25995
+ branch
25996
+ })),
25997
+ outcome,
25998
+ ...landing.note !== void 0 ? [{
25999
+ kind: "outcome",
26000
+ outcome: {
26001
+ kind: "note",
26002
+ text: landing.note
25831
26003
  }
25832
26004
  }] : [],
25833
- outcome
26005
+ ...writeIf(historyRef !== void 0 ? landing.pushHistory : void 0, (remote) => ({
26006
+ kind: "pushRef",
26007
+ remote,
26008
+ ref: historyRef
26009
+ }))
25834
26010
  ]
25835
26011
  };
25836
26012
  };
@@ -25850,6 +26026,42 @@ const accessRefusal = (changes, write) => {
25850
26026
  `allowed: ${write.length === 0 ? "(nothing)" : write.join(", ")}`
25851
26027
  ].join("\n");
25852
26028
  };
26029
+ // src/step/startBranch.ts
26030
+ const GTD_BRANCH_PREFIX = "gtd/";
26031
+ /** `<b>` for a `gtd/<b>` branch name; `undefined` for any other. */
26032
+ const userBranchOf = (branch) => branch.startsWith("gtd/") ? branch.slice(4) : void 0;
26033
+ /**
26034
+ * Where a new process starts: on `gtd/<b>`, so `<b>` never carries its commits.
26035
+ * An existing `gtd/<b>` is reused only when its tip is contained in `<b>` or in
26036
+ * a kept history ref (a squash onto `<b>` never makes it an ancestor).
26037
+ */
26038
+ const startBranchFor = (commandLabel) => gen(function* () {
26039
+ const git = yield* GitService;
26040
+ const current = yield* git.currentBranch();
26041
+ if (isNone(current) || current.value.startsWith("gtd/")) return { kind: "inPlace" };
26042
+ if (!(yield* git.hasCommits())) return { kind: "inPlace" };
26043
+ const branch = `${GTD_BRANCH_PREFIX}${current.value}`;
26044
+ const existing = yield* git.readRefOption(`refs/heads/${branch}`);
26045
+ if (isSome(existing)) {
26046
+ const head = yield* git.resolveRef("HEAD");
26047
+ let contained = yield* git.isAncestor(existing.value, head);
26048
+ if (!contained) {
26049
+ const kept = yield* git.listRefs(PROCESS_HISTORY_PREFIX);
26050
+ for (const k of kept) if (yield* git.isAncestor(existing.value, k.hash)) {
26051
+ contained = true;
26052
+ break;
26053
+ }
26054
+ }
26055
+ if (!contained) return {
26056
+ kind: "refusal",
26057
+ message: `${commandLabel}: branch ${branch} holds commits that are neither on ${current.value} nor in a kept history ref — merge or delete it first`
26058
+ };
26059
+ }
26060
+ return {
26061
+ kind: "switch",
26062
+ branch
26063
+ };
26064
+ });
25853
26065
  // src/step/planStart.ts
25854
26066
  const refusal = (message) => ({
25855
26067
  kind: "refusal",
@@ -25890,29 +26102,42 @@ const planStart = (current, actor, start) => gen(function* () {
25890
26102
  const base = template === void 0 ? void 0 : yield* resolveBase(commandLabel, template);
25891
26103
  if (typeof base === "object") return base;
25892
26104
  const subject = formatSubject(actor, firstStep);
26105
+ const message = formatCommitMessage({
26106
+ actor,
26107
+ to: firstStep,
26108
+ workflow: name,
26109
+ ...base !== void 0 ? { reviewBase: base } : {},
26110
+ vars
26111
+ });
26112
+ const onBranch = yield* startBranchFor(commandLabel);
26113
+ if (onBranch.kind === "refusal") return refusal(onBranch.message);
25893
26114
  return {
25894
26115
  kind: "start",
25895
26116
  workflow: name,
25896
26117
  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
26118
+ steps: [
26119
+ ...onBranch.kind === "switch" ? [{
26120
+ kind: "gitWrite",
26121
+ write: {
26122
+ kind: "switchBranch",
26123
+ branch: onBranch.branch
26124
+ }
26125
+ }] : [],
26126
+ {
26127
+ kind: "gitWrite",
26128
+ write: {
26129
+ kind: "commitAll",
26130
+ message
26131
+ }
26132
+ },
26133
+ {
26134
+ kind: "outcome",
26135
+ outcome: {
26136
+ kind: "commit",
26137
+ subject
26138
+ }
25914
26139
  }
25915
- }]
26140
+ ]
25916
26141
  };
25917
26142
  });
25918
26143
  /**
@@ -26156,14 +26381,32 @@ const runAtTip = (git, def, tip) => gen(function* () {
26156
26381
  if (closing.length === 0) closing = yield* git.commitHistory(void 0, tip.hash);
26157
26382
  return yield* runOf([...before, ...closing], { ...location }, tip.hash);
26158
26383
  });
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. */
26384
+ /**
26385
+ * The process HEAD closes or sits inside — `gtd summary`'s run. On `<b>`, where
26386
+ * a finished process leaves HEAD, it is the one `gtd/<b>`'s tip closes, while
26387
+ * `<b>` holds nothing since: its start, or its last ship.
26388
+ */
26163
26389
  const summaryRun = gen(function* () {
26164
26390
  const git = yield* GitService;
26165
26391
  const def = (yield* (yield* ConfigService).load).workflow;
26166
- return yield* runAtHead(git, def);
26392
+ const head = (yield* git.subjectHistory(1, 0))[0];
26393
+ const left = head === void 0 ? void 0 : yield* leftProcessTip(git, head);
26394
+ return yield* runAtTip(git, def, left ?? head);
26395
+ });
26396
+ const leftProcessTip = (git, head) => gen(function* () {
26397
+ if (parseCommitMessage(head.subject).parsed !== void 0) return void 0;
26398
+ const current = yield* git.currentBranch();
26399
+ if (isNone(current) || userBranchOf(current.value) !== void 0) return void 0;
26400
+ const tip = yield* git.readRefOption(`refs/heads/${GTD_BRANCH_PREFIX}${current.value}`);
26401
+ if (isNone(tip)) return void 0;
26402
+ const message = yield* git.lastCommitMessage(tip.value);
26403
+ if (parseCommitMessage(message).parsed?.to === void 0) return void 0;
26404
+ const since = parseCommitMessage(yield* git.lastCommitMessage(head.hash)).tip ?? head.hash;
26405
+ if (!(yield* git.isAncestor(since, tip.value))) return void 0;
26406
+ return {
26407
+ hash: tip.value,
26408
+ subject: message.split("\n")[0]
26409
+ };
26167
26410
  });
26168
26411
  const commitTree = (workspace, hash) => {
26169
26412
  let entries;
@@ -26297,7 +26540,14 @@ const promptDef = (common, request, skills, access) => ({
26297
26540
  const scriptDef = (common, request) => ({
26298
26541
  ...common,
26299
26542
  kind: "script",
26300
- content: request.body
26543
+ content: request.options.onFail === "rest" ? failRestScript(request.body, RUN_FAILED_PATH) : request.body,
26544
+ ...optional("onFail", request.options.onFail)
26545
+ });
26546
+ const SHIP_CONTENT = noteOutcome("gtd: landing this step ships the process onto your branch");
26547
+ const shipDef = (common) => ({
26548
+ ...common,
26549
+ kind: "script",
26550
+ content: SHIP_CONTENT
26301
26551
  });
26302
26552
  const judgeDef = (common, request, step) => {
26303
26553
  const message = request.options.message ?? DEFAULT_JUDGE_MESSAGE;
@@ -26321,6 +26571,7 @@ const stepDefOf = (step) => {
26321
26571
  switch (request.kind) {
26322
26572
  case "agent": return promptDef(common, request, step.skills, step.access);
26323
26573
  case "run": return scriptDef(common, request);
26574
+ case "ship": return shipDef(common);
26324
26575
  case "judge": return judgeDef(common, request, step);
26325
26576
  case "human": return messageDef(common, request, step);
26326
26577
  }
@@ -26474,6 +26725,7 @@ const restAt = (ref, startOverrides = {}) => gen(function* () {
26474
26725
  path: e.path
26475
26726
  })) : [];
26476
26727
  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`);
26728
+ const shipBlocked = ref === void 0 ? yield* shipBlockedAt(setup, pin, step, changes) : void 0;
26477
26729
  return {
26478
26730
  def,
26479
26731
  workflowName: resolved.name,
@@ -26492,7 +26744,8 @@ const restAt = (ref, startOverrides = {}) => gen(function* () {
26492
26744
  memoryResumed: memory.resumed,
26493
26745
  hints: hintsOf(stepDef),
26494
26746
  cost: costTotals(run.costEntries),
26495
- setup
26747
+ setup,
26748
+ ...shipBlocked !== void 0 ? { shipBlocked } : {}
26496
26749
  };
26497
26750
  });
26498
26751
  const currentRest = restAt(void 0);
@@ -26621,6 +26874,8 @@ const renderRest = (rest) => succeed$1({
26621
26874
  kind: rest.stepDef.kind,
26622
26875
  content: rest.stepDef.content,
26623
26876
  ...rest.hints,
26877
+ ...failedRunStalled(rest) ? { failure: rest.setup.workspace.readSync(".gtd/run-failed.txt") ?? "" } : {},
26878
+ ...rest.shipBlocked !== void 0 ? { blocked: rest.shipBlocked } : {},
26624
26879
  ...rest.memory !== void 0 ? { memory: rest.memory } : {},
26625
26880
  memoryResumed: rest.memoryResumed,
26626
26881
  ...rest.pin !== void 0 ? { pin: rest.pin } : {}
@@ -26629,7 +26884,9 @@ const renderRest = (rest) => succeed$1({
26629
26884
  * Derived, restart-proof stall detection: the tree is clean, HEAD is an empty
26630
26885
  * attempt at this very agent rest, and another dispatch would repeat it.
26631
26886
  */
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;
26887
+ 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;
26888
+ /** A `run(…, { onFail: "rest" })` step whose last turn recorded a failure, on a clean tree. */
26889
+ 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
26890
  /** 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
26891
  const noProcessUnderway = (rest) => rest.pin === void 0 && !underwayAt(rest.run, rest.step, rest.def);
26635
26892
  /** `idle` means exactly one thing: no process underway, clean tree. */
@@ -26794,7 +27051,7 @@ const outcomeMarks = (outcome) => outcome.kind === "rest" || outcome.kind === "e
26794
27051
  * replay with the working tree as the pending turn. The target step is
26795
27052
  * computed by replaying, never matched.
26796
27053
  */
26797
- const decideLanding = (rest, verdicts, branch, approver) => gen(function* () {
27054
+ const decideLanding = (rest, verdicts, branch, approver, shipped) => gen(function* () {
26798
27055
  if (rest.pin !== void 0) return pinLanding(rest, rest.pin, branch);
26799
27056
  const unapproved = approvalRefusal(rest, approver);
26800
27057
  if (unapproved !== void 0) return unapproved;
@@ -26807,30 +27064,183 @@ const decideLanding = (rest, verdicts, branch, approver) => gen(function* () {
26807
27064
  message
26808
27065
  };
26809
27066
  }
27067
+ const landing = pendingTree(rest.setup.workspace, rest.setup.pendingWorktree(), reviewGateRewrite(rest.stepDef));
27068
+ if (rest.stepDef.onFail === "rest" && landing.read(".gtd/run-failed.txt") !== void 0) return {
27069
+ kind: "attempt",
27070
+ subject: formatSubject(rest.actor, rest.state)
27071
+ };
26810
27072
  const outcome = yield* promise(() => replayFor(rest.setup, {
26811
- tree: pendingTree(rest.setup.workspace, rest.setup.pendingWorktree(), reviewGateRewrite(rest.stepDef)),
26812
- ...verdicts !== void 0 ? { verdicts } : {}
27073
+ tree: landing,
27074
+ ...verdicts !== void 0 ? { verdicts } : {},
27075
+ ...shipped !== void 0 ? { shipped } : {}
26813
27076
  }));
26814
27077
  const to = yield* landingTarget(outcome, rest);
26815
27078
  if (typeof to !== "string") return to;
26816
27079
  return landingTo(rest, to, branch, approver, outcomeMarks(outcome));
26817
27080
  });
27081
+ /** The user's branch: `<b>` for a process on `gtd/<b>` while `<b>` still exists; any other branch as is. */
27082
+ const userBranch = (git) => gen(function* () {
27083
+ const current = yield* git.currentBranch();
27084
+ const name = isSome(current) ? userBranchOf(current.value) : void 0;
27085
+ if (name === void 0) return current;
27086
+ const there = yield* git.readRefOption(`refs/heads/${name}`);
27087
+ return isSome(there) ? some(name) : current;
27088
+ });
27089
+ /** `<b>` for a process on `gtd/<b>` while `<b>` exists: where a finishing landing leaves HEAD. */
27090
+ const returnBranch = gen(function* () {
27091
+ const git = yield* GitService;
27092
+ const current = yield* git.currentBranch();
27093
+ const name = isSome(current) ? userBranchOf(current.value) : void 0;
27094
+ if (name === void 0) return void 0;
27095
+ return isSome(yield* git.readRefOption(`refs/heads/${name}`)) ? name : void 0;
27096
+ });
27097
+ /** A starting landing moves onto `gtd/<b>` first. */
27098
+ const withBranchAndPush = (landing) => gen(function* () {
27099
+ if (landing.kind !== "commit") return landing;
27100
+ let out = landing;
27101
+ if (landing.spec.vars !== void 0) {
27102
+ const start = yield* startBranchFor("gtd land");
27103
+ if (start.kind === "refusal") return {
27104
+ kind: "refusal",
27105
+ message: start.message
27106
+ };
27107
+ if (start.kind === "switch") out = {
27108
+ ...out,
27109
+ startBranch: start.branch
27110
+ };
27111
+ }
27112
+ return yield* withHistory(out);
27113
+ });
27114
+ /** A closing landing switches back to `<b>`; it and a shipping one push their history ref. */
27115
+ const withHistory = (landing) => gen(function* () {
27116
+ let out = landing;
27117
+ const back = landing.keepHistory === void 0 ? void 0 : yield* returnBranch;
27118
+ if (back !== void 0) out = {
27119
+ ...out,
27120
+ leaveBranch: back
27121
+ };
27122
+ if (landing.keepHistory === void 0 && landing.ship === void 0) return out;
27123
+ const remote = (yield* (yield* ConfigService).load).historyRemote;
27124
+ return remote === void 0 ? out : {
27125
+ ...out,
27126
+ pushHistory: remote
27127
+ };
27128
+ });
26818
27129
  /** 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)));
27130
+ 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)));
27131
+ const snapshotOf = (rest, landing) => ({
27132
+ stepDef: rest.stepDef,
27133
+ state: rest.state,
27134
+ actor: rest.actor,
27135
+ changes: rest.changes,
27136
+ file: rest.hints.file,
27137
+ landing
27138
+ });
27139
+ const refusedSnapshot = (rest, message) => snapshotOf(rest, {
27140
+ kind: "refusal",
27141
+ message
27142
+ });
27143
+ const SHIPPED_STAND_IN = "0".repeat(40);
27144
+ const SHIP_EXCLUDED = ".gtd/";
27145
+ const shippedPaths = (before, after) => {
27146
+ const diff = diffTrees$1(before, after);
27147
+ return [
27148
+ ...diff.added,
27149
+ ...diff.modified,
27150
+ ...diff.deleted
27151
+ ].filter((path) => !path.startsWith(SHIP_EXCLUDED));
27152
+ };
27153
+ const noTarget = (why) => ({
27154
+ kind: "skip",
27155
+ note: `gtd: no branch to ship to: ${why}`
27156
+ });
27157
+ /** `<b>` and its tip, for a process on `gtd/<b>`; why there is none otherwise. */
27158
+ const shipTarget = (git) => gen(function* () {
27159
+ const current = yield* git.currentBranch();
27160
+ if (isNone(current)) return noTarget("HEAD is detached");
27161
+ const branch = userBranchOf(current.value);
27162
+ if (branch === void 0) return noTarget(`${current.value} is no gtd/ branch`);
27163
+ if (userBranchOf(branch) !== void 0) return noTarget(`${current.value} records no user branch`);
27164
+ const parent = yield* git.readRefOption(`refs/heads/${branch}`);
27165
+ if (isNone(parent)) return noTarget(`${branch} does not exist`);
27166
+ return {
27167
+ kind: "ship",
27168
+ branch,
27169
+ parent: parent.value
27170
+ };
27171
+ });
27172
+ /**
27173
+ * The paths `<b>` changed outside this process that shipping `tree` would
27174
+ * revert. A path the working tree changed since HEAD counts as brought in:
27175
+ * whether it carries `<b>`'s change is the person's call, not a merge gtd redoes.
27176
+ */
27177
+ const revertedPaths = (git, workspace, parent, tree, changed) => gen(function* () {
27178
+ const head = yield* git.resolveRef("HEAD");
27179
+ const shipped = parseCommitMessage(yield* git.lastCommitMessage(parent)).tip;
27180
+ if (shipped !== void 0 && (yield* git.isAncestor(shipped, head))) return [];
27181
+ const fork = yield* git.mergeBase(parent, head);
27182
+ const foreign = isNone(fork) ? [...changed] : shippedPaths(commitTree(workspace, fork.value), commitTree(workspace, parent));
27183
+ const committed = commitTree(workspace, head);
27184
+ return foreign.filter((path) => changed.has(path) && tree.read(path) === committed.read(path));
27185
+ });
27186
+ /**
27187
+ * Whether landing a ship step with `tree` would ship, and onto what. The squash
27188
+ * replaces the user's branch's tree with `tree`, so it is blocked while that
27189
+ * branch carries a change `tree` would revert: one made since the last ship
27190
+ * (whose `Gtd-Tip` HEAD descends from), or since it forked from HEAD.
27191
+ */
27192
+ const decideShip = (workspace, tree) => gen(function* () {
27193
+ const git = yield* GitService;
27194
+ const target = yield* shipTarget(git);
27195
+ if (target.kind !== "ship") return target;
27196
+ const { branch, parent } = target;
27197
+ const changed = new Set(shippedPaths(commitTree(workspace, parent), tree));
27198
+ const reverted = yield* revertedPaths(git, workspace, parent, tree, changed);
27199
+ if (reverted.length > 0) return {
27200
+ kind: "blocked",
27201
+ 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`
27202
+ };
27203
+ return changed.size === 0 ? {
27204
+ kind: "skip",
27205
+ note: `gtd: nothing to ship: ${branch} already has this tree`
27206
+ } : target;
27207
+ });
27208
+ const shipBlockedAt = (setup, pin, step, changes) => gen(function* () {
27209
+ if (pin !== void 0 || step.request.kind !== "ship" || changes.length > 0) return void 0;
27210
+ const head = yield* (yield* GitService).resolveRef("HEAD");
27211
+ const decision = yield* decideShip(setup.workspace, commitTree(setup.workspace, head));
27212
+ return decision.kind === "blocked" ? decision.message : void 0;
27213
+ });
27214
+ const shipSnapshot = (rest, message, verdicts, branch, approver) => gen(function* () {
27215
+ const tree = pendingTree(rest.setup.workspace, rest.setup.pendingWorktree(), void 0);
27216
+ const decision = yield* decideShip(rest.setup.workspace, tree);
27217
+ if (decision.kind === "blocked") return refusedSnapshot(rest, decision.message);
27218
+ const shipping = decision.kind === "ship";
27219
+ const decided = yield* decideLanding(rest, verdicts, branch, approver, shipping ? SHIPPED_STAND_IN : void 0);
27220
+ if (decided.kind !== "commit") return snapshotOf(rest, decided);
27221
+ const landing = shipping ? {
27222
+ ...decided,
27223
+ ship: {
27224
+ branch: decision.branch,
27225
+ parent: decision.parent,
27226
+ shipMessage: message,
27227
+ historyRef: processHistoryRef(rest.run.startHash)
27228
+ }
27229
+ } : {
27230
+ ...decided,
27231
+ note: decision.note
27232
+ };
27233
+ return snapshotOf(rest, yield* withBranchAndPush(landing));
27234
+ });
26820
27235
  /** Every fact the pure landing planner needs, read once: the rest, its steering file, and the landing decision. */
26821
27236
  const snapshotFromRest = (rest, verdicts) => gen(function* () {
26822
27237
  const git = yield* GitService;
26823
- const branch = yield* git.currentBranch();
27238
+ const branch = yield* userBranch(git);
26824
27239
  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
- };
27240
+ const request = rest.step.request;
27241
+ if (rest.pin === void 0 && request.kind === "ship") return yield* shipSnapshot(rest, request.message, verdicts, branch, approver);
27242
+ const decided = yield* decideLanding(rest, verdicts, branch, approver);
27243
+ return snapshotOf(rest, yield* withBranchAndPush(decided));
26834
27244
  });
26835
27245
  const humanCommitsOf = (trace, entryCommit) => trace.filter((entry) => entry.actor === "human" && entry.hash !== entryCommit).map((entry) => ({
26836
27246
  hash: entry.hash,
@@ -27957,6 +28367,32 @@ const runStartListCommand = (json, out) => gen(function* () {
27957
28367
  out.write([...workflows.map((name) => `workflow ${name}\n`), ...doors.map(([n, d]) => `door ${doorSynopsis(n, d)} → ${d.workflow}\n`)].join(""));
27958
28368
  });
27959
28369
  /**
28370
+ * Back on `<b>` when the process ran on `gtd/<b>` and `<b>` moved only by its
28371
+ * ships (whose `Gtd-Tip` the tip descends from): the rewind stops at the last
28372
+ * ship, so the uncommitted changes are what it left unshipped.
28373
+ */
28374
+ const abandonTarget = (current, tip, startParent) => gen(function* () {
28375
+ const git = yield* GitService;
28376
+ const userBranch = isSome(current) ? userBranchOf(current.value) : void 0;
28377
+ const userTip = userBranch === void 0 ? none() : yield* git.readRefOption(`refs/heads/${userBranch}`);
28378
+ if (userBranch === void 0 || isNone(userTip)) return {
28379
+ leaves: void 0,
28380
+ rewindTo: startParent
28381
+ };
28382
+ if (userTip.value === startParent) return {
28383
+ leaves: userBranch,
28384
+ rewindTo: startParent
28385
+ };
28386
+ const shipped = parseCommitMessage(yield* git.lastCommitMessage(userTip.value)).tip;
28387
+ return shipped !== void 0 && (yield* git.isAncestor(shipped, tip)) ? {
28388
+ leaves: userBranch,
28389
+ rewindTo: userTip.value
28390
+ } : {
28391
+ leaves: void 0,
28392
+ rewindTo: startParent
28393
+ };
28394
+ });
28395
+ /**
27960
28396
  * Ends the process underway without completing it. Nothing is discarded: a
27961
28397
  * `git reset --mixed` to the process's start commit leaves every turn
27962
28398
  * commit's content in the working tree.
@@ -27971,7 +28407,8 @@ const runStartListCommand = (json, out) => gen(function* () {
27971
28407
  */
27972
28408
  const runAbandonCommand = (json, out) => gen(function* () {
27973
28409
  const git = yield* GitService;
27974
- const initial = (yield* (yield* ConfigService).load).workflow.initial;
28410
+ const config = yield* (yield* ConfigService).load;
28411
+ const initial = config.workflow.initial;
27975
28412
  const run = yield* currentRun;
27976
28413
  if (run.trace.length === 0) {
27977
28414
  const required = emitScripts([{
@@ -27983,6 +28420,10 @@ const runAbandonCommand = (json, out) => gen(function* () {
27983
28420
  const restState = run.trace[run.trace.length - 1].state;
27984
28421
  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
28422
  const tip = yield* git.resolveRef("HEAD");
28423
+ const historyRef = processHistoryRef(run.startHash);
28424
+ const current = yield* git.currentBranch();
28425
+ const { leaves, rewindTo } = yield* abandonTarget(current, tip, run.startParentHash);
28426
+ const remote = config.historyRemote;
27986
28427
  const steps = [
27987
28428
  {
27988
28429
  kind: "gitWrite",
@@ -27990,21 +28431,60 @@ const runAbandonCommand = (json, out) => gen(function* () {
27990
28431
  },
27991
28432
  {
27992
28433
  kind: "gitWrite",
27993
- command: mixedResetTo(run.startParentHash)
28434
+ command: updateRef(historyRef, tip)
28435
+ },
28436
+ {
28437
+ kind: "gitWrite",
28438
+ command: mixedResetTo(rewindTo)
27994
28439
  },
27995
28440
  {
27996
28441
  kind: "gitWrite",
27997
28442
  command: unpinScript(PIN_PATH)
27998
28443
  },
28444
+ ...leaves !== void 0 ? [
28445
+ {
28446
+ kind: "gitWrite",
28447
+ command: switchTo(leaves)
28448
+ },
28449
+ {
28450
+ kind: "gitWrite",
28451
+ command: deleteBranch(getOrThrow$1(current))
28452
+ },
28453
+ {
28454
+ kind: "gitWrite",
28455
+ command: updateRef(RESUME_BRANCH_REF, tip)
28456
+ }
28457
+ ] : [{
28458
+ kind: "gitWrite",
28459
+ command: deleteRef(RESUME_BRANCH_REF)
28460
+ }],
27999
28461
  {
28000
28462
  kind: "outcome",
28001
- command: abandonedOutcome(restState, run.startParentHash, initial)
28002
- }
28463
+ command: abandonedOutcome(restState, rewindTo, initial)
28464
+ },
28465
+ ...remote !== void 0 ? [{
28466
+ kind: "gitWrite",
28467
+ command: pushRef(remote, historyRef)
28468
+ }] : []
28003
28469
  ];
28004
28470
  const required = emitScripts(steps).required;
28005
28471
  yield* writeScript(json, out, combinedScript(required, ""));
28006
28472
  });
28007
28473
  /**
28474
+ * `gtd/<b>` when the abandon left it for `<b>` and HEAD is still on `<b>`. A
28475
+ * `gtd/<b>` made since is reset only when the retained tip contains it — the
28476
+ * `-C` switch would orphan anything else.
28477
+ */
28478
+ const resumeBranch = (git, tip) => gen(function* () {
28479
+ if (isNone(yield* git.readRefOption("refs/worktree/gtd/history-branch"))) return void 0;
28480
+ const current = yield* git.currentBranch();
28481
+ if (isNone(current) || current.value.startsWith("gtd/")) return void 0;
28482
+ const branch = `${GTD_BRANCH_PREFIX}${current.value}`;
28483
+ const existing = yield* git.readRefOption(`refs/heads/${branch}`);
28484
+ 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`));
28485
+ return branch;
28486
+ });
28487
+ /**
28008
28488
  * `gtd restore`: hard-reset HEAD back to the tip `gtd abandon` retained.
28009
28489
  *
28010
28490
  * Guarded by `restorability` so it never discards work it didn't create:
@@ -28021,15 +28501,20 @@ const runRestoreCommand = (json, out) => gen(function* () {
28021
28501
  const check = yield* restorability(git, headHash, tip);
28022
28502
  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
28503
  const after = yield* restAt(tip);
28504
+ const resumeOn = yield* resumeBranch(git, tip);
28024
28505
  const steps = [
28025
28506
  {
28026
28507
  kind: "gitWrite",
28027
- command: hardResetTo(tip)
28508
+ command: resumeOn === void 0 ? hardResetTo(tip) : switchBranchTo(resumeOn, tip)
28028
28509
  },
28029
28510
  {
28030
28511
  kind: "gitWrite",
28031
28512
  command: deleteRef(HISTORY_REF)
28032
28513
  },
28514
+ {
28515
+ kind: "gitWrite",
28516
+ command: deleteRef(RESUME_BRANCH_REF)
28517
+ },
28033
28518
  {
28034
28519
  kind: "outcome",
28035
28520
  command: restoredOutcome(tip, after.state)
@@ -28744,7 +29229,7 @@ gtd writes nothing itself. Plain output names the commit it would make; --json=s
28744
29229
  },
28745
29230
  group: "Start and stop",
28746
29231
  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.
29232
+ 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
29233
 
28749
29234
  <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
29235
 
@@ -28758,7 +29243,7 @@ With no <name>, list every workflow and door it can start. An unknown <name>, th
28758
29243
  arity: "none",
28759
29244
  group: "Start and stop",
28760
29245
  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.
29246
+ 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
29247
 
28763
29248
  s=$(gtd abandon --json=script) && sh -c "$s"`
28764
29249
  },
@@ -28768,7 +29253,7 @@ With no <name>, list every workflow and door it can start. An unknown <name>, th
28768
29253
  arity: "none",
28769
29254
  group: "Start and stop",
28770
29255
  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.`
29256
+ 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
29257
  },
28773
29258
  {
28774
29259
  token: "repin",
@@ -28786,7 +29271,7 @@ With no <name>, list every workflow and door it can start. An unknown <name>, th
28786
29271
  arity: "none",
28787
29272
  group: "Inspect",
28788
29273
  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.
29274
+ 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
29275
 
28791
29276
  Refuses when the workflow declares no summary template, or when the process has no commits.`
28792
29277
  },
@@ -28868,7 +29353,7 @@ Prints a sorted JSON array ([] when clean) and a summary on stderr; --verbose ad
28868
29353
  arity: "none",
28869
29354
  group: "Inspect",
28870
29355
  summary: "Serve the web and phone client",
28871
- details: `Serve gtd's web and phone client for this worktree. By default it publishes through \`tailscale serve\`, reachable from anywhere on the tailnet, on the first of ports 8443, 10000 and 443 that works; when none does it serves HTTPS locally instead of refusing.`
29356
+ details: `Serve gtd's web and phone client for this worktree. By default it publishes through \`tailscale serve\`, reachable from anywhere on the tailnet, on the first of ports 8443, 10000 and 443 that works; when none does it serves HTTPS locally instead of refusing. Done saves the steering file and keeps serving; the server exits when the worktree moves on to another step, or on a signal.`
28872
29357
  },
28873
29358
  {
28874
29359
  token: "guide",
@@ -29589,6 +30074,71 @@ var InMemRepo = class InMemRepo {
29589
30074
  this.worktree = new Map(tree);
29590
30075
  this.index = new Map(tree);
29591
30076
  }
30077
+ /** `git switch -C <name> [<start>]`: pending changes ride along only when the tip's tree is unchanged. */
30078
+ switchBranchTo(name, start) {
30079
+ const hash = start === void 0 ? this.head : this.resolveRef(start);
30080
+ if (hash === null) throw new Error(`invalid reference: ${start ?? "HEAD"}`);
30081
+ this.moveToBranch(name, hash, true);
30082
+ }
30083
+ /** `git switch <name>`: a pending change rides along when its path is the same in both trees. */
30084
+ switchTo(name) {
30085
+ const hash = this.branches.get(name);
30086
+ if (hash === void 0) throw new Error(`invalid reference: ${name}`);
30087
+ if (hash === this.head) return this.moveToBranch(name, hash, false);
30088
+ const from = this.headTree();
30089
+ const to = this.getCommit(hash)?.files ?? /* @__PURE__ */ new Map();
30090
+ const pending = [.../* @__PURE__ */ new Set([
30091
+ ...from.keys(),
30092
+ ...this.index.keys(),
30093
+ ...this.worktree.keys()
30094
+ ])].filter((path) => this.index.get(path) !== from.get(path) || this.worktree.get(path) !== from.get(path));
30095
+ if (pending.some((path) => to.get(path) !== from.get(path))) throw new Error(`cannot switch to '${name}': local changes would be overwritten`);
30096
+ const carry = (side) => {
30097
+ const out = new Map(to);
30098
+ for (const path of pending) {
30099
+ const content = side.get(path);
30100
+ if (content === void 0) out.delete(path);
30101
+ else out.set(path, content);
30102
+ }
30103
+ return out;
30104
+ };
30105
+ this.worktree = carry(this.worktree);
30106
+ this.index = carry(this.index);
30107
+ this.head = hash;
30108
+ this.currentBranch = name;
30109
+ }
30110
+ moveToBranch(name, hash, reset) {
30111
+ if (hash !== this.head) {
30112
+ if (this.statusPorcelain().trim() !== "") throw new Error(`cannot switch to '${name}': the working tree has uncommitted changes`);
30113
+ const tree = this.getCommit(hash)?.files ?? /* @__PURE__ */ new Map();
30114
+ this.head = hash;
30115
+ this.worktree = new Map(tree);
30116
+ this.index = new Map(tree);
30117
+ }
30118
+ if (reset || !this.branches.has(name)) this.branches.set(name, hash);
30119
+ this.currentBranch = name;
30120
+ }
30121
+ deleteBranch(name) {
30122
+ if (name === this.currentBranch) throw new Error(`cannot delete branch '${name}' checked out`);
30123
+ if (!this.branches.delete(name)) throw new Error(`branch '${name}' not found`);
30124
+ }
30125
+ remotes = /* @__PURE__ */ new Map();
30126
+ /** Test setup: a remote that accepts pushes. */
30127
+ addRemote(name) {
30128
+ this.remotes.set(name, /* @__PURE__ */ new Map());
30129
+ }
30130
+ /** What a remote holds after pushes: ref → hash. */
30131
+ remoteRefs(name) {
30132
+ return this.remotes.get(name) ?? /* @__PURE__ */ new Map();
30133
+ }
30134
+ /** `git push --force <remote> <ref>:<ref>`; returns git's complaint, or `undefined`. */
30135
+ pushRef(remote, ref) {
30136
+ const there = this.remotes.get(remote);
30137
+ if (there === void 0) return `fatal: '${remote}' does not appear to be a git repository`;
30138
+ const hash = this.resolveRef(ref);
30139
+ if (hash === null) return `error: src refspec ${ref} does not match any`;
30140
+ there.set(ref, hash);
30141
+ }
29592
30142
  /** Refs under `prefix`, newest tip first; the commit sequence number stands in for the committer date. */
29593
30143
  listRefs(prefix) {
29594
30144
  return [...this.refs.entries()].filter(([ref]) => ref.startsWith(prefix)).map(([ref, hash]) => ({
@@ -29821,6 +30371,27 @@ var InMemRepo = class InMemRepo {
29821
30371
  this.index = new Map(tree);
29822
30372
  this.resetHard();
29823
30373
  }
30374
+ /** Twin of `GitScript.shipCommit`: the staged tree, with `.gtd/` as `branch` has it, onto `branch`, then the landing commit naming it. */
30375
+ shipCommit(branch, parent, shipMessage, historyRef, message) {
30376
+ this.stageAll();
30377
+ if (this.head === null) return "fatal: Needed a single revision";
30378
+ if (this.signingFails) return "error: gpg failed to sign the data";
30379
+ const kept = [...this.commits.get(parent)?.files ?? []];
30380
+ const tree = new Map([...[...this.index].filter(([path]) => !path.startsWith(".gtd/")), ...kept.filter(([path]) => path.startsWith(".gtd/"))]);
30381
+ const shipped = `${shipMessage}\n\nGtd-History: ${historyRef}\nGtd-Tip: ${this.head}\n`;
30382
+ const hash = makeHash(shipped, parent, tree);
30383
+ 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}`;
30384
+ this.commits.set(hash, {
30385
+ hash,
30386
+ message: shipped,
30387
+ files: tree,
30388
+ parent,
30389
+ seq: this.nextSeq++,
30390
+ signed: this.signingEnabled
30391
+ });
30392
+ this.branches.set(branch, hash);
30393
+ return this.commitAsIs(`${message}\nGtd-Shipped: ${hash}`);
30394
+ }
29824
30395
  /** Discard every pending change, tracked or untracked: stage everything, then hard-reset (which drops the freshly-staged untracked paths too). */
29825
30396
  discardPending() {
29826
30397
  this.index = new Map(this.worktree);
@@ -30610,6 +31181,18 @@ const recognizeKeepTurn = (repo, block) => {
30610
31181
  status: 1
30611
31182
  };
30612
31183
  };
31184
+ const attempt = (apply) => {
31185
+ try {
31186
+ apply();
31187
+ return { kind: "applied" };
31188
+ } catch (e) {
31189
+ return {
31190
+ kind: "failed",
31191
+ error: `fatal: ${e instanceof Error ? e.message : String(e)}`,
31192
+ status: 128
31193
+ };
31194
+ }
31195
+ };
30613
31196
  const recognizeGitBuilders = (repo, block) => {
30614
31197
  if (block === discardPending()) {
30615
31198
  repo.discardPending();
@@ -30687,6 +31270,58 @@ const recognizeGitBuilders = (repo, block) => {
30687
31270
  }
30688
31271
  return;
30689
31272
  }
31273
+ if (block.startsWith("git switch -q -C ")) {
31274
+ const [branch, start] = extractQuotedTokens(block);
31275
+ if (branch !== void 0 && switchBranchTo(branch, start) === block) return attempt(() => repo.switchBranchTo(branch, start));
31276
+ return;
31277
+ }
31278
+ if (block.startsWith("if ! git switch -q ")) {
31279
+ const [branch, , note] = extractQuotedTokens(block);
31280
+ if (branch !== void 0 && switchBack(branch) === block) try {
31281
+ repo.switchTo(branch);
31282
+ return { kind: "applied" };
31283
+ } catch {
31284
+ return {
31285
+ kind: "noop",
31286
+ output: `${note}\n`
31287
+ };
31288
+ }
31289
+ return;
31290
+ }
31291
+ if (block.startsWith("git switch -q ")) {
31292
+ const [branch] = extractQuotedTokens(block);
31293
+ if (branch !== void 0 && switchTo(branch) === block) return attempt(() => repo.switchTo(branch));
31294
+ return;
31295
+ }
31296
+ if (block.startsWith("git branch -q -D ")) {
31297
+ const [branch] = extractQuotedTokens(block);
31298
+ if (branch !== void 0 && deleteBranch(branch) === block) return attempt(() => repo.deleteBranch(branch));
31299
+ return;
31300
+ }
31301
+ if (block.startsWith("if ! git push --force ")) {
31302
+ const [remote, refspec] = extractQuotedTokens(block);
31303
+ const ref = refspec?.split(":")[0];
31304
+ if (remote !== void 0 && ref !== void 0 && pushRef(remote, ref) === block) {
31305
+ const failure = repo.pushRef(remote, ref);
31306
+ if (failure === void 0) return { kind: "applied" };
31307
+ return {
31308
+ kind: "failed",
31309
+ error: `${failure}\n${extractQuotedTokens(block)[3]}`,
31310
+ status: 1
31311
+ };
31312
+ }
31313
+ return;
31314
+ }
31315
+ if (block.startsWith("gtd_ship_parent=")) {
31316
+ const [parent, branch, history, shipMessage, message] = extractQuotedTokens(block);
31317
+ if (parent === void 0 || branch === void 0 || history === void 0 || shipMessage === void 0 || message === void 0 || shipCommit(branch, parent, shipMessage, history, message) !== block) return;
31318
+ const failure = repo.shipCommit(branch, parent, shipMessage, history, message);
31319
+ return failure === void 0 ? { kind: "applied" } : {
31320
+ kind: "failed",
31321
+ error: failure,
31322
+ status: 1
31323
+ };
31324
+ }
30690
31325
  if (block.startsWith("git update-ref -d ")) {
30691
31326
  const [ref] = extractQuotedTokens(block);
30692
31327
  if (ref !== void 0 && deleteRef(ref) === block) {
@@ -30793,6 +31428,31 @@ const recognizeModeCommand = (repo, commands, block) => {
30793
31428
  } : scripted;
30794
31429
  return recognizeScriptedCommand(repo, /* @__PURE__ */ new Map([[command, bound]]), command);
30795
31430
  };
31431
+ const FAIL_REST_MIDDLE = "\n} 2>&1 )\" || gtd_run_status=$?\n";
31432
+ /** A `run(…, { onFail: "rest" })` body: the inner script's failure is recorded in the tree and the block itself succeeds. */
31433
+ const recognizeFailRest = (repo, commands, block) => {
31434
+ if (!block.startsWith("rm -f -- '") || !block.includes("gtd_run_out=")) return void 0;
31435
+ const [path] = extractQuotedTokens(block);
31436
+ const headerEnd = block.indexOf("gtd_run_out=\"$( {\n");
31437
+ const bodyEnd = block.indexOf(FAIL_REST_MIDDLE);
31438
+ if (path === void 0 || headerEnd === -1 || bodyEnd === -1) return void 0;
31439
+ const body = block.slice(headerEnd + 18, bodyEnd);
31440
+ if (failRestScript(body, path) !== block) return void 0;
31441
+ if (repo.readFile(path) !== void 0) repo.deleteFile(path);
31442
+ const result = applyEmittedScript(repo, commands, body);
31443
+ if (!result.ok && result.error?.startsWith(UNRECOGNIZED_PREFIX)) return {
31444
+ kind: "failed",
31445
+ error: `${result.output}${result.error}`
31446
+ };
31447
+ if (!result.ok) {
31448
+ const lines = result.output.replace(/\n+$/, "").split("\n");
31449
+ repo.writeFile(path, `exit ${result.status}\n${lines.slice(-50).join("\n")}\n`);
31450
+ }
31451
+ return {
31452
+ kind: "noop",
31453
+ output: result.output
31454
+ };
31455
+ };
30796
31456
  const FAILURE_PROMPT_HEADER = "gtd_validate_status=0\ngtd_validate_out=\"$( {\n";
30797
31457
  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
31458
  const FAILURE_PROMPT_FOOTER = " \"$gtd_validate_out\"\n exit \"$gtd_validate_status\"\nfi";
@@ -31083,6 +31743,7 @@ const recognizersFor = (repo, commands, git) => [
31083
31743
  (block) => git ? recognizeGitBuilders(repo, block) : void 0,
31084
31744
  (block) => recognizeWriteLines(repo, block),
31085
31745
  (block) => recognizePinScripts(repo, block),
31746
+ (block) => recognizeFailRest(repo, commands, block),
31086
31747
  (block) => recognizeFailurePromptWrapper(repo, commands, block),
31087
31748
  (block) => recognizePresentationSubshell(repo, commands, block, git),
31088
31749
  (block) => recognizeModeContradictionSkipNotice(block),