@pmelab/gtd 65.0.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 (63) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +3 -2
  3. package/claude/hooks/drive.ts +10 -1
  4. package/claude/hooks/git.ts +2 -2
  5. package/claude/hooks/pr.ts +80 -20
  6. package/claude/hooks/register.tsx +19 -4
  7. package/dist/chunks/{flows-DGs0W3HI.mjs → flows-CmQplvh1.mjs} +134 -40
  8. package/dist/chunks/{steering-DEh_MS40.mjs → steering-CMrePUiY.mjs} +115 -17
  9. package/dist/chunks/{testkit-CgrLmpdJ.mjs → testkit-BJhAAVGt.mjs} +179 -34
  10. package/dist/chunks/{wire-BNS8ciNu.mjs → wire-xTbzFc26.mjs} +7 -3
  11. package/dist/flows.mjs +1 -1
  12. package/dist/gtd.bundle.mjs +430 -100
  13. package/dist/gtd.core.mjs +147 -134
  14. package/dist/steering.mjs +1 -1
  15. package/dist/testkit/gherkin.mjs +1 -1
  16. package/dist/testkit.mjs +2 -2
  17. package/dist/web/main.css +1 -1
  18. package/dist/web/main.js +185 -18
  19. package/dist/wire.mjs +1 -1
  20. package/dist/workflow-kit.mjs +9 -3
  21. package/package.json +1 -1
  22. package/src/cli/Cli.ts +1 -1
  23. package/src/cli/Program.ts +18 -2
  24. package/src/edge/Edge.ts +93 -11
  25. package/src/flows/helpers.ts +36 -18
  26. package/src/flows/index.test.ts +3 -0
  27. package/src/flows/index.ts +5 -0
  28. package/src/flows/runtime.ts +123 -16
  29. package/src/install/Install.ts +9 -2
  30. package/src/platform/Git.test.ts +7 -11
  31. package/src/platform/NodeSync.ts +2 -0
  32. package/src/platform/SyncIo.ts +2 -0
  33. package/src/platform/Workspace.test.ts +37 -0
  34. package/src/platform/Workspace.ts +36 -0
  35. package/src/replay/Replay.ts +33 -1
  36. package/src/replay/Trailers.ts +3 -0
  37. package/src/replay/Tree.ts +5 -0
  38. package/src/steering/FORMATS.md +3 -1
  39. package/src/steering/SteeringFormat.ts +13 -0
  40. package/src/steering/fixtures/review/sectioned.md +1 -1
  41. package/src/steering/fixtures/review/sectioned.read.json +0 -1
  42. package/src/steering/index.ts +1 -0
  43. package/src/steering/review.test.ts +181 -5
  44. package/src/steering/review.ts +160 -18
  45. package/src/step/RepoSnapshot.ts +4 -0
  46. package/src/step/planStep.ts +6 -6
  47. package/src/twins/EmittedScriptRecognizer.ts +1 -1
  48. package/src/twins/InMemRepo.ts +24 -3
  49. package/src/twins/Layers.ts +1 -0
  50. package/src/twins/SyncIo.test.ts +19 -4
  51. package/src/twins/SyncIo.ts +13 -1
  52. package/src/wire/BeatDocument.test.ts +15 -0
  53. package/src/wire/BeatDocument.ts +8 -2
  54. package/src/wire/Demand.ts +4 -2
  55. package/src/wire/golden/1/beat-capture.json +1 -0
  56. package/src/wire/golden/1/beat-message.json +1 -0
  57. package/src/wire/golden/1/beat-prompt.json +1 -0
  58. package/src/wire/golden/1/beat-script.json +1 -0
  59. package/src/wire/golden/1/beat-stalled.json +1 -0
  60. package/src/workflow/Workflow.ts +2 -0
  61. package/src/workflow-kit/index.test.ts +1 -0
  62. package/src/workflow-kit/index.ts +1 -0
  63. package/src/workflow-kit/scenarios.ts +8 -0
@@ -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-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
- 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";
@@ -20354,7 +20354,8 @@ const orAbsent = (read) => {
20354
20354
  };
20355
20355
  const infoOf = (stats) => ({
20356
20356
  type: stats.isSymbolicLink() ? "symlink" : stats.isDirectory() ? "directory" : stats.isFile() ? "file" : "other",
20357
- 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 }
20358
20359
  });
20359
20360
  const nodeSyncIo = {
20360
20361
  readFile: (path) => orAbsent(() => readFileSync(path)),
@@ -20824,6 +20825,15 @@ const applyRawDiff = (tree, entries) => {
20824
20825
  tree.set(entry.path, entry.newId);
20825
20826
  }
20826
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
+ };
20827
20837
  /** `git ls-tree -r -z --full-tree <ref>`'s output, parsed into `path → blob id`. */
20828
20838
  const parseLsTree = (out) => {
20829
20839
  const entries = /* @__PURE__ */ new Map();
@@ -21056,6 +21066,34 @@ const makeWorkspaceOps = ({ root, pid }, git, commonDir, paths, io) => {
21056
21066
  }
21057
21067
  return new Map([...entries].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
21058
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
+ };
21059
21097
  /** A submodule's (or embedded repository's) checked-out commit — what `git add` records for it. */
21060
21098
  const gitlinkOf = (path) => {
21061
21099
  try {
@@ -21073,6 +21111,7 @@ const makeWorkspaceOps = ({ root, pid }, git, commonDir, paths, io) => {
21073
21111
  return {
21074
21112
  treeSync,
21075
21113
  worktreeSync,
21114
+ modeSync,
21076
21115
  episodeTrees,
21077
21116
  readSync,
21078
21117
  read: (path) => try_({
@@ -22730,6 +22769,7 @@ while :; do
22730
22769
  model="$(gtd next --json=model)"
22731
22770
  system="$(gtd next --json=system)"
22732
22771
  validate="$(gtd next --json=validate)"
22772
+ fresh="$(gtd next --json=fresh)"
22733
22773
  log="$(gtd next --json=log)"
22734
22774
  # \`model\` is a hint, not a model name — gtd-workflows' default ships
22735
22775
  # \`smart\` and \`base\`, which \`claude --model\` rejects. Resolve them
@@ -22772,8 +22812,14 @@ while :; do
22772
22812
  then first=--resume second=--session-id
22773
22813
  else first=--session-id second=--resume
22774
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
22775
22821
  turn "$first" "$session_id" <"$log.prompt" ||
22776
- { [ "$ran" = false ] && turn "$second" "$session_id" <"$log.prompt"; } || {
22822
+ { [ "$ran" = false ] && turn "$second" "$session_id" <"$second_prompt"; } || {
22777
22823
  echo "agent turn failed — see $log" >&2
22778
22824
  exit 1
22779
22825
  }
@@ -22787,7 +22833,7 @@ while :; do
22787
22833
  exit 1
22788
22834
  }
22789
22835
  done
22790
- rm -f "$log.prompt" ;;
22836
+ rm -f "$log.prompt" "$log.fresh" ;;
22791
22837
  esac
22792
22838
 
22793
22839
  # Read every value the landing needs BEFORE piping the script to \`sh\`:
@@ -23379,6 +23425,9 @@ const treeFromRecord = (files) => ({
23379
23425
  });
23380
23426
  const diffTrees$1 = (before, after) => {
23381
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
+ }
23382
23431
  const a = before.id?.(path);
23383
23432
  const b = after.id?.(path);
23384
23433
  return a !== void 0 && b !== void 0 ? a === b : before.read(path) === after.read(path);
@@ -23618,7 +23667,8 @@ const formatCommitMessage = (spec) => {
23618
23667
  ...trailer("Anchor", spec.anchor),
23619
23668
  ...trailer("Branch", spec.branch),
23620
23669
  ...trailer("Version", spec.version),
23621
- `Gtd-Format: 4`
23670
+ `Gtd-Format: 4`,
23671
+ ...trailer("Shipped", spec.shipped)
23622
23672
  ];
23623
23673
  return `${formatSubject(spec.actor, spec.to, spec.from)}\n\n${lines.join("\n")}`;
23624
23674
  };
@@ -23753,7 +23803,11 @@ const KNOWN_OPTIONS = {
23753
23803
  "model",
23754
23804
  "system",
23755
23805
  "allowEmpty",
23756
- "keep"
23806
+ "keep",
23807
+ "validate",
23808
+ "cap",
23809
+ "exhausted",
23810
+ "correction"
23757
23811
  ]),
23758
23812
  human: /* @__PURE__ */ new Set([
23759
23813
  ...STEERING_OPTIONS,
@@ -23765,6 +23819,18 @@ const KNOWN_OPTIONS = {
23765
23819
  ship: /* @__PURE__ */ new Set(["label", "base"]),
23766
23820
  judge: /* @__PURE__ */ new Set([...STEERING_OPTIONS, "message"])
23767
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
+ };
23768
23834
  const restsOnFail = (step) => step.request.kind === "run" && step.request.options.onFail === "rest";
23769
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;
23770
23836
  const unknownOptions = (step) => {
@@ -24093,6 +24159,8 @@ const replay = async (raw) => {
24093
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`;
24094
24160
  if (request.kind !== "agent") return bodyFault(request);
24095
24161
  const name = scoped(request.name);
24162
+ const optionFault = validatorOptionFault(name, request.options);
24163
+ if (optionFault !== void 0) return optionFault;
24096
24164
  if (request.options.keep === true) {
24097
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`;
24098
24166
  const refFault = name.includes("/") ? "it contains \"/\"" : refNameFault(name);
@@ -25871,11 +25939,10 @@ const validateScriptFor = (resolved, file) => gen(function* () {
25871
25939
  });
25872
25940
  // src/step/planStep.ts
25873
25941
  /**
25874
- * At the human review gate an unconditional `gtd uncheck '<file>'` runs
25875
- * ahead of the commit, resetting every checkbox before `git add -A` picks it
25876
- * 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.
25877
25944
  */
25878
- 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 ? [{
25879
25946
  kind: "uncheck",
25880
25947
  file: snapshot.file
25881
25948
  }] : [];
@@ -25955,7 +26022,7 @@ const planStep = (snapshot, opts = {}) => {
25955
26022
  ...opts.judge !== void 0 && opts.judge.length > 0 ? { judge: opts.judge } : {}
25956
26023
  });
25957
26024
  const subject = message.split("\n")[0];
25958
- const historyRef = landing.keepHistory ?? landing.ship?.historyRef;
26025
+ const historyRef = landing.keepHistory ?? landing.ship?.historyRef ?? landing.recordedHistory;
25959
26026
  const outcome = landing.to === snapshot.state ? {
25960
26027
  kind: "outcome",
25961
26028
  outcome: {
@@ -25980,7 +26047,7 @@ const planStep = (snapshot, opts = {}) => {
25980
26047
  kind: "switchBranch",
25981
26048
  branch
25982
26049
  })),
25983
- ...uncheckStep(snapshot),
26050
+ ...uncheckStep(snapshot, landing.keepTicks === true),
25984
26051
  {
25985
26052
  kind: "gitWrite",
25986
26053
  write: gitWriteOf(landing, message)
@@ -26534,6 +26601,7 @@ const promptDef = (common, request, skills, access) => ({
26534
26601
  ...optional("system", request.options.system),
26535
26602
  ...optional("allowEmpty", request.options.allowEmpty),
26536
26603
  ...optional("keep", request.options.keep),
26604
+ ...optional("original", request.original),
26537
26605
  ...optional("skills", skills),
26538
26606
  ...optional("access", access)
26539
26607
  });
@@ -26878,6 +26946,7 @@ const renderRest = (rest) => succeed$1({
26878
26946
  ...rest.shipBlocked !== void 0 ? { blocked: rest.shipBlocked } : {},
26879
26947
  ...rest.memory !== void 0 ? { memory: rest.memory } : {},
26880
26948
  memoryResumed: rest.memoryResumed,
26949
+ ...rest.stepDef.original !== void 0 ? { original: rest.stepDef.original } : {},
26881
26950
  ...rest.pin !== void 0 ? { pin: rest.pin } : {}
26882
26951
  });
26883
26952
  /**
@@ -26924,7 +26993,7 @@ const cleanLanding = (rest) => {
26924
26993
  settled: false
26925
26994
  };
26926
26995
  };
26927
- /** 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. */
26928
26997
  const reviewGateRewrite = (def) => {
26929
26998
  const reviewFormat = steeringFormatFor("review");
26930
26999
  if (!isHumanReviewGate(def) || def.file === void 0 || reviewFormat === void 0) return;
@@ -26933,6 +27002,15 @@ const reviewGateRewrite = (def) => {
26933
27002
  apply: (content) => reviewFormat.clearTicks(content)
26934
27003
  };
26935
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
+ };
26936
27014
  const multilineRefusal = (vars) => {
26937
27015
  const multiline = multilineSetting(vars);
26938
27016
  return multiline === void 0 ? void 0 : {
@@ -27076,7 +27154,17 @@ const decideLanding = (rest, verdicts, branch, approver, shipped) => gen(functio
27076
27154
  }));
27077
27155
  const to = yield* landingTarget(outcome, rest);
27078
27156
  if (typeof to !== "string") return to;
27079
- 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;
27080
27168
  });
27081
27169
  /** The user's branch: `<b>` for a process on `gtd/<b>` while `<b>` still exists; any other branch as is. */
27082
27170
  const userBranch = (git) => gen(function* () {
@@ -27119,7 +27207,7 @@ const withHistory = (landing) => gen(function* () {
27119
27207
  ...out,
27120
27208
  leaveBranch: back
27121
27209
  };
27122
- if (landing.keepHistory === void 0 && landing.ship === void 0) return out;
27210
+ if (landing.keepHistory === void 0 && landing.ship === void 0 && landing.recordedHistory === void 0) return out;
27123
27211
  const remote = (yield* (yield* ConfigService).load).historyRemote;
27124
27212
  return remote === void 0 ? out : {
27125
27213
  ...out,
@@ -27142,6 +27230,14 @@ const refusedSnapshot = (rest, message) => snapshotOf(rest, {
27142
27230
  });
27143
27231
  const SHIPPED_STAND_IN = "0".repeat(40);
27144
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
+ };
27145
27241
  const shippedPaths = (before, after) => {
27146
27242
  const diff = diffTrees$1(before, after);
27147
27243
  return [
@@ -27174,14 +27270,15 @@ const shipTarget = (git) => gen(function* () {
27174
27270
  * revert. A path the working tree changed since HEAD counts as brought in:
27175
27271
  * whether it carries `<b>`'s change is the person's call, not a merge gtd redoes.
27176
27272
  */
27177
- const revertedPaths = (git, workspace, parent, tree, changed) => gen(function* () {
27273
+ const revertedPaths = (git, workspace, parent, tree, changed, treeRef) => gen(function* () {
27178
27274
  const head = yield* git.resolveRef("HEAD");
27179
27275
  const shipped = parseCommitMessage(yield* git.lastCommitMessage(parent)).tip;
27180
27276
  if (shipped !== void 0 && (yield* git.isAncestor(shipped, head))) return [];
27181
27277
  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));
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));
27185
27282
  });
27186
27283
  /**
27187
27284
  * Whether landing a ship step with `tree` would ship, and onto what. The squash
@@ -27189,13 +27286,18 @@ const revertedPaths = (git, workspace, parent, tree, changed) => gen(function* (
27189
27286
  * branch carries a change `tree` would revert: one made since the last ship
27190
27287
  * (whose `Gtd-Tip` HEAD descends from), or since it forked from HEAD.
27191
27288
  */
27192
- const decideShip = (workspace, tree) => gen(function* () {
27289
+ const decideShip = (workspace, tree, treeRef) => gen(function* () {
27193
27290
  const git = yield* GitService;
27194
27291
  const target = yield* shipTarget(git);
27195
27292
  if (target.kind !== "ship") return target;
27196
27293
  const { branch, parent } = target;
27197
- const changed = new Set(shippedPaths(commitTree(workspace, parent), tree));
27198
- const reverted = yield* revertedPaths(git, workspace, parent, tree, changed);
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);
27199
27301
  if (reverted.length > 0) return {
27200
27302
  kind: "blocked",
27201
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`
@@ -27208,16 +27310,27 @@ const decideShip = (workspace, tree) => gen(function* () {
27208
27310
  const shipBlockedAt = (setup, pin, step, changes) => gen(function* () {
27209
27311
  if (pin !== void 0 || step.request.kind !== "ship" || changes.length > 0) return void 0;
27210
27312
  const head = yield* (yield* GitService).resolveRef("HEAD");
27211
- const decision = yield* decideShip(setup.workspace, commitTree(setup.workspace, head));
27313
+ const decision = yield* decideShip(setup.workspace, commitTree(setup.workspace, head), head);
27212
27314
  return decision.kind === "blocked" ? decision.message : void 0;
27213
27315
  });
27214
27316
  const shipSnapshot = (rest, message, verdicts, branch, approver) => gen(function* () {
27215
27317
  const tree = pendingTree(rest.setup.workspace, rest.setup.pendingWorktree(), void 0);
27216
- const decision = yield* decideShip(rest.setup.workspace, tree);
27318
+ const decision = yield* decideShip(rest.setup.workspace, tree, void 0);
27217
27319
  if (decision.kind === "blocked") return refusedSnapshot(rest, decision.message);
27218
27320
  const shipping = decision.kind === "ship";
27219
- const decided = yield* decideLanding(rest, verdicts, branch, approver, shipping ? SHIPPED_STAND_IN : void 0);
27321
+ const decided = yield* decideLanding(rest, verdicts, branch, approver, decision.kind === "recorded" ? decision.hash : shipping ? SHIPPED_STAND_IN : void 0);
27220
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
+ }
27221
27334
  const landing = shipping ? {
27222
27335
  ...decided,
27223
27336
  ship: {
@@ -28498,7 +28611,8 @@ const runRestoreCommand = (json, out) => gen(function* () {
28498
28611
  if (isNone(retained)) return yield* fail$4(/* @__PURE__ */ new Error("gtd restore: no retained history to restore."));
28499
28612
  const tip = retained.value;
28500
28613
  const headHash = yield* git.resolveRef("HEAD");
28501
- 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);
28502
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)}.`));
28503
28617
  const after = yield* restAt(tip);
28504
28618
  const resumeOn = yield* resumeBranch(git, tip);
@@ -28734,6 +28848,18 @@ const computeStatusChanges = (changes) => changes.map((change) => ({
28734
28848
  }));
28735
28849
  /** The step the pending change would land the process at. */
28736
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
+ };
28737
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. */
28738
28864
  const gatherBeatDocument = (rest, rendered) => gen(function* () {
28739
28865
  const kind = restBeatKind(rest);
@@ -28741,9 +28867,11 @@ const gatherBeatDocument = (rest, rendered) => gen(function* () {
28741
28867
  const resolvedValidate = kind === "prompt" ? yield* resolveValidateScript(rest).pipe(catch_(() => succeed$1(void 0))) : void 0;
28742
28868
  const validate = resolvedValidate !== void 0 && resolvedValidate.script.length > 0 ? resolvedValidate.script : void 0;
28743
28869
  const log = yield* loopLogPath;
28870
+ const retry = retryContent(kind, rendered, session?.resume === true);
28744
28871
  const demand = demandOf({
28745
- rendered,
28872
+ rendered: retry.rendered,
28746
28873
  kind,
28874
+ ...retry.fresh !== void 0 ? { fresh: retry.fresh } : {},
28747
28875
  ...session !== void 0 ? { session: {
28748
28876
  id: session.sessionId,
28749
28877
  resume: session.resume
@@ -29411,7 +29539,7 @@ The fixed answerer reads --answers and leaves out the questions it does not cove
29411
29539
  arity: { name: "file" },
29412
29540
  group: "Driver plumbing",
29413
29541
  summary: "Untick a review file's boxes",
29414
- 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.`
29415
29543
  },
29416
29544
  {
29417
29545
  token: "lsp",
@@ -29961,6 +30089,9 @@ function makeHash(message, parent, tree) {
29961
30089
  const treeStr = [...tree.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}:${v}`).join("\n");
29962
30090
  return sha1(`${message}\n${parent ?? "null"}\n${treeStr}`);
29963
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;
29964
30095
  var InMemRepo = class InMemRepo {
29965
30096
  testDouble = TEST_DOUBLE_SENTINEL;
29966
30097
  commits = /* @__PURE__ */ new Map();
@@ -30219,7 +30350,8 @@ var InMemRepo = class InMemRepo {
30219
30350
  if (!hash) return null;
30220
30351
  const c = this.getCommit(hash);
30221
30352
  if (!c) return null;
30222
- return c.files.get(path) ?? null;
30353
+ const content = c.files.get(path);
30354
+ return content === void 0 ? null : plain(content);
30223
30355
  }
30224
30356
  /**
30225
30357
  * `base` (default HEAD) mirrors the port's own optional base. Tree-vs-worktree
@@ -30243,7 +30375,18 @@ var InMemRepo = class InMemRepo {
30243
30375
  }
30244
30376
  /** The worktree content of `path`, or `undefined` when absent. A LIVE read — never a snapshot. */
30245
30377
  readFile(path) {
30246
- 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"]));
30247
30390
  }
30248
30391
  /** True when `path` is exactly a worktree file, or a directory prefix of one (any worktree key starts with `path/`). */
30249
30392
  hasPath(path) {
@@ -30451,7 +30594,8 @@ var InMemRepo = class InMemRepo {
30451
30594
  this.worktree = newWorktree;
30452
30595
  }
30453
30596
  writeFile(path, content) {
30454
- this.worktree.set(path, content);
30597
+ const was = this.worktree.get(path);
30598
+ this.worktree.set(path, was?.startsWith(EXEC) ? EXEC + content : content);
30455
30599
  }
30456
30600
  deleteFile(path) {
30457
30601
  this.worktree.delete(path);
@@ -30679,6 +30823,7 @@ const makeInMemoryWorkspaceOps = (repo, root) => {
30679
30823
  },
30680
30824
  treeSync: treeOf,
30681
30825
  episodeTrees: (base, commits) => succeed$1(new Map([...base !== void 0 ? [base] : [], ...commits].map((h) => [h, treeOf(h)]))),
30826
+ modeSync: (ref) => repo.modesAt(ref),
30682
30827
  worktreeSync: () => new Map(repo.pathsUnder("").filter((path) => !isGitDirKey(path)).map((path) => [path, repo.readFile(path)])),
30683
30828
  atPath: (path) => readAt(toKey(path)),
30684
30829
  writeAtPath: (path, content) => try_({
@@ -31605,7 +31750,7 @@ const recognizeGtdCheck = (repo, block) => {
31605
31750
  };
31606
31751
  };
31607
31752
  const GTD_UNCHECK_RE = /^gtd uncheck (.+)$/;
31608
- /** `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. */
31609
31754
  const recognizeGtdUncheck = (repo, block) => {
31610
31755
  const match = GTD_UNCHECK_RE.exec(block);
31611
31756
  if (!match) return void 0;
@@ -84,7 +84,7 @@ const stallDiagnosis = (state, actor, failure, blocked) => blocked !== void 0 ?
84
84
  * any other kind.
85
85
  */
86
86
  const demandOf = (input) => {
87
- const { rendered, kind, session, validate } = input;
87
+ const { rendered, kind, session, validate, fresh = null } = input;
88
88
  const content = kind === "stalled" ? stallDiagnosis(rendered.state, rendered.actor, rendered.failure, rendered.blocked) : rendered.content;
89
89
  const skills = rendered.skills !== void 0 && rendered.skills.length > 0 ? rendered.skills : void 0;
90
90
  const access = rendered.access ?? {
@@ -96,6 +96,7 @@ const demandOf = (input) => {
96
96
  content,
97
97
  session,
98
98
  validate,
99
+ fresh,
99
100
  skills,
100
101
  access
101
102
  };
@@ -158,6 +159,7 @@ const dispatchFieldsOf = (demand) => {
158
159
  const none = {
159
160
  session: void 0,
160
161
  validate: void 0,
162
+ fresh: null,
161
163
  skills: void 0,
162
164
  access: void 0,
163
165
  pin: void 0
@@ -167,6 +169,7 @@ const dispatchFieldsOf = (demand) => {
167
169
  ...none,
168
170
  session: demand.session,
169
171
  validate: demand.validate,
172
+ fresh: demand.fresh,
170
173
  skills: demand.skills,
171
174
  access: demand.access
172
175
  };
@@ -181,13 +184,13 @@ const dispatchFieldsOf = (demand) => {
181
184
  }
182
185
  };
183
186
  /**
184
- * Flatten a `Demand` plus its `BeatStatus` into the single 26-key document
187
+ * Flatten a `Demand` plus its `BeatStatus` into the single 27-key document
185
188
  * `gtd next --json` emits — the ONLY place the two are joined. `cost`/
186
189
  * `costByModel` are omitted together, exactly when no cost was recorded
187
190
  * (`cost <= 0`).
188
191
  */
189
192
  const beatDocument = (demand, status) => {
190
- const { session, validate, skills, access, pin } = dispatchFieldsOf(demand);
193
+ const { session, validate, fresh, skills, access, pin } = dispatchFieldsOf(demand);
191
194
  const hasCost = status.cost > 0;
192
195
  return {
193
196
  schema: 1,
@@ -199,6 +202,7 @@ const beatDocument = (demand, status) => {
199
202
  model: status.model,
200
203
  system: status.system,
201
204
  validate,
205
+ fresh,
202
206
  pin,
203
207
  log: status.log,
204
208
  state: status.state,
package/dist/flows.mjs CHANGED
@@ -1 +1 @@
1
- export { A as FLOWS_PROTOCOL, j as FlowsProtocolError, M as accessFor, N as agent, s as answered, x as applyScript, P as changes, F as changesSince, c as check, S as checkScript, l as codeChanges, I as codeThreads, r as defineConfig, a as defineWorkflow, u as displayPath, L as envSettings, R as envSettingsOf, z as glob, d as hasThreadFor, B as head, V as human, U as judge, f as judgeDiffShape, W as kept, G as mark, q as matches, C as moveScript, p as numeric, J as openQuestions, w as quote, X as read, Z as refuse, T as removeScript, m as requireAnswers, h as requireProgress, g as requireReplies, _ as requireRevert, v as requireThreadsClosed, Q as restart, E as restoreScript, D as revertScript, $ as run, et as scope, y as seamGate, tt as sectionBodies, nt as sections, rt as settings, it as settingsOf, at as ship, O as showScript, ot as skillsFor, st as start, ct as threads, lt as workflow, k as writeScript, b as wrote } from "./chunks/flows-DGs0W3HI.mjs";
1
+ export { N as FLOWS_PROTOCOL, P as FlowsProtocolError, F as accessFor, I as agent, s as answered, w as applyScript, L as changes, R as changesSince, c as check, T as checkScript, l as codeChanges, z as codeThreads, r as defineConfig, a as defineWorkflow, u as displayPath, B as envSettings, V as envSettingsOf, H as glob, d as hasThreadFor, U as head, W as human, K as judge, f as judgeDiffShape, q as kept, J as mark, X as matches, E as moveScript, p as numeric, Z as openQuestions, m as progressErrors, D as quote, $ as read, et as refuse, O as removeScript, h as repliesErrors, g as requireAnswers, _ as requireProgress, v as requireReplies, y as requireRevert, b as requireThreadsClosed, tt as restart, k as restoreScript, x as revertErrors, A as revertScript, nt as run, rt as scope, S as seamGate, it as sectionBodies, at as sections, ot as settings, st as settingsOf, ct as ship, j as showScript, lt as skillsFor, ut as start, dt as threads, ft as workflow, M as writeScript, C as wrote } from "./chunks/flows-CmQplvh1.mjs";