@pmelab/gtd 60.1.0 → 62.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 (61) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +16 -0
  3. package/dist/chunks/{testkit-BhTey1Do.mjs → testkit-XQeYCNMn.mjs} +1670 -1470
  4. package/dist/chunks/{wire-DZ8R1RU9.mjs → wire-CPNSm_9N.mjs} +27 -12
  5. package/dist/gtd.bundle.mjs +991 -627
  6. package/dist/gtd.core.mjs +117 -117
  7. package/dist/testkit/gherkin.mjs +1 -1
  8. package/dist/testkit.mjs +1 -1
  9. package/dist/wire.mjs +2 -2
  10. package/package.json +1 -1
  11. package/src/cli/Cli.ts +6 -1
  12. package/src/cli/History.test.ts +10 -1
  13. package/src/cli/Launch.test.ts +52 -0
  14. package/src/cli/Launch.ts +143 -0
  15. package/src/cli/Metrics.test.ts +10 -1
  16. package/src/cli/Program.test.ts +30 -20
  17. package/src/cli/Program.ts +4 -2
  18. package/src/cli/Strays.ts +3 -2
  19. package/src/cli/index.ts +1 -0
  20. package/src/edge/Edge.test.ts +79 -26
  21. package/src/edge/Edge.ts +178 -99
  22. package/src/emit/GitScript.ts +21 -3
  23. package/src/emit/index.ts +3 -0
  24. package/src/gherkin/messages.test.ts +1 -0
  25. package/src/gherkin/module-config.feature +1 -0
  26. package/src/gherkin/more-steps.feature +1 -0
  27. package/src/gherkin/testkit.feature +2 -1
  28. package/src/install/Install.ts +9 -2
  29. package/src/platform/PinFile.ts +61 -0
  30. package/src/platform/index.ts +1 -0
  31. package/src/replay/Format.test.ts +35 -8
  32. package/src/replay/Format.ts +7 -4
  33. package/src/replay/Replay.test.ts +54 -0
  34. package/src/replay/Replay.ts +61 -17
  35. package/src/replay/Trailers.test.ts +11 -48
  36. package/src/replay/Trailers.ts +36 -42
  37. package/src/replay/Tree.ts +11 -0
  38. package/src/replay/index.ts +3 -0
  39. package/src/step/LandStep.ts +1 -0
  40. package/src/step/RepoSnapshot.ts +2 -0
  41. package/src/step/planStart.test.ts +2 -3
  42. package/src/step/planStart.ts +1 -5
  43. package/src/step/planStep.test.ts +4 -4
  44. package/src/step/planStep.ts +10 -8
  45. package/src/testkit/TestRepo.test.ts +4 -1
  46. package/src/testkit/TestRepo.ts +3 -1
  47. package/src/twins/EmittedScriptRecognizer.ts +32 -1
  48. package/src/twins/GitTiers.ts +23 -0
  49. package/src/twins/InMemRepo.ts +14 -0
  50. package/src/twins/PinBeats.ts +33 -0
  51. package/src/twins/PinFile.test.ts +107 -0
  52. package/src/twins/PinFile.ts +12 -0
  53. package/src/twins/index.ts +2 -0
  54. package/src/wire/BeatDocument.ts +26 -5
  55. package/src/wire/Demand.ts +16 -6
  56. package/src/wire/constants.ts +3 -0
  57. package/src/wire/types.ts +4 -0
  58. package/src/workflow/fingerprint.ts +2 -2
  59. package/src/workflow/index.ts +2 -2
  60. package/src/workflow/load.ts +14 -1
  61. package/src/workflow/pin.ts +15 -7
@@ -1,5 +1,5 @@
1
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-DENt0uWV.mjs";
2
- import { O as ACCESS_REFUSAL, a as renderScriptJson, d as renderLandPlain, f as DEMAND_BRIEFING, g as statusOf, h as renderBeatJson, i as judgeJson, j as renderFormat, 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-DZ8R1RU9.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-CPNSm_9N.mjs";
3
3
  import { M as headingSectionBodies, N as formatFinding, a as sections, c as openThreads, 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-DF_BMadH.mjs";
4
4
  import { createRequire, isBuiltin } from "node:module";
5
5
  import * as Crypto from "node:crypto";
@@ -22274,7 +22274,13 @@ The \`kind\` field (\`--json=kind\`) selects what to do:
22274
22274
  Land it immediately, no display needed.
22275
22275
  - \`script\` — run \`--json=content\` with \`sh -c\`, appending its output to
22276
22276
  \`--json=log\`, and ignore its exit code — the outcome lives in the tree,
22277
- not in the exit code.
22277
+ not in the exit code. Two script beats carry a \`pin\` mark: the **pin
22278
+ beat** right after a process starts (\`pin: "add"\`, its script writes
22279
+ \`gtd bundle\`'s output to \`.gtd/process.gtdpin\`) and the **unpin beat**
22280
+ once the flow has ended (\`pin: "remove"\`, its script deletes that file).
22281
+ Both run unattended, like any script beat. A driver may write or delete the
22282
+ file its own way instead of running the script; either way it then lands,
22283
+ and the unpin's landing is the commit that closes the process.
22278
22284
  - \`prompt\` — send plain \`gtd next\`'s own output to your agent CLI over
22279
22285
  stdin (it's the bare content at this kind, so no separate \`--json=content\`
22280
22286
  read is needed), using the accompanying \`session.id\`/\`session.resume\`/
@@ -22317,7 +22323,8 @@ skills, \`skills\` (an array of the skill names, already trimmed and split —
22317
22323
  \`label\`, \`file\`, \`mode\`, \`edges\`, \`judge\` (the pending judgment's rendered
22318
22324
  document). When a cost has been recorded (a prior \`gtd land --cost=<n>\`):
22319
22325
  \`cost\`, \`costByModel\`. Always \`approvals\` (an array of
22320
- \`{step, name, email, commit}\`, \`[]\` when none).
22326
+ \`{step, name, email, commit}\`, \`[]\` when none). Only at a pin or unpin
22327
+ beat: \`pin\` (\`"add"\` or \`"remove"\`).
22321
22328
 
22322
22329
  \`gtd land\` fields, in fixed order: \`schema\`, \`script\` (the POSIX sh to run), \`settled\`
22323
22330
  (true for a no-op at a \`script\` rest — stop immediately, nothing more to
@@ -22792,11 +22799,20 @@ const diffTrees$1 = (before, after) => {
22792
22799
  };
22793
22800
  };
22794
22801
  const isEmptyDiff = (diff) => diff.added.length === 0 && diff.modified.length === 0 && diff.deleted.length === 0;
22802
+ const withoutPath = (tree, hidden) => {
22803
+ let paths;
22804
+ return {
22805
+ paths: () => paths ??= tree.paths().filter((path) => path !== hidden),
22806
+ read: (path) => path === hidden ? void 0 : tree.read(path),
22807
+ ...tree.id !== void 0 ? { id: (path) => path === hidden ? void 0 : tree.id(path) } : {}
22808
+ };
22809
+ };
22810
+ const OLDEST_READABLE_FORMAT = 1;
22795
22811
  const short$1 = (hash) => hash.slice(0, 7);
22796
22812
  const formatFault = (commits) => {
22797
- const foreign = commits.find((c) => c.format !== 1);
22813
+ const foreign = commits.find((c) => c.format < OLDEST_READABLE_FORMAT || c.format > 2);
22798
22814
  if (foreign === void 0) return void 0;
22799
- return `gtd: commit ${short$1(foreign.hash)} is written in history format ${foreign.format}, but this gtd reads format 1 — run a gtd release that reads format ${foreign.format} to continue this process`;
22815
+ return `gtd: commit ${short$1(foreign.hash)} is written in history format ${foreign.format}, but this gtd reads formats ${OLDEST_READABLE_FORMAT} to 2 — run a gtd release that reads format ${foreign.format} to continue this process`;
22800
22816
  };
22801
22817
  //#endregion
22802
22818
  //#region src/replay/Trailers.ts
@@ -22833,6 +22849,11 @@ const parseStepId = (raw) => {
22833
22849
  occurrence: Number(match[2])
22834
22850
  };
22835
22851
  };
22852
+ /** What a pin and an unpin beat rest at, and their commits name — neither is a step of the workflow. */
22853
+ const PIN_STATES = {
22854
+ add: "pin",
22855
+ remove: "unpin"
22856
+ };
22836
22857
  const isVerdict$1 = (value) => {
22837
22858
  if (typeof value !== "object" || value === null) return false;
22838
22859
  const v = value;
@@ -22852,13 +22873,7 @@ const parseJson = (raw) => {
22852
22873
  const TRAILER_RE = /^(Gtd-[A-Za-z-]+):[ \t]*(.*?)[ \t]*$/gm;
22853
22874
  const COST_RE = /^([0-9]+(?:\.[0-9]+)?)(?:[ \t]+(.+))?$/;
22854
22875
  const VAR_RE = /^([^=\s]+)=(.*)$/;
22855
- const CONFIG_RE = /^([0-9a-f]{16}) (.+)$/;
22856
22876
  const CONTROL_CHARACTER$1 = /[\x00-\x1f\x7f-\x9f\u2028\u2029]/;
22857
- const encodePath = (path) => path.trim() !== path || path.startsWith("\"") || CONTROL_CHARACTER$1.test(path) ? JSON.stringify(path) : path;
22858
- const decodePath = (raw) => {
22859
- const path = raw.startsWith("\"") ? parseJson(raw) : raw;
22860
- return typeof path === "string" && path !== "" && !CONTROL_CHARACTER$1.test(path) ? path : void 0;
22861
- };
22862
22877
  const decodeApproval = (raw) => {
22863
22878
  const parsed = parseJson(raw);
22864
22879
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
@@ -22910,11 +22925,6 @@ const TRAILER_READERS = {
22910
22925
  const kv = VAR_RE.exec(value);
22911
22926
  if (kv !== null) into.vars[kv[1]] = kv[2];
22912
22927
  },
22913
- "Gtd-Config": (value, into) => {
22914
- const entry = CONFIG_RE.exec(value);
22915
- const path = entry === null ? void 0 : decodePath(entry[2]);
22916
- if (path !== void 0) into.config[path] = entry[1];
22917
- },
22918
22928
  "Gtd-Review-Base": (value, into) => {
22919
22929
  into.reviewBase ??= value.split(/\s/)[0];
22920
22930
  },
@@ -22930,6 +22940,9 @@ const TRAILER_READERS = {
22930
22940
  "Gtd-Approved": (value, into) => {
22931
22941
  into.approved ??= decodeApproval(value);
22932
22942
  },
22943
+ "Gtd-Pin": (value, into) => {
22944
+ if (value === "add" || value === "remove") into.pin ??= value;
22945
+ },
22933
22946
  "Gtd-Format": (value, into) => {
22934
22947
  if (FORMAT_RE.test(value)) into.format ??= Number(value);
22935
22948
  },
@@ -22952,10 +22965,10 @@ const parseCommitMessage = (message) => {
22952
22965
  version: void 0,
22953
22966
  kept: void 0,
22954
22967
  approved: void 0,
22968
+ pin: void 0,
22955
22969
  judge: [],
22956
22970
  marks: [],
22957
22971
  vars: {},
22958
- config: {},
22959
22972
  cost: [],
22960
22973
  format: void 0
22961
22974
  };
@@ -22969,24 +22982,28 @@ const parseCommitMessage = (message) => {
22969
22982
  };
22970
22983
  const sortedEntries = (record = {}) => Object.entries(record).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
22971
22984
  /** Trailer order is fixed so the same spec always yields the same bytes. */
22985
+ const trailer = (name, value) => value === void 0 ? [] : [`Gtd-${name}: ${value}`];
22986
+ const costValue = (cost) => cost === void 0 ? void 0 : `${cost.cost}${cost.model !== void 0 ? ` ${cost.model}` : ""}`;
22972
22987
  const formatCommitMessage = (spec) => {
22973
- const lines = [];
22974
- if (spec.step !== void 0) lines.push(`Gtd-Step: ${formatStepId(spec.step)}`);
22975
- if (spec.workflow !== void 0) lines.push(`Gtd-Workflow: ${spec.workflow}`);
22976
- if (spec.reviewBase !== void 0) lines.push(`Gtd-Review-Base: ${spec.reviewBase}`);
22977
- for (const [name, value] of sortedEntries(spec.vars)) lines.push(`Gtd-Var: ${name}=${value}`);
22978
- for (const [path, hash] of sortedEntries(spec.config)) lines.push(`Gtd-Config: ${hash} ${encodePath(path)}`);
22979
- if (spec.cost !== void 0) lines.push(`Gtd-Cost: ${spec.cost.cost}${spec.cost.model !== void 0 ? ` ${spec.cost.model}` : ""}`);
22980
- for (const verdict of spec.judge ?? []) lines.push(`Gtd-Judge: ${JSON.stringify(verdict)}`);
22981
- for (const m of spec.marks ?? []) lines.push(`Gtd-Mark: ${m.name}=${m.value}`);
22982
- if (spec.approved !== void 0) lines.push(`Gtd-Approved: ${encodeApproval(spec.approved)}`);
22983
- if (spec.branch !== void 0) lines.push(`Gtd-Branch: ${spec.branch}`);
22984
- if (spec.version !== void 0) lines.push(`Gtd-Version: ${spec.version}`);
22985
- lines.push(`Gtd-Format: 1`);
22988
+ const lines = [
22989
+ ...trailer("Step", spec.step && formatStepId(spec.step)),
22990
+ ...trailer("Workflow", spec.workflow),
22991
+ ...trailer("Review-Base", spec.reviewBase),
22992
+ ...sortedEntries(spec.vars).map(([name, value]) => `Gtd-Var: ${name}=${value}`),
22993
+ ...trailer("Cost", costValue(spec.cost)),
22994
+ ...(spec.judge ?? []).map((verdict) => `Gtd-Judge: ${JSON.stringify(verdict)}`),
22995
+ ...(spec.marks ?? []).map((m) => `Gtd-Mark: ${m.name}=${m.value}`),
22996
+ ...trailer("Approved", spec.approved && encodeApproval(spec.approved)),
22997
+ ...trailer("Pin", spec.pin),
22998
+ ...trailer("Branch", spec.branch),
22999
+ ...trailer("Version", spec.version),
23000
+ `Gtd-Format: 2`
23001
+ ];
22986
23002
  return `${formatSubject(spec.actor, spec.to, spec.from)}\n\n${lines.join("\n")}`;
22987
23003
  };
22988
23004
  //#endregion
22989
23005
  //#region src/replay/Replay.ts
23006
+ const UNPIN_STATE = PIN_STATES.remove;
22990
23007
  const actorOfKind = (kind) => kind === "run" ? "check" : kind === "agent" ? "agent" : kind === "human" ? "human" : "judge";
22991
23008
  const memoryScopeOf = (name) => {
22992
23009
  const dot = name.lastIndexOf(".");
@@ -23120,7 +23137,33 @@ const modeWithoutFile = (step) => {
23120
23137
  if (options.mode === void 0 || options.file !== void 0) return void 0;
23121
23138
  return `gtd: step "${step.name}": mode requires file in ${step.kind}() options — name the steering file the mode checks`;
23122
23139
  };
23123
- const replay = async (input) => {
23140
+ const withoutPin = (input) => {
23141
+ const hide = (tree) => withoutPath(tree, PIN_PATH);
23142
+ return {
23143
+ ...input,
23144
+ episode: {
23145
+ base: {
23146
+ ...input.episode.base,
23147
+ tree: hide(input.episode.base.tree)
23148
+ },
23149
+ commits: input.episode.commits.map((c) => ({
23150
+ ...c,
23151
+ tree: hide(c.tree),
23152
+ ...c.kept !== void 0 ? { kept: {
23153
+ ...c.kept,
23154
+ tree: hide(c.kept.tree)
23155
+ } } : {}
23156
+ }))
23157
+ },
23158
+ ...input.startTree !== void 0 ? { startTree: hide(input.startTree) } : {},
23159
+ ...input.pending !== void 0 ? { pending: {
23160
+ ...input.pending,
23161
+ tree: hide(input.pending.tree)
23162
+ } } : {}
23163
+ };
23164
+ };
23165
+ const replay = async (raw) => {
23166
+ const input = withoutPin(raw);
23124
23167
  const commits = input.episode.commits.map((c) => ({
23125
23168
  ...c,
23126
23169
  parsed: parseCommitMessage(c.message)
@@ -23129,6 +23172,9 @@ const replay = async (input) => {
23129
23172
  let cursor = 0;
23130
23173
  let pendingUsed = false;
23131
23174
  let position = input.episode.base;
23175
+ const skipPins = () => {
23176
+ while (cursor < commits.length && commits[cursor].parsed.pin !== void 0) cursor++;
23177
+ };
23132
23178
  let previousPosition = input.episode.base;
23133
23179
  let expectedNext;
23134
23180
  const occurrences = /* @__PURE__ */ new Map();
@@ -23310,19 +23356,24 @@ const replay = async (input) => {
23310
23356
  if (unapproved !== void 0) return finish(unapproved);
23311
23357
  cursor++;
23312
23358
  advance(commit);
23313
- if (commit.parsed.kept !== void 0) if (commit.kept === void 0) keptTips.delete(step.name);
23314
- else keptTips.set(step.name, {
23315
- hash: commit.kept.hash,
23316
- before: commit.tree,
23317
- after: commit.kept.tree
23318
- });
23359
+ skipPins();
23360
+ noteKept(step, commit);
23319
23361
  const to = commit.parsed.parsed?.to;
23320
- if (to !== void 0) expectedNext = {
23362
+ if (to !== void 0 && to !== UNPIN_STATE) expectedNext = {
23321
23363
  name: to,
23322
23364
  hash: commit.hash
23323
23365
  };
23324
23366
  return replyFor(step.request, commit.parsed.judge);
23325
23367
  };
23368
+ const noteKept = (step, commit) => {
23369
+ if (commit.parsed.kept === void 0) return;
23370
+ if (commit.kept === void 0) keptTips.delete(step.name);
23371
+ else keptTips.set(step.name, {
23372
+ hash: commit.kept.hash,
23373
+ before: commit.tree,
23374
+ after: commit.kept.tree
23375
+ });
23376
+ };
23326
23377
  const landPending = (step, pending) => {
23327
23378
  pendingUsed = true;
23328
23379
  landed = step;
@@ -23339,7 +23390,10 @@ const replay = async (input) => {
23339
23390
  return replyFor(step.request, pending.verdicts ?? []);
23340
23391
  };
23341
23392
  const answer = (step) => {
23342
- while (cursor < commits.length && isAttemptAt(commits[cursor], step.name)) cursor++;
23393
+ while (cursor < commits.length && isAttemptAt(commits[cursor], step.name)) {
23394
+ cursor++;
23395
+ skipPins();
23396
+ }
23343
23397
  const commit = commits[cursor];
23344
23398
  if (commit !== void 0) return consumeCommit(step, commit);
23345
23399
  if (input.pending === void 0 || pendingUsed) return finish({
@@ -23352,6 +23406,7 @@ const replay = async (input) => {
23352
23406
  return landPending(step, input.pending);
23353
23407
  };
23354
23408
  const requestFault = (request) => {
23409
+ if (Object.values(PIN_STATES).includes(scoped(request.name))) return `gtd: step "${scoped(request.name)}": the name is reserved for the pin and unpin beats — rename the step`;
23355
23410
  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)`;
23356
23411
  if (request.kind !== "agent") return void 0;
23357
23412
  const name = scoped(request.name);
@@ -23378,15 +23433,18 @@ const replay = async (input) => {
23378
23433
  const blocker = blockerAt(step);
23379
23434
  return blocker === void 0 ? answer(step) : finish(blocker);
23380
23435
  };
23381
- const endOfFlow = (via) => cursor < commits.length ? {
23382
- kind: "divergence",
23383
- message: `gtd: the flow ended, but commit ${short(commits[cursor].hash)} continues the episode — ${DIVERGED}`
23384
- } : {
23385
- kind: "ended",
23386
- via,
23387
- trace,
23388
- landed,
23389
- marks
23436
+ const endOfFlow = (via) => {
23437
+ skipPins();
23438
+ return cursor < commits.length ? {
23439
+ kind: "divergence",
23440
+ message: `gtd: the flow ended, but commit ${short(commits[cursor].hash)} continues the episode — ${DIVERGED}`
23441
+ } : {
23442
+ kind: "ended",
23443
+ via,
23444
+ trace,
23445
+ landed,
23446
+ marks
23447
+ };
23390
23448
  };
23391
23449
  const changesSince = (hash) => {
23392
23450
  const tree = treeAt(hash);
@@ -23449,10 +23507,11 @@ const replay = async (input) => {
23449
23507
  skillsFor: (localName) => resolveSkills(memoryScopeOf(scoped(localName))) ?? [],
23450
23508
  accessFor: (localName, file) => foldedAccessFor(resolveAccess(memoryScopeOf(scoped(localName))), file),
23451
23509
  head: () => {
23452
- const rest = commits.slice(cursor);
23510
+ const rest = commits.slice(cursor).filter((c) => c.parsed.pin === void 0);
23453
23511
  return rest.length > 0 && rest.every((c) => c.parsed.step === void 0) ? rest[rest.length - 1].hash : position.hash;
23454
23512
  }
23455
23513
  };
23514
+ skipPins();
23456
23515
  installContext(context);
23457
23516
  try {
23458
23517
  const flowDone = input.flow({}).then(() => {
@@ -23840,7 +23899,8 @@ const load = gen(function* () {
23840
23899
  },
23841
23900
  ...compiled.ui !== void 0 ? { ui: compiled.ui } : {},
23842
23901
  warnings: diagnostics.filter((d) => d.severity === "warning"),
23843
- fingerprint: pinned?.fingerprint ?? configFingerprint(loaded.file.dependencyFiles, host.root, module.read ?? readFromDisk)
23902
+ fingerprint: pinned?.fingerprint ?? configFingerprint(loaded.file.dependencyFiles, host.root, module.read ?? readFromDisk),
23903
+ drift: pinned === void 0 ? [] : changedConfigFiles(pinned.fingerprint, configFingerprint(Object.keys(pinned.fingerprint).map((file) => resolve(host.root, file)), host.root, readFromDisk))
23844
23904
  };
23845
23905
  });
23846
23906
  const readFromDisk = (path) => {
@@ -24490,10 +24550,6 @@ const resolveScopeAccess = (def, skills, vars) => gen(function* () {
24490
24550
  //#endregion
24491
24551
  //#region src/workflow/Workflow.ts
24492
24552
  const knownModes = (def) => Object.keys(def.modes);
24493
- //#endregion
24494
- //#region src/workflow/pin.ts
24495
- /** Bumped when a launcher could no longer run an older pin the same way. */
24496
- const PIN_FORMAT = 1;
24497
24553
  /**
24498
24554
  * The engine a pin carries, as ES module source. Live, the `gtd.core.mjs` the
24499
24555
  * running gtd ships beside its own bundle.
@@ -24519,1549 +24575,1650 @@ const pin = (module, root, version) => gen(function* () {
24519
24575
  try: () => pinnedWorkflowSource(module, root, version),
24520
24576
  catch: (e) => new GtdError(`gtd bundle: ${module.filepath}: ${e instanceof Error ? e.message : String(e)}`)
24521
24577
  })) + core.replace(/^#!.*\n/, "");
24522
- return Buffer.concat([Buffer.from(`gtdpin ${PIN_FORMAT} gtd ${version}\n`), gzipSync(source, { level: 9 })]);
24578
+ return Buffer.concat([Buffer.from(`gtdpin 1 gtd ${version}\n`), gzipSync(source, { level: 9 })]);
24523
24579
  });
24524
24580
  //#endregion
24525
- //#region src/step/planStep.ts
24581
+ //#region src/emit/GitScript.ts
24582
+ const shellQuote = (value) => `'${value.replace(/'/g, "'\\''")}'`;
24526
24583
  /**
24527
- * At the human review gate an unconditional `gtd uncheck '<file>'` runs
24528
- * ahead of the commit, resetting every checkbox before `git add -A` picks it
24529
- * up — a tick is read-progress, never sign-off.
24584
+ * `--allow-empty` because gtd's workflow commits may be empty on purpose.
24585
+ * Mirrors `Git.ts`'s retry: only re-tries without hooks on the specific
24586
+ * "empty git commit" rejection message, discriminated from the captured
24587
+ * output rather than the exit code, so a genuinely rejected commit (a lint
24588
+ * error, a commit-msg hook) still fails.
24530
24589
  */
24531
- const uncheckStep = (snapshot) => snapshot.stepDef.actor === "human" && snapshot.stepDef.mode === "review" && snapshot.file !== void 0 ? [{
24532
- kind: "uncheck",
24533
- file: snapshot.file
24534
- }] : [];
24535
- const planStep = (snapshot, opts = {}) => {
24536
- const landing = snapshot.landing;
24537
- if (landing.kind === "refusal") return {
24538
- kind: "refusal",
24539
- message: landing.message
24540
- };
24541
- if (landing.kind === "noop") return {
24542
- kind: "noop",
24543
- state: snapshot.state,
24544
- settled: landing.settled
24545
- };
24546
- const cost = opts.cost === void 0 ? {} : { cost: {
24547
- cost: opts.cost,
24548
- ...opts.model !== void 0 ? { model: opts.model } : {}
24549
- } };
24550
- if (landing.kind === "attempt") {
24551
- const message = formatCommitMessage({
24552
- actor: snapshot.actor,
24553
- to: snapshot.state,
24554
- ...cost
24555
- });
24556
- return {
24557
- kind: "commit",
24558
- state: snapshot.state,
24559
- to: snapshot.state,
24560
- subject: landing.subject,
24561
- steps: [{
24562
- kind: "gitWrite",
24563
- write: {
24564
- kind: "commitAll",
24565
- message
24566
- }
24567
- }, {
24568
- kind: "outcome",
24569
- outcome: {
24570
- kind: "commit",
24571
- subject: landing.subject
24572
- }
24573
- }]
24574
- };
24575
- }
24576
- const message = formatCommitMessage({
24577
- ...landing.spec,
24578
- ...cost,
24579
- ...opts.judge !== void 0 && opts.judge.length > 0 ? { judge: opts.judge } : {}
24580
- });
24581
- const subject = message.split("\n")[0];
24582
- const outcome = landing.to === snapshot.state ? {
24583
- kind: "outcome",
24584
- outcome: {
24585
- kind: "commit",
24586
- subject
24587
- }
24588
- } : {
24589
- kind: "outcome",
24590
- outcome: {
24591
- kind: "transition",
24592
- from: snapshot.state,
24593
- to: landing.to
24594
- }
24595
- };
24596
- return {
24597
- kind: "commit",
24598
- state: snapshot.state,
24599
- to: landing.to,
24600
- subject,
24601
- steps: [
24602
- ...uncheckStep(snapshot),
24603
- {
24604
- kind: "gitWrite",
24605
- write: landing.keepTurn !== void 0 ? {
24606
- kind: "keepTurn",
24607
- ref: landing.keepTurn,
24608
- message
24609
- } : landing.spec.approved !== void 0 ? {
24610
- kind: "commitClean",
24611
- message
24612
- } : {
24613
- kind: "commitAll",
24614
- message
24615
- }
24616
- },
24617
- ...landing.keepHistory !== void 0 ? [{
24618
- kind: "gitWrite",
24619
- write: {
24620
- kind: "updateRef",
24621
- ref: landing.keepHistory,
24622
- target: "HEAD"
24623
- }
24624
- }] : [],
24625
- outcome
24626
- ]
24627
- };
24590
+ const commitAllowEmpty = (message, only) => {
24591
+ const m = shellQuote(message);
24592
+ const paths = only === void 0 ? "" : ` -- ${shellQuote(only)}`;
24593
+ return [
24594
+ `if ! out=$(git commit --allow-empty -m ${m}${paths} 2>&1); then`,
24595
+ ` case "$out" in`,
24596
+ ` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m}${paths} || exit 1 ;;`,
24597
+ ` *) printf '%s\\n' "$out" >&2; exit 1 ;;`,
24598
+ ` esac`,
24599
+ `fi`
24600
+ ].join("\n");
24628
24601
  };
24629
- //#endregion
24630
- //#region src/step/accessRefusal.ts
24602
+ const CHANGED_SINCE_LAND = "gtd: the working tree changed since `gtd land` — revert the edits and run `gtd land` again to approve";
24603
+ /** `git add -A || exit 1` (an `&&` left side would not abort under `set -e`) then `commitAllowEmpty`. */
24604
+ const commitAll = (message) => `git add -A || exit 1\n${commitAllowEmpty(message)}`;
24605
+ /** Commits `path` alone, as the worktree has it; every other pending change stays pending. */
24606
+ const commitPath = (path, message) => `git add -A -- ${shellQuote(path)} || exit 1\n${commitAllowEmpty(message, path)}`;
24607
+ /** A pin beat's script. A failed `gtd bundle` leaves no half-written pin behind. */
24608
+ const pinScript = (path) => {
24609
+ const p = shellQuote(path);
24610
+ const slash = path.lastIndexOf("/");
24611
+ return `${slash === -1 ? "" : `mkdir -p ${shellQuote(path.slice(0, slash))}\n`}gtd bundle >${p} || { rm -f -- ${p}; exit 1; }`;
24612
+ };
24613
+ /** An unpin beat's script, and `gtd abandon`'s removal of the pin. */
24614
+ const unpinScript = (path) => `rm -f -- ${shellQuote(path)}`;
24631
24615
  /**
24632
- * The landing refusal for changes outside `write`, or `undefined` when every
24633
- * path is allowed. `null` is unrestricted. The message is the fix prompt: gtd
24634
- * runs no fix turn of its own.
24616
+ * An approval records exactly the judged tree: HEAD is pinned before the
24617
+ * clean check and the commit is built over its tree with `commit-tree`, so no
24618
+ * hook runs and none can add to it. A HEAD that moved fails the guarded
24619
+ * `update-ref`. `commit-tree` ignores `commit.gpgSign`, so it is read here.
24635
24620
  */
24636
- const accessRefusal = (changes, write) => {
24637
- if (write === null) return void 0;
24638
- const offending = [...new Set(changes.filter((c) => !write.some((glob) => globMatches(c.path, glob))).map((c) => c.path))];
24639
- if (offending.length === 0) return void 0;
24621
+ const commitClean = (message) => [
24622
+ `gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
24623
+ `if [ -n "$(git status --porcelain --untracked-files=all --ignore-submodules=none)" ]; then`,
24624
+ ` printf '%s\\n' ${shellQuote(CHANGED_SINCE_LAND)} >&2`,
24625
+ ` exit 1`,
24626
+ `fi`,
24627
+ `gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
24628
+ `if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
24629
+ `gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m ${shellQuote(message)}) || exit 1`,
24630
+ `git update-ref -m ${shellQuote(`commit: ${message}`)} HEAD "$gtd_tip" "$gtd_head" || exit 1`
24631
+ ].join("\n");
24632
+ /** No current caller — see `Git.ts`'s `GitWriterOperations.commitAsIs` for why it's kept anyway. */
24633
+ const commitAsIs = (message) => commitAllowEmpty(message);
24634
+ /** No current caller — see `Git.ts`'s `GitWriterOperations.softResetTo` for why it's kept anyway. */
24635
+ const softResetTo = (ref) => `git reset --soft ${shellQuote(ref)}`;
24636
+ const mixedResetTo = (ref) => `git reset --mixed ${shellQuote(ref)}`;
24637
+ const hardResetTo = (ref) => `git reset --hard ${shellQuote(ref)}`;
24638
+ /**
24639
+ * `git add -A` then `git reset --hard HEAD` — discards every pending change,
24640
+ * tracked or untracked. Joined with `&&`: a failed stage must not reach the
24641
+ * hard reset, or untracked survivors remain — the exact outcome this builder
24642
+ * exists to avoid. No current caller — see `Git.ts`'s
24643
+ * `GitWriterOperations.discardPending` for why it's kept anyway.
24644
+ */
24645
+ const discardPending = () => `git add -A && git reset --hard HEAD`;
24646
+ /**
24647
+ * POSIX sh (runs under dash). The marker is a `commit-tree` over HEAD's own
24648
+ * tree, so no hook runs on it and its tree is the pre-turn tree by
24649
+ * construction; `commit-tree` ignores `commit.gpgSign`, hence the explicit
24650
+ * `-S`, read (and failing closed) before any object or ref is written; the
24651
+ * index is already staged by then, so an early exit leaves the turn staged.
24652
+ * The kept ref is deleted only after `reset -q` succeeds: it is then the
24653
+ * turn's only handle.
24654
+ * The branch moves before the final hard reset, so if that reset fails the
24655
+ * marker has landed and the turn is still in the working tree (kept ref
24656
+ * present); every other failure leaves the branch untouched.
24657
+ */
24658
+ const keepTurn = (refPrefix, message) => {
24659
+ const keptRef = `${shellQuote(`${refPrefix}/`)}"$gtd_kept"`;
24640
24660
  return [
24641
- ACCESS_REFUSAL,
24642
- ...offending.map((path) => ` - ${path}`),
24643
- `allowed: ${write.length === 0 ? "(nothing)" : write.join(", ")}`
24661
+ `git add -A || exit 1`,
24662
+ `gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
24663
+ `if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
24664
+ `gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
24665
+ `gtd_tree=$(git write-tree) || exit 1`,
24666
+ `gtd_kept=$(git commit-tree "$gtd_tree" -p "$gtd_head" -m ${shellQuote(message)}) || exit 1`,
24667
+ `git update-ref ${keptRef} "$gtd_kept" || exit 1`,
24668
+ `gtd_marker=$(printf '%s\\nGtd-Kept: %s' ${shellQuote(message)} "$gtd_kept")`,
24669
+ `if ! gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m "$gtd_marker") || ! git update-ref -m ${shellQuote(`commit: ${message}`)} HEAD "$gtd_tip" "$gtd_head"; then`,
24670
+ ` git reset -q && git update-ref -d ${keptRef}`,
24671
+ ` exit 1`,
24672
+ `fi`,
24673
+ `git reset --hard HEAD`
24644
24674
  ].join("\n");
24645
24675
  };
24646
- //#endregion
24647
- //#region src/step/planStart.ts
24648
- const refusal = (message) => ({
24649
- kind: "refusal",
24650
- message
24651
- });
24652
- const checkOverrides = (commandLabel, varOverrides, declaredNames, envNames) => gen(function* () {
24653
- const environmentOnly = Object.keys(varOverrides).filter((n) => !declaredNames.includes(n) && envNames.includes(n));
24654
- if (environmentOnly.length > 0) return yield* fail$4(new GtdUsageError(`${commandLabel}: --var pins process settings only; ${environmentOnly.join(", ")} ${environmentOnly.length === 1 ? "is an environment setting" : "are environment settings"} — set it as a workflow option in gtd.config.ts or with a GTD_<NAME> environment variable`));
24655
- const undeclared = Object.keys(varOverrides).filter((n) => !declaredNames.includes(n));
24656
- if (undeclared.length > 0) return yield* fail$4(new GtdUsageError(`${commandLabel}: --var name(s) not declared by this workflow: ${undeclared.join(", ")} — declared: ${declaredNames.length > 0 ? declaredNames.join(", ") : "(none)"}`));
24657
- });
24676
+ const updateRef = (ref, hash) => `git update-ref ${shellQuote(ref)} ${shellQuote(hash)}`;
24677
+ /** `git update-ref -d <ref>` — idempotent: deleting a missing ref is already a no-op in real git. */
24678
+ const deleteRef = (ref) => `git update-ref -d ${shellQuote(ref)}`;
24658
24679
  /**
24659
- * `gtd start <name>`: start a brand NEW process on a workflow, writing an
24660
- * opening commit that names the workflow's first step and carries a
24661
- * `Gtd-Workflow:` trailer, every process setting as a `Gtd-Var:` trailer, plus
24662
- * a `Gtd-Review-Base:` trailer when the workflow fixes the process's diff base,
24663
- * and a `Gtd-Config:` trailer per config file.
24664
- * All validation is a REFUSAL, not an Effect failure.
24680
+ * No `+` on the refspec: a local ref the remote's tip does not descend from
24681
+ * fails the fetch instead of being overwritten (a fast-forward still applies).
24665
24682
  */
24666
- const planStart = (current, actor, start) => gen(function* () {
24667
- const { workflow, first, commandLabel, vars: varOverrides } = start;
24668
- const name = workflow.name;
24669
- if (!current.idle) return refusal(`${commandLabel}: a process is already underway (resting at "${current.state}") — finish it, or run \`gtd abandon\`, before starting another`);
24670
- if ("refusal" in first) return refusal(`${commandLabel}: ${first.refusal}`);
24671
- const { firstStep } = first;
24672
- yield* checkOverrides(commandLabel, varOverrides, Object.keys(workflow.vars), Object.keys(workflow.env));
24673
- const baseOf = workflow.def.base;
24674
- const flags = yield* StartFlags;
24675
- const hostEnv = (yield* Host).env;
24676
- const badFlag = ownFlagRefusal(workflow.vars, flags, hostEnv);
24677
- if (badFlag !== void 0) return refusal(`${commandLabel}: ${badFlag}`);
24678
- const vars = resolveStartVars(workflow.vars, flags, varOverrides, hostEnv);
24679
- const multiline = multilineSetting(vars);
24680
- if (multiline !== void 0) return refusal(`${commandLabel}: process setting "${multiline}" spans several lines — a process setting is pinned in a commit trailer and must be a single line`);
24681
- const template = yield* try_({
24682
- try: () => baseOf?.(name, vars),
24683
- catch: (e) => /* @__PURE__ */ new Error(`${commandLabel}: ${e instanceof Error ? e.message : String(e)}`)
24684
- });
24685
- const base = template === void 0 ? void 0 : yield* resolveBase(commandLabel, template);
24686
- if (typeof base === "object") return base;
24687
- const subject = formatSubject(actor, firstStep);
24688
- return {
24689
- kind: "start",
24690
- workflow: name,
24691
- subject,
24692
- steps: [{
24693
- kind: "gitWrite",
24694
- write: {
24695
- kind: "commitAll",
24696
- message: formatCommitMessage({
24697
- actor,
24698
- to: firstStep,
24699
- workflow: name,
24700
- ...base !== void 0 ? { reviewBase: base } : {},
24701
- vars,
24702
- config: start.config
24703
- })
24704
- }
24705
- }, {
24706
- kind: "outcome",
24707
- outcome: {
24708
- kind: "commit",
24709
- subject
24710
- }
24711
- }]
24712
- };
24713
- });
24714
- /**
24715
- * The workflow's diff base: `git merge-base <base> HEAD`, where a blank base
24716
- * means the default branch. The merge-base is what gets pinned, so a base on
24717
- * another branch reviews exactly this branch's own commits.
24718
- */
24719
- const resolveBase = (commandLabel, value) => gen(function* () {
24720
- const git = yield* GitService;
24721
- const rendered = value.trim();
24722
- const target = rendered === "" ? yield* git.defaultBranch() : rendered;
24723
- const resolvedBase = yield* result(git.resolveRef(target));
24724
- if (resolvedBase._tag === "Failure") return refusal(`${commandLabel}: "${target}" does not resolve to a commit`);
24725
- const mergeBase = yield* git.mergeBase(resolvedBase.success, "HEAD");
24726
- if (isNone(mergeBase)) return refusal(`${commandLabel}: "${target}" shares no common ancestor with HEAD`);
24727
- if (mergeBase.value === (yield* git.resolveRef("HEAD"))) return refusal(`${commandLabel}: nothing to review: HEAD has no commits beyond ${target}`);
24728
- return mergeBase.value;
24729
- });
24730
- //#endregion
24731
- //#region src/step/strayPaths.ts
24732
- /** Globs use the `access.write` dialect (`globMatches`). */
24733
- const strayPaths = (touched, allow) => touched.filter((path) => !allow.some((glob) => globMatches(path, glob))).sort();
24734
- //#endregion
24735
- //#region src/edge/Edge.ts
24736
- const EMPTY_TREE$1 = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
24737
- const ACTORS = /* @__PURE__ */ new Set([
24738
- "agent",
24739
- "human",
24740
- "check",
24741
- "judge"
24742
- ]);
24743
- /**
24744
- * Where the episode HEAD belongs to begins, read off subjects alone. Walking
24745
- * back from HEAD: a bare `gtd(human): <step>` commit with no `Gtd-Step` is what
24746
- * `gtd start` writes — no landing ever produces one — and opens an
24747
- * episode as its first commit (its `Gtd-Workflow` trailer names the workflow;
24748
- * a legacy `--entry` opening has none); a commit that is not a gtd step commit, or one
24749
- * entering the flow's first step (a finished episode), bounds the episode
24750
- * from below.
24751
- */
24752
- const locateEpisode = (def, history) => {
24753
- for (let i = history.length - 1; i >= 0; i--) {
24754
- const message = parseCommitMessage(history[i].message);
24755
- const subject = message.parsed;
24756
- if (subject !== void 0 && message.step === void 0 && subject.from === void 0 && subject.actor === "human") return {
24757
- workflow: message.workflow,
24758
- legacyOpening: message.workflow === void 0,
24759
- baseIndex: i,
24760
- processStart: i
24761
- };
24762
- if (subject === void 0 || !ACTORS.has(subject.actor) || subject.to === def.initial) return {
24763
- workflow: void 0,
24764
- legacyOpening: false,
24765
- baseIndex: i,
24766
- processStart: i + 1
24767
- };
24683
+ const fetchHistoryRefs = (remote) => [
24684
+ `set -eu`,
24685
+ `before=$(git for-each-ref --format='x' 'refs/gtd/history/' | wc -l)`,
24686
+ `git fetch ${shellQuote(remote)} 'refs/gtd/history/*:refs/gtd/history/*'`,
24687
+ `after=$(git for-each-ref --format='x' 'refs/gtd/history/' | wc -l)`,
24688
+ `printf '%s history refs arrived\\n' "$((after - before))"`
24689
+ ].join("\n");
24690
+ /** Marks an emitted block as print-only — mirrors `OutcomeScript.ts`'s `OUTCOME_MARKER` verbatim, so `src/twins/EmittedScriptRecognizer.ts` recognizes either path's output identically. */
24691
+ const OUTCOME_MARKER$1 = "# gtd: outcome (print-only)";
24692
+ const printfLine$1 = (fmt, args) => `${OUTCOME_MARKER$1}\nprintf ${shellQuote(fmt.replace(/\n/g, "\\n"))} ${args.join(" ")}`;
24693
+ const renderOutcome = (outcome) => {
24694
+ switch (outcome.kind) {
24695
+ case "transition": return printfLine$1("%s %s → %s\n", [
24696
+ "'->'",
24697
+ shellQuote(outcome.from),
24698
+ shellQuote(outcome.to)
24699
+ ]);
24700
+ case "commit": return printfLine$1("%s %s\n", ["'[commit]'", shellQuote(outcome.subject)]);
24701
+ case "note": return printfLine$1("%s\n", [shellQuote(outcome.text)]);
24768
24702
  }
24769
- return {
24770
- workflow: void 0,
24771
- legacyOpening: false,
24772
- baseIndex: -1,
24773
- processStart: 0
24774
- };
24775
- };
24776
- const headTurnOf = (head) => {
24777
- if (head === void 0) return void 0;
24778
- const message = parseCommitMessage(head.message);
24779
- const subject = message.parsed;
24780
- if (subject === void 0 || !ACTORS.has(subject.actor)) return void 0;
24781
- return {
24782
- state: subject.to,
24783
- actor: subject.actor,
24784
- empty: head.touched.length === 0,
24785
- step: message.step !== void 0
24786
- };
24787
24703
  };
24788
- const traceEntryOf = (hash, message) => ({
24789
- state: message.parsed?.to ?? "",
24790
- hash,
24791
- actor: message.parsed?.actor ?? ""
24792
- });
24793
- const costEntriesOf = (hash, message) => message.cost.map((c) => ({
24794
- cost: c.cost,
24795
- model: c.model ?? "unspecified",
24796
- hash
24797
- }));
24798
- const hashAt = (history, index) => history[index]?.hash;
24799
- const historyFormatFault = (history, location) => formatFault(history.slice(location.baseIndex + 1).concat(history[location.baseIndex]?.message.startsWith("gtd") ? [history[location.baseIndex]] : []).map((c) => ({
24800
- hash: c.hash,
24801
- format: parseCommitMessage(c.message).format
24802
- })));
24803
- /** Fails on a history in a format this gtd does not read, before anything acts on it. */
24804
- const runOf = (history, location, closingHash = void 0) => {
24805
- const fault = historyFormatFault(history, location);
24806
- return fault === void 0 ? succeed$1(buildRun(history, location, closingHash)) : fail$4(new Error(fault));
24807
- };
24808
- const pinnedConfigOf = (history) => {
24809
- for (let i = history.length - 1; i >= 0; i--) {
24810
- const files = parseCommitMessage(history[i].message).config;
24811
- if (Object.keys(files).length > 0) return {
24812
- commit: history[i].hash,
24813
- files
24814
- };
24704
+ const renderGitWrite = (write) => {
24705
+ switch (write.kind) {
24706
+ case "commitAll": return commitAll(write.message);
24707
+ case "commitPath": return commitPath(write.path, write.message);
24708
+ case "commitClean": return commitClean(write.message);
24709
+ case "updateRef": return updateRef(write.ref, write.target);
24710
+ case "keepTurn": return keepTurn(write.ref, write.message);
24815
24711
  }
24816
24712
  };
24817
- const approvalEntryOf = (hash, message) => message.approved === void 0 ? [] : [{
24818
- step: formatStepId(message.approved.step),
24819
- name: message.approved.name,
24820
- email: message.approved.email,
24821
- commit: hash
24822
- }];
24823
- const buildRun = (history, location, closingHash) => {
24824
- const processCommits = history.slice(location.processStart);
24825
- const parsed = processCommits.map((c) => parseCommitMessage(c.message));
24826
- const first = parsed[0];
24827
- const startParentHash = hashAt(history, location.processStart - 1) ?? EMPTY_TREE$1;
24828
- const head = history[history.length - 1];
24829
- return {
24830
- workflow: location.workflow,
24831
- legacyOpening: location.legacyOpening,
24832
- startHash: hashAt(history, location.processStart) ?? head?.hash ?? EMPTY_TREE$1,
24833
- startParentHash,
24834
- diffBase: first?.reviewBase ?? startParentHash,
24835
- trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i])),
24836
- costEntries: parsed.flatMap((m, i) => costEntriesOf(processCommits[i].hash, m)),
24837
- judgeVerdicts: parsed.flatMap((m) => m.judge),
24838
- marks: parsed.flatMap((m) => m.marks),
24839
- approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i])),
24840
- pinnedVars: { ...first?.vars },
24841
- pinnedConfig: pinnedConfigOf(history.slice(Math.max(location.baseIndex, 0))),
24842
- headTurn: headTurnOf(head),
24843
- closingHash,
24844
- episode: {
24845
- base: hashAt(history, location.baseIndex),
24846
- commits: history.slice(location.baseIndex + 1)
24847
- }
24848
- };
24849
- };
24850
- const PAGE_TIERS = [
24851
- 32,
24852
- 128,
24853
- 512,
24854
- Infinity
24855
- ];
24856
24713
  /**
24857
- * `locateEpisode`'s own boundary test, subject-only: a commit that isn't a
24858
- * gtd step commit, or one landing the flow's initial state, bounds an
24859
- * episode from below — EXCEPT a bare `gtd(human): <x>` subject (no `→`),
24860
- * which is NEVER a boundary here, whatever `<x>` is. That bare shape is
24861
- * `locateEpisode`'s own `opening` branch (a started episode's first commit),
24862
- * which takes priority there over the initial-state check no matter what the
24863
- * workflow's first step is named — including one named after `def.initial`
24864
- * itself, where the two branches would otherwise disagree. A bare human commit can
24865
- * also be a self-loop step landing rather than a real opening (this
24866
- * subject-only pass can't read the `Gtd-Step` trailer that would tell them
24867
- * apart), so ruling it out here can only make `findBoundaryBase` walk a FEW
24868
- * commits further back than strictly needed — never stop short of the
24869
- * boundary `locateEpisode` would find once it has full commit bodies in
24870
- * hand. That asymmetry is the only direction this approximation may err in;
24871
- * it must never call a real boundary "not yet found".
24714
+ * Wraps `command` so a non-zero exit prints `prompt` plus the command's
24715
+ * captured output before propagating that exit code — mirrors `Emit.ts`'s
24716
+ * `failurePromptWrapper` (same reverse-import concern as `renderOutcome`
24717
+ * above: `Emit.ts` imports `shellQuote` from here already).
24872
24718
  */
24873
- const subjectIsBoundary = (def, subject) => {
24874
- const parsed = parseCommitMessage(subject).parsed;
24875
- if (parsed === void 0 || !ACTORS.has(parsed.actor)) return true;
24876
- if (parsed.actor === "human" && parsed.from === void 0) return false;
24877
- return parsed.to === def.initial;
24719
+ const failurePromptWrapper$1 = (command, prompt) => {
24720
+ return [
24721
+ `gtd_validate_status=0`,
24722
+ `gtd_validate_out="$( {`,
24723
+ command,
24724
+ `} 2>&1 )" || gtd_validate_status=$?`,
24725
+ `if [ "$gtd_validate_status" -ne 0 ]; then`,
24726
+ ` printf '%s\\n\\n%s\\n' ${shellQuote(prompt)} "$gtd_validate_out"`,
24727
+ ` exit "$gtd_validate_status"`,
24728
+ `fi`
24729
+ ].join("\n");
24878
24730
  };
24879
- /** The last page index (walking newest→oldest, same direction as `locateEpisode`) whose subject is a boundary, or `undefined` if none in `page`. */
24880
- const boundaryIndexIn = (def, page) => {
24881
- for (let i = page.length - 1; i >= 0; i--) if (subjectIsBoundary(def, page[i].subject)) return i;
24731
+ const renderLandStep = (step) => {
24732
+ if (step.kind === "gitWrite") return renderGitWrite(step.write);
24733
+ if (step.kind === "outcome") return renderOutcome(step.outcome);
24734
+ if (step.kind === "uncheck") return `gtd uncheck ${shellQuote(step.file)}`;
24735
+ return step.onFailure !== void 0 ? failurePromptWrapper$1(step.command, step.onFailure) : step.command;
24882
24736
  };
24737
+ const ScriptSurface = { render: (steps) => {
24738
+ const rendered = steps.map(renderLandStep);
24739
+ return rendered.length === 0 ? "" : ["set -eu", ...rendered].join("\n\n");
24740
+ } };
24741
+ //#endregion
24742
+ //#region src/emit/Emit.ts
24743
+ /** Exits 0 when the declared steering file is absent — nothing to format or validate yet. An OR list, not `if`: its left side failing never trips `set -e`. */
24744
+ const fileExistsGuard = (file) => `[ -f ${shellQuote(file)} ] || exit 0`;
24745
+ /** A mode or `ui.format` command finds the file it works on in `$GTD_FILE`. */
24746
+ const withFileVar = (command, file) => `export GTD_FILE=${shellQuote(file)}\n${command}`;
24747
+ const SIMPLE_LEADING_WORD_RE = /^[A-Za-z0-9_./-]+(?=[ \t]|$)/;
24748
+ const SHELL_METACHARACTER_RE = /[|&;$<>(){}`]/;
24749
+ const FILE_VAR_RE = /"\$GTD_FILE"|"\$\{GTD_FILE\}"|\$GTD_FILE\b|\$\{GTD_FILE\}/g;
24750
+ /** `command` with `file` written in for `$GTD_FILE` — one line an agent can copy, equivalent to running it under `withFileVar`. */
24751
+ const commandForFile = (command, file) => command.replace(FILE_VAR_RE, shellQuote(file));
24883
24752
  /**
24884
- * `findBoundaryBase`'s result: `base` is the hash to pass `commitHistory` so
24885
- * a `git.commitHistory(base, head)` call reads exactly the episode ending at
24886
- * `head`, plus whatever a subject-only read couldn't rule out — never the
24887
- * whole repository. `base` is deliberately the boundary commit's PARENT, not
24888
- * the boundary commit itself: `commitHistory`'s range excludes `base`, and
24889
- * `runOf` needs the boundary commit present in the read history
24890
- * (`episode.base`, `startParentHash`). `base: undefined` means "read from the
24891
- * repository root" — an empty repository, an unresolvable `head`, or an
24892
- * episode that reaches the root commit, none of which cost anything extra
24893
- * since that IS the whole history in those cases.
24894
- *
24895
- * `head` is the CONCRETE hash the literal `head` argument resolved to on this
24896
- * call's first read — `undefined` only when it never resolved (an empty
24897
- * repository, or an unresolvable ref). The caller must hand this hash, not
24898
- * the original `head` argument, to its own `commitHistory` call: resolving a
24899
- * symbolic ref (literal `HEAD`, most callers' default) a second time risks
24900
- * reading a DIFFERENT commit than this function paged through, if something
24901
- * moves HEAD in between — pinning both reads to one resolved hash is what
24902
- * keeps the two-read split from disagreeing with itself.
24753
+ * The leading word of a rendered `format:`/`validate:` command, when the
24754
+ * command has exactly one unambiguous binary to probe — `undefined` for
24755
+ * anything a shell metacharacter could hide a second command behind. A missing
24756
+ * guard degrades to a raw exit; a wrong guard would refuse a working command.
24903
24757
  */
24904
- const findBoundaryBase = (git, def, head) => gen(function* () {
24905
- let pinnedHead = head;
24906
- for (const pageSize of PAGE_TIERS) {
24907
- const page = yield* git.subjectHistory(pageSize, 0, pinnedHead);
24908
- if (pinnedHead === void 0) pinnedHead = page[page.length - 1]?.hash;
24909
- const wholeHistory = !Number.isFinite(pageSize) || page.length < pageSize;
24910
- const boundaryIdx = boundaryIndexIn(def, page);
24911
- if (boundaryIdx === void 0 || boundaryIdx === 0) {
24912
- if (wholeHistory) return {
24913
- base: void 0,
24914
- head: pinnedHead
24915
- };
24916
- continue;
24917
- }
24918
- return {
24919
- base: page[boundaryIdx - 1].hash,
24920
- head: pinnedHead
24921
- };
24922
- }
24923
- return {
24924
- base: void 0,
24925
- head: pinnedHead
24926
- };
24927
- });
24928
- const historyUpTo = (git, def, head) => flatMap(findBoundaryBase(git, def, head), ({ base, head: resolvedHead }) => git.commitHistory(base, resolvedHead));
24929
- const computeProcessRun = (git, def, head) => flatMap(historyUpTo(git, def, head), (history) => runOf(history, locateEpisode(def, history)));
24930
- const changedSinceStart = (pinned, current, workspace) => {
24931
- if (pinned === void 0) return [];
24932
- return changedConfigFiles(pinned.files, current).filter((path) => {
24933
- const committed = workspace.readCommittedSync(path, pinned.commit);
24934
- return current[path] === void 0 || committed === void 0 || contentHash(committed) !== current[path];
24935
- });
24936
- };
24937
- const warnConfigChanges = (run, current, workspace) => gen(function* () {
24938
- const changed = changedSinceStart(run.pinnedConfig, current, workspace);
24939
- if (changed.length === 0) return;
24940
- yield* (yield* Narrator).warn(`gtd: warning: the config changed since this process started (${changed.join(", ")}) — the rest of the process replays under the changed config`);
24941
- });
24942
- /** The run alone, never replaying — `gtd abandon` must work even when replay would refuse, short of a history it cannot read. */
24943
- const currentRun = gen(function* () {
24944
- const git = yield* GitService;
24945
- const config = yield* (yield* ConfigService).load;
24946
- return yield* computeProcessRun(git, config.workflow);
24947
- });
24948
- const runAtTip = (git, def, tip) => gen(function* () {
24949
- if (tip === void 0) {
24950
- const history = yield* historyUpTo(git, def, void 0);
24951
- return yield* runOf(history, locateEpisode(def, history));
24952
- }
24953
- if (!(parseCommitMessage(tip.subject).parsed?.to === def.initial)) {
24954
- const history = yield* historyUpTo(git, def, tip.hash);
24955
- return yield* runOf(history, locateEpisode(def, history));
24956
- }
24957
- const parentRef = `${tip.hash}~1`;
24958
- const before = yield* historyUpTo(git, def, parentRef);
24959
- const location = locateEpisode(def, before);
24960
- let closing = yield* git.commitHistory(parentRef, tip.hash);
24961
- if (closing.length === 0) closing = yield* git.commitHistory(void 0, tip.hash);
24962
- return yield* runOf([...before, ...closing], { ...location }, tip.hash);
24963
- });
24964
- const runAtHead = (git, def) => gen(function* () {
24965
- return yield* runAtTip(git, def, (yield* git.subjectHistory(1, 0))[0]);
24966
- });
24967
- /** The process HEAD closes or sits inside — `gtd summary`'s run. */
24968
- const summaryRun = gen(function* () {
24969
- const git = yield* GitService;
24970
- const def = (yield* (yield* ConfigService).load).workflow;
24971
- return yield* runAtHead(git, def);
24972
- });
24973
- const commitTree = (workspace, hash) => {
24974
- let entries;
24975
- const list = () => entries ??= workspace.treeSync(hash);
24976
- let sortedPaths;
24977
- const contents = /* @__PURE__ */ new Map();
24978
- return {
24979
- paths: () => sortedPaths ??= [...list().keys()].sort(),
24980
- read: (path) => {
24981
- if (!list().has(path)) return void 0;
24982
- if (!contents.has(path)) contents.set(path, workspace.readCommittedSync(path, hash));
24983
- return contents.get(path);
24984
- },
24985
- id: (path) => list().get(path)
24986
- };
24758
+ const extractLeadingBinary = (command) => {
24759
+ if (command.includes("\n")) return void 0;
24760
+ if (SHELL_METACHARACTER_RE.test(command.replace(FILE_VAR_RE, ""))) return void 0;
24761
+ const match = SIMPLE_LEADING_WORD_RE.exec(command);
24762
+ return match === null ? void 0 : match[0];
24987
24763
  };
24988
24764
  /**
24989
- * The tree a landing would commit: the working tree, with any rewrite the
24990
- * landing script applies first. `entries` is `setup.pendingWorktree()` —
24991
- * already memoized — never a fresh `workspace.worktreeSync()` call; see that
24992
- * field's own comment for why the memo lives there and not on `Workspace`.
24765
+ * Names a typo'd or uninstalled binary and the `$PATH` it was looked up on.
24766
+ * `$PATH` is a shell word the DRIVER's shell expands at run time, so gtd never
24767
+ * resolves it in-process. Exits 127, matching bash's "command not found".
24993
24768
  */
24994
- const pendingTree = (workspace, entries, rewrite) => {
24995
- const paths = [...entries.keys()];
24996
- const readWorktree = (path) => {
24997
- try {
24998
- return workspace.readSync(path);
24999
- } catch {
25000
- return;
25001
- }
25002
- };
25003
- return {
25004
- paths: () => paths,
25005
- read: (path) => {
25006
- if (!entries.has(path)) return void 0;
25007
- const content = readWorktree(path);
25008
- return content === void 0 || rewrite?.path !== path ? content : rewrite.apply(content);
25009
- },
25010
- id: (path) => rewrite?.path === path ? void 0 : entries.get(path)
25011
- };
24769
+ const binaryGuard = (binary, mode, key) => {
24770
+ const messageQ = shellQuote(`gtd: mode "${mode}": "${key}" command not found: ${binary}`);
24771
+ return [
24772
+ `command -v ${shellQuote(binary)} >/dev/null 2>&1 || {`,
24773
+ ` printf '%s\\n%s\\n' ${messageQ} '$PATH: '"$PATH"`,
24774
+ ` exit 127`,
24775
+ `}`
24776
+ ].join("\n");
25012
24777
  };
25013
24778
  /**
25014
- * The one resolver of both setting kinds. Process settings: the recorded
25015
- * (pinned) value wins per name, anything unrecorded resolves live — the
25016
- * fallback for a process started before pinning existed. Environment settings
25017
- * are always live. `startOverrides` are `--var` values, passed only while a
25018
- * process is being started.
24779
+ * Prints `prompt` plus the captured output on a non-zero exit, then propagates
24780
+ * it. The `{ … }` group inlines a multi-line `command` verbatim; the
24781
+ * assignment sits left of `||` so `set -e` never trips on the failure itself.
25019
24782
  */
25020
- const settingsFor = (workflow, pinnedVars, hostEnv, startOverrides = {}, startFlags) => ({
25021
- vars: {
25022
- ...resolveStartVars(workflow.vars, startFlags, startOverrides, hostEnv),
25023
- ...pinnedVars
25024
- },
25025
- env: resolveVars(workflow.env, {}, hostEnv)
24783
+ const failurePromptWrapper = (command, prompt) => {
24784
+ return [
24785
+ `gtd_validate_status=0`,
24786
+ `gtd_validate_out="$( {`,
24787
+ command,
24788
+ `} 2>&1 )" || gtd_validate_status=$?`,
24789
+ `if [ "$gtd_validate_status" -ne 0 ]; then`,
24790
+ ` printf '%s\\n\\n%s\\n' ${shellQuote(prompt)} "$gtd_validate_out"`,
24791
+ ` exit "$gtd_validate_status"`,
24792
+ `fi`
24793
+ ].join("\n");
24794
+ };
24795
+ const renderStep = (step) => {
24796
+ if (step.kind === "command" && step.onFailure !== void 0) return failurePromptWrapper(step.command, step.onFailure);
24797
+ return step.command;
24798
+ };
24799
+ const assembleScript = (steps) => steps.length === 0 ? "" : ["set -eu", ...steps.map(renderStep)].join("\n\n");
24800
+ /** Build both halves from already-rendered command strings; an empty (or omitted) array produces the empty-string result, so a driver checking `if [ -n "$required" ]` never has to special-case a bare preamble. */
24801
+ const emitScripts = (required = [], optional = []) => ({
24802
+ required: assembleScript(required),
24803
+ optional: assembleScript(optional)
25026
24804
  });
25027
- /** The workflow a run replays on: its pinned one, else the default. */
25028
- const workflowOf = (config, run) => {
25029
- if (run.legacyOpening) return fail$4(/* @__PURE__ */ new Error("gtd: this process was opened by `gtd --entry`, which no longer exists — run `gtd abandon` to start over"));
25030
- if (run.workflow === void 0) return succeed$1(config.defaultWorkflow);
25031
- const named = config.workflowNamed(run.workflow);
25032
- if (named !== void 0) return succeed$1(named);
25033
- return fail$4(/* @__PURE__ */ new Error(`gtd: this process runs workflow "${run.workflow}", which gtd.config.ts no longer defines — run \`gtd abandon\` to start over`));
24805
+ /** Exported so `src/twins/EmittedScriptRecognizer.ts` can recognize these by exact string comparison. `printf`, not `echo`, avoids shell-specific backslash-escape quirks. */
24806
+ const DID_NOT_RUN_COMMENT = "# gtd emitted this and did NOT run it — run it with `sh` to apply it";
24807
+ const PRESENTATION_ONLY_COMMENT = "# presentation only — safe to skip";
24808
+ const PRESENTATION_FAILURE_WARNING = "printf 'gtd: presentation-only follow-up failed — continuing\\n' >&2";
24809
+ /**
24810
+ * A write command's single pasteable script (`gtd abandon | sh`,
24811
+ * `gtd land --json=script`'s own field). `optional`, when non-empty, is
24812
+ * wrapped in a subshell whose failure is swallowed — presentation-only, so it
24813
+ * must never turn a landed turn into a non-zero exit.
24814
+ */
24815
+ const combinedScript = (required, optional) => {
24816
+ if (required.length === 0) return "";
24817
+ if (optional.length === 0) return `${DID_NOT_RUN_COMMENT}\n\n${required}`;
24818
+ return [
24819
+ DID_NOT_RUN_COMMENT,
24820
+ "",
24821
+ required,
24822
+ "",
24823
+ PRESENTATION_ONLY_COMMENT,
24824
+ `(\n${optional}\n) || ${PRESENTATION_FAILURE_WARNING}`
24825
+ ].join("\n");
25034
24826
  };
24827
+ //#endregion
24828
+ //#region src/emit/OutcomeScript.ts
24829
+ /**
24830
+ * A `printf '<fmt>' <args...>` bash statement — `args` are already-valid bash
24831
+ * tokens (a `shellQuote`d literal, a `"$(git …)"` substitution), never
24832
+ * re-quoted here: a value reaches the script as a printf ARGUMENT, never
24833
+ * interpolated into the format itself, so a subject or state name containing
24834
+ * `%` can never be read as a conversion spec. Markers like `->` are arguments
24835
+ * too, keeping every format string free of a leading `-` that a shell's
24836
+ * `printf` could mistake for an option. A real newline in `fmt` is emitted as
24837
+ * the two-character `\n` escape `printf` itself expands, keeping every emitted
24838
+ * statement on one line.
24839
+ */
24840
+ const printfLine = (fmt, args) => `${OUTCOME_MARKER}\nprintf ${shellQuote(fmt.replace(/\n/g, "\\n"))} ${args.join(" ")}`;
24841
+ /**
24842
+ * Marks an emitted block as print-only. Load-bearing beyond documentation:
24843
+ * `src/twins/EmittedScriptRecognizer.ts` recognizes an outcome block by
24844
+ * this line, and a bare `printf` would be ambiguous with a workflow
24845
+ * `script:` command that happens to start the same way.
24846
+ */
24847
+ const OUTCOME_MARKER = "# gtd: outcome (print-only)";
24848
+ const FMT_ABANDON_NOOP = "no gtd process is underway (resting at \"%s\") — nothing to abandon\n";
24849
+ const FMT_ABANDONED = "abandoned the process resting at \"%s\" — HEAD is back at %s (\"%s\"), resting at \"%s\".\nEverything the process produced is kept as uncommitted changes (`git status`); discard them with `git checkout -- . && git clean -fd .gtd` for a clean tree.\n";
24850
+ const FMT_RESTORED = "restored the retained history — HEAD is back at %s (\"%s\"), resting at \"%s\". Resume with the loop, or `git reset` to any earlier turn to restart from there.\n";
24851
+ /** `no gtd process is underway (resting at "<initial>") — nothing to abandon` — `gtd abandon`'s no-op plain-text line. */
24852
+ const abandonNoopText = (initial) => renderFormat(FMT_ABANDON_NOOP, initial);
24853
+ /** One already-rendered plain line. */
24854
+ const noteOutcome = (text) => printfLine("%s\n", [shellQuote(text)]);
24855
+ /**
24856
+ * Resolves the post-hoc short hash/subject from `head` (a commitish) in-script
24857
+ * — this line runs AFTER the reset that made `head` the new HEAD, so neither
24858
+ * value is knowable when gtd generates it.
24859
+ */
24860
+ const abandonedOutcome = (from, head, state) => printfLine(FMT_ABANDONED, [
24861
+ shellQuote(from),
24862
+ `"$(git rev-parse --short ${shellQuote(head)})"`,
24863
+ `"$(git log -1 --format=%s ${shellQuote(head)})"`,
24864
+ shellQuote(state)
24865
+ ]);
24866
+ /** `gtd abandon`'s no-op outcome — the same wording `abandonNoopText` renders. */
24867
+ const abandonNoopOutcome = (initial) => noteOutcome(abandonNoopText(initial));
24868
+ /** Resolves the post-hoc short hash/subject from `to` (a commitish) in-script, for the same reason `abandonedOutcome` does. */
24869
+ const restoredOutcome = (to, state) => printfLine(FMT_RESTORED, [
24870
+ `"$(git rev-parse --short ${shellQuote(to)})"`,
24871
+ `"$(git log -1 --format=%s ${shellQuote(to)})"`,
24872
+ shellQuote(state)
24873
+ ]);
24874
+ //#endregion
24875
+ //#region src/emit/ModeContradiction.ts
25035
24876
  /**
25036
- * `judgeBudgetBytes` is the one var that refuses rather than disabling its
25037
- * mechanism when blanked: without a bound the judge payload is rejected
25038
- * anyway. Absent falls back to a conservative default.
24877
+ * Written for two readers at once, since gtd validate's exit code carries no
24878
+ * second meaning to distinguish them: the agent (this is a CONFIGURATION BUG,
24879
+ * not the steering file — stop, don't edit it, or a fix-retry loop will
24880
+ * thrash on the document) and the human (which mode, and the exact rendered
24881
+ * `format:` command, to fix `gtd.config.ts` without reproducing the failure).
25039
24882
  */
25040
- const DEFAULT_JUDGE_BUDGET_BYTES = 32768;
25041
- const judgeBudgetBytes = (vars) => {
25042
- const raw = vars.judgeBudgetBytes;
25043
- if (raw === void 0) return DEFAULT_JUDGE_BUDGET_BYTES;
25044
- const n = Number(raw);
25045
- if (raw.trim() === "" || !Number.isInteger(n) || n <= 0) throw new Error(`"judgeBudgetBytes" must be a positive integer — got ${JSON.stringify(raw)} (blanking this var disables the payload bound rather than the mechanism it guards, so it is refused rather than defaulted)`);
25046
- return n;
25047
- };
25048
- const replayFor = (setup, pending) => replay({
25049
- flow: setup.def.flow,
25050
- episode: {
25051
- base: setup.base,
25052
- commits: setup.episode
25053
- },
25054
- vars: setup.vars,
25055
- env: setup.env,
25056
- start: setup.run.diffBase,
25057
- startTree: setup.run.diffBase === EMPTY_TREE$1 ? treeFromRecord({}) : commitTree(setup.workspace, setup.run.diffBase),
25058
- budgetBytes: setup.budget,
25059
- skills: setup.skills,
25060
- configuredSkills: setup.def.configuredSkills,
25061
- access: setup.access,
25062
- configuredAccess: setup.def.configuredAccess,
25063
- ...pending !== void 0 ? { pending } : {}
25064
- });
25065
- const replayError = (outcome) => {
25066
- if (outcome.kind === "divergence" || outcome.kind === "failed") return new Error(outcome.message);
25067
- if (outcome.kind === "refused") return /* @__PURE__ */ new Error(`gtd: ${outcome.message}`);
25068
- if (outcome.kind === "ended") return /* @__PURE__ */ new Error("gtd: history ends the episode, but HEAD does not enter the default workflow's first step — the workflow changed under this process; run `gtd abandon` to start over");
25069
- };
25070
- const judgeDocument = (step) => step.request.kind === "judge" ? judgeJson(step.request.evidence, step.request.questions) : void 0;
24883
+ const contradictionMessage = (mode, formatCommand) => `gtd: mode "${mode}"'s format: command breaks its own validator — this is a CONFIGURATION BUG, not a problem with the steering file. Do NOT edit the steering file — stop and end your turn now.\nThe rendered format: command that ran:\n${formatCommand}\nWhat it turned mode "${mode}"'s own canonical sample into:`;
25071
24884
  /**
25072
- * The fixed sentence appended to a judge gate's message when a bounded read
25073
- * dropped bytes to fit `judgeBudgetBytes` — a constant, so no workflow can
25074
- * reword or drop it.
24885
+ * Formats a copy of the mode's own canonical sample and re-validates it with
24886
+ * `gtd check <mode>`, failing loudly when the round-trip breaks it — detects a
24887
+ * mode whose `format:` contradicts its own `validate:` without ever touching
24888
+ * the real steering file. `printf '%s'` over a `shellQuote`d sample, never a
24889
+ * heredoc, since no delimiter chosen ahead of time can collide with the
24890
+ * sample's own bytes. One bash fragment with no blank lines (so
24891
+ * `src/emit/Emit.ts`'s `assembleScript` never splits it from a sibling step), meant
24892
+ * to run BEFORE `[ -f <file> ] || exit 0` in the emitted validate script so it
24893
+ * still fires at a first-write beat where the real steering file doesn't
24894
+ * exist yet.
25075
24895
  */
25076
- const TRUNCATION_NOTICE = "Note: some evidence above was truncated to fit the judge's payload budget.";
25077
- const DEFAULT_JUDGE_MESSAGE = "A judgment is pending. Run `gtd judge answer` and pipe a verdict, or land to take the conservative default with no verdict recorded.";
25078
- const optional = (key, value) => value === void 0 ? {} : { [key]: value };
25079
- const commonOf = (step) => {
25080
- const options = step.request.options;
25081
- return {
25082
- actor: step.actor,
25083
- ...optional("label", options.label),
25084
- ...optional("file", options.file),
25085
- ...optional("mode", options.mode)
25086
- };
25087
- };
25088
- const promptDef = (common, request, skills, access) => ({
25089
- ...common,
25090
- kind: "prompt",
25091
- content: request.prompt,
25092
- ...optional("model", request.options.model),
25093
- ...optional("system", request.options.system),
25094
- ...optional("allowEmpty", request.options.allowEmpty),
25095
- ...optional("keep", request.options.keep),
25096
- ...optional("skills", skills),
25097
- ...optional("access", access)
25098
- });
25099
- const scriptDef = (common, request) => ({
25100
- ...common,
25101
- kind: "script",
25102
- content: request.body
25103
- });
25104
- const judgeDef = (common, request, step) => {
25105
- const message = request.options.message ?? DEFAULT_JUDGE_MESSAGE;
25106
- return {
25107
- ...common,
25108
- kind: "message",
25109
- content: step.truncated ? `${message}\n\n${TRUNCATION_NOTICE}` : message,
25110
- ...optional("judge", judgeDocument(step))
25111
- };
24896
+ const buildModeContradictionCheck = (inputs) => {
24897
+ const { mode, samplePath, sample, formatCommand } = inputs;
24898
+ const pathQ = shellQuote(samplePath);
24899
+ const messageQ = shellQuote(contradictionMessage(mode, formatCommand));
24900
+ return [
24901
+ `printf '%s' ${shellQuote(sample)} > ${pathQ}`,
24902
+ formatCommand,
24903
+ `gtd check ${mode} ${pathQ} >/dev/null 2>&1 || {`,
24904
+ ` printf '%s\\n' ${messageQ} >&2`,
24905
+ ` cat ${pathQ} >&2`,
24906
+ ` rm -f ${pathQ}`,
24907
+ ` exit 1`,
24908
+ `}`,
24909
+ `rm -f ${pathQ}`
24910
+ ].join("\n");
25112
24911
  };
25113
- const messageDef = (common, request, step) => ({
25114
- ...common,
25115
- kind: "message",
25116
- content: request.options.message ?? request.options.label ?? step.name,
25117
- ...optional("acceptClean", request.options.approve === true || request.options.acceptClean),
25118
- ...optional("approve", request.options.approve)
25119
- });
25120
- const stepDefOf = (step) => {
25121
- const request = step.request;
25122
- const common = commonOf(step);
25123
- switch (request.kind) {
25124
- case "agent": return promptDef(common, request, step.skills, step.access);
25125
- case "run": return scriptDef(common, request);
25126
- case "judge": return judgeDef(common, request, step);
25127
- case "human": return messageDef(common, request, step);
25128
- }
24912
+ /**
24913
+ * Skip notice for a mode whose validator is EXTERNAL (a user `validate:`
24914
+ * command, not one of the two built-in modes) — there's no in-process parser
24915
+ * to round-trip a sample through. Printed to stderr since silence would read
24916
+ * as a clean bill of health.
24917
+ */
24918
+ const modeContradictionSkipNotice = (mode) => {
24919
+ return `printf '%s\\n' ${shellQuote(`gtd: mode "${mode}" has an external validate: command — skipping the format/validate contradiction check`)} >&2`;
25129
24920
  };
25130
- const ROOT_MEMORY_SCOPE_NAME = "root";
24921
+ //#endregion
24922
+ //#region src/emit/SteeringMode.ts
25131
24923
  /**
25132
- * An agent rest's memory key, `<scope>#<hash7>`: the hash is the commit the
25133
- * current unbroken run into the scope started FROM, so the same run always
25134
- * re-derives the same key. `resumed` is whether an agent turn in that run
25135
- * already landed.
24924
+ * `format` alone (no live/external verdict) when there's no validator at
24925
+ * all; a live in-process validator for a `"builtin"` validate OR a command
24926
+ * that is exactly the format's own seeded string; `externalValidate` for any
24927
+ * other command. Each arm returns directly — a flat sequence, not nested
24928
+ * ternaries — so the four outcomes stay each their own case to read, not a
24929
+ * boolean expression to re-derive.
25136
24930
  */
25137
- const memoryOf = (trace, run) => {
25138
- const rest = trace[trace.length - 1];
25139
- if (rest === void 0 || rest.kind !== "agent") return {
25140
- key: void 0,
25141
- resumed: false
24931
+ const capabilitiesFor = (mode, format, validate) => {
24932
+ if (format === void 0) return validate?.kind === "command" ? { externalValidate: true } : {};
24933
+ if (validate?.kind === "builtin") return {
24934
+ format,
24935
+ liveValidate: format.validate
24936
+ };
24937
+ if (validate === void 0) return { format };
24938
+ if (isSeededValidateCommand(mode, validate.command)) return {
24939
+ format,
24940
+ liveValidate: format.validate
25142
24941
  };
25143
- const start = scopeRunStart(trace, rest.memoryScope);
25144
- const token = start <= 0 ? run.startParentHash : trace[start - 1].enteredAt;
25145
- const resumed = trace.slice(Math.max(start, 0), trace.length - 1).some((s) => s.kind === "agent" && s.memoryScope === rest.memoryScope);
25146
24942
  return {
25147
- key: `${rest.memoryScope || ROOT_MEMORY_SCOPE_NAME}#${token.slice(0, 7)}`,
25148
- resumed
24943
+ format,
24944
+ externalValidate: true
25149
24945
  };
25150
24946
  };
25151
- const hintsOf = (def) => ({
25152
- ...optional("model", def.model),
25153
- ...optional("label", def.label),
25154
- ...optional("file", def.file),
25155
- ...optional("judge", def.judge),
25156
- ...optional("system", def.system),
25157
- ...optional("mode", def.mode),
25158
- ...optional("skills", def.skills),
25159
- ...optional("access", def.access)
25160
- });
25161
- const normalizeStatus = (raw) => raw === "A" ? "A" : raw === "D" ? "D" : "M";
25162
- /** Per-model token totals, highest-cost first (ties broken by model name). */
25163
- const costByModel = (entries) => {
25164
- const byModel = /* @__PURE__ */ new Map();
25165
- for (const entry of entries) byModel.set(entry.model, (byModel.get(entry.model) ?? 0) + entry.cost);
25166
- return [...byModel.entries()].map(([model, cost]) => ({
25167
- model,
25168
- cost
25169
- })).sort((a, b) => b.cost - a.cost || a.model.localeCompare(b.model));
25170
- };
25171
- const costTotals = (entries) => ({
25172
- total: entries.reduce((sum, entry) => sum + entry.cost, 0),
25173
- byModel: costByModel(entries)
25174
- });
25175
- const setupFor = (config, run, workspace, host, startOverrides = {}) => gen(function* () {
25176
- const resolved = yield* workflowOf(config, run);
25177
- const def = resolved.def;
25178
- const startFlags = run.trace.length === 0 ? yield* StartFlags : {};
25179
- const { vars, env } = settingsFor(resolved, run.pinnedVars, host.env, startOverrides, startFlags);
25180
- const skills = yield* resolveScopeSkills(def, vars);
25181
- const access = yield* resolveScopeAccess(def, skills, vars);
25182
- const budget = yield* try_({
25183
- try: () => judgeBudgetBytes(vars),
25184
- catch: (e) => e instanceof Error ? e : new Error(String(e))
25185
- });
25186
- yield* workspace.episodeTrees(run.episode.base, run.episode.commits.map((c) => c.hash));
25187
- const base = run.episode.base === void 0 ? {
25188
- hash: EMPTY_TREE$1,
25189
- tree: treeFromRecord({})
25190
- } : {
25191
- hash: run.episode.base,
25192
- tree: commitTree(workspace, run.episode.base)
25193
- };
25194
- const git = yield* GitService;
25195
- const episode = [];
25196
- for (const c of run.episode.commits) {
25197
- const tip = parseCommitMessage(c.message).kept;
25198
- const exists = tip !== void 0 && isSome(yield* git.readRefOption(`${tip}^{commit}`));
25199
- episode.push({
25200
- hash: c.hash,
25201
- message: c.message,
25202
- tree: commitTree(workspace, c.hash),
25203
- ...exists ? { kept: {
25204
- hash: tip,
25205
- tree: commitTree(workspace, tip)
25206
- } } : {}
25207
- });
25208
- }
25209
- let pendingMemo;
25210
- return {
25211
- resolved,
25212
- def,
25213
- vars,
25214
- env,
25215
- setup: {
25216
- def,
25217
- run,
25218
- vars,
25219
- env,
25220
- budget,
25221
- skills,
25222
- access,
25223
- workspace,
25224
- base,
25225
- episode,
25226
- pendingWorktree: () => pendingMemo ??= workspace.worktreeSync()
25227
- }
24947
+ /**
24948
+ * Resolve a `mode:` name against `def.modes` plus the built-in registry
24949
+ * (`src/workflow/SteeringFormats.ts`) — half by half: a declared `format:`/`validate:`
24950
+ * wins, and an undeclared `validate:` falls back to the built-in format's own
24951
+ * parser when the name is registered. `format` is set from the registry
24952
+ * ALONE, independent of which half of `validate` wins — a declared validator
24953
+ * overrides validation, not the format identity.
24954
+ *
24955
+ * `def: undefined` is the LSP's built-in-only fallback — resolving `mode`
24956
+ * against the registry alone, with no workflow definition in hand (e.g. the
24957
+ * basename dispatch for a `REVIEW.md` no config maps).
24958
+ *
24959
+ * The `"unknown"` arm carries the whole refusal message, naming what IS known
24960
+ * when `def` is given — this is the one place that message is built, so it
24961
+ * can never drift from what actually resolved.
24962
+ */
24963
+ const resolveMode = (def, state, mode) => {
24964
+ const declared = def?.modes?.[mode];
24965
+ const format = steeringFormatFor(mode);
24966
+ if (declared === void 0 && format === void 0) return {
24967
+ kind: "unknown",
24968
+ message: def !== void 0 ? `state "${state}": mode "${mode}" is not defined by the active workflow (known modes: ${knownModes(def).join(", ")})` : `mode "${mode}" is not a built-in steering format`
25228
24969
  };
25229
- });
25230
- const pendingChanges = (git, withIndex) => gen(function* () {
25231
- const worktree = yield* git.changedPaths();
25232
- if (!withIndex) return worktree;
25233
- const seen = new Set(worktree.map((e) => e.path));
25234
- return [...worktree, ...(yield* git.stagedPaths()).filter((e) => !seen.has(e.path))];
25235
- });
25236
- /** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
25237
- const restAt = (ref, startOverrides = {}) => gen(function* () {
25238
- const git = yield* GitService;
25239
- const config = yield* (yield* ConfigService).load;
25240
- const workspace = yield* Workspace;
25241
- const host = yield* Host;
25242
- const run = yield* computeProcessRun(git, config.workflow, ref);
25243
- const { resolved, def, vars, env, setup } = yield* setupFor(config, run, workspace, host, startOverrides);
25244
- const outcome = yield* promise(() => replayFor(setup));
25245
- const error = replayError(outcome);
25246
- if (error !== void 0 || outcome.kind !== "rest") return yield* fail$4(error ?? /* @__PURE__ */ new Error("gtd: replay found no rest"));
25247
- const step = outcome.rest;
25248
- yield* (yield* Narrator).narrate(`rest resolved: ${step.name} (awaits ${step.actor})`);
25249
- const stepDef = stepDefOf(step);
25250
- if (stepDef.mode !== void 0 && def.modes[stepDef.mode] === void 0) return yield* fail$4(/* @__PURE__ */ new Error(`gtd: config: step "${step.name}": mode "${stepDef.mode}" is not a mode this workflow knows (${knownModes(def).join(", ")})`));
25251
- const changes = ref === void 0 ? (yield* pendingChanges(git, stepDef.approve === true)).map((e) => ({
25252
- status: normalizeStatus(e.status),
25253
- path: e.path
25254
- })) : [];
25255
- const memory = memoryOf(outcome.trace, run);
25256
- const configNow = config.fingerprint;
25257
- if (ref === void 0) yield* warnConfigChanges(run, configNow, workspace);
24970
+ const validate = declared?.validate !== void 0 ? {
24971
+ kind: "command",
24972
+ command: declared.validate
24973
+ } : format !== void 0 ? {
24974
+ kind: "builtin",
24975
+ format
24976
+ } : void 0;
25258
24977
  return {
25259
- def,
25260
- workflowName: resolved.name,
25261
- state: step.name,
25262
- stepDef,
25263
- actor: step.actor,
25264
- step,
25265
- trace: outcome.trace,
25266
- run,
25267
- vars,
25268
- env,
25269
- config: configNow,
25270
- changes,
25271
- memory: memory.key,
25272
- memoryResumed: memory.resumed,
25273
- hints: hintsOf(stepDef),
25274
- cost: costTotals(run.costEntries),
25275
- setup
24978
+ kind: "resolved",
24979
+ mode,
24980
+ ...format !== void 0 ? { format } : {},
24981
+ ...declared?.format !== void 0 ? { formatCommand: declared.format } : {},
24982
+ ...validate !== void 0 ? { validate } : {},
24983
+ capabilities: capabilitiesFor(mode, format, validate)
25276
24984
  };
24985
+ };
24986
+ /** `gtd next`'s fix-retry instruction, printed ahead of the failing command's captured output. */
24987
+ const fixPromptInstruction = (file) => `Your last turn does not pass its own validation script. Fix these format violations in ${file}, then finish:`;
24988
+ /**
24989
+ * Pushes a `binaryGuard` step ahead of `command` when its own leading word
24990
+ * is unambiguous — BEFORE, not wrapped around: a guard that fails must stop
24991
+ * the script on its own `exit 127`, without touching `failurePromptWrapper`'s
24992
+ * captured-output path. Each command guards on its OWN leading word — a
24993
+ * guard naming the wrong binary is worse than none.
24994
+ */
24995
+ const pushGuardedCommand = (steps, mode, key, command, file, onFailure) => {
24996
+ const binary = extractLeadingBinary(command);
24997
+ if (binary !== void 0) steps.push({
24998
+ kind: "command",
24999
+ command: binaryGuard(binary, mode, key)
25000
+ });
25001
+ steps.push({
25002
+ kind: "command",
25003
+ command: withFileVar(command, file),
25004
+ ...onFailure !== void 0 ? { onFailure } : {}
25005
+ });
25006
+ };
25007
+ /**
25008
+ * `resolved`'s `format:`/`validate:` as `EmitStep[]` — format first,
25009
+ * validate last, the last one wrapped with `onFailure` when the validator is
25010
+ * a command (a built-in's own in-process parser has no shell step to wrap:
25011
+ * `gtd check`'s seeded command carries that instead). Each command gets its
25012
+ * own `binaryGuard` immediately ahead of it.
25013
+ */
25014
+ const modeCommandSteps = (resolved, file) => {
25015
+ const steps = [];
25016
+ if (resolved.formatCommand !== void 0) pushGuardedCommand(steps, resolved.mode, "format", resolved.formatCommand, file, void 0);
25017
+ if (resolved.validate?.kind === "command") pushGuardedCommand(steps, resolved.mode, "validate", resolved.validate.command, file, fixPromptInstruction(file));
25018
+ return steps;
25019
+ };
25020
+ /** `<Host.scratchDir>/gtd-mode-sample-<mode>-<pid>.md` — an absolute literal baked in at emit time, never a shell variable. `<pid>` avoids collisions between concurrent `gtd` processes. Never a `/tmp` literal or `mktemp` (`tests/tooling/no-tmp-assumption.test.ts` scans for both). */
25021
+ const scratchSamplePath = (scratchDir, mode) => join(scratchDir, `gtd-mode-sample-${mode}-${process.pid}.md`);
25022
+ /**
25023
+ * The contradiction round-trip/skip-notice steps for `resolved`'s `mode:`.
25024
+ * No `formatCommand` means nothing to round-trip: empty. Otherwise: a live
25025
+ * built-in validator runs the round-trip against that format's own canonical
25026
+ * sample; an external validator prints a one-line skip notice instead, since
25027
+ * silence there would read as a clean bill of health; a format-only mode with
25028
+ * neither has nothing to round-trip either.
25029
+ */
25030
+ const modeContradictionSteps = (resolved) => gen(function* () {
25031
+ if (resolved.formatCommand === void 0) return [];
25032
+ const { capabilities } = resolved;
25033
+ if (capabilities.format !== void 0 && capabilities.liveValidate !== void 0) {
25034
+ const { scratchDir } = yield* Host;
25035
+ const samplePath = scratchSamplePath(scratchDir, resolved.mode);
25036
+ const formatCommand = withFileVar(resolved.formatCommand, samplePath);
25037
+ return [{
25038
+ kind: "command",
25039
+ command: buildModeContradictionCheck({
25040
+ mode: resolved.mode,
25041
+ samplePath,
25042
+ sample: capabilities.format.sample,
25043
+ formatCommand
25044
+ })
25045
+ }];
25046
+ }
25047
+ if (capabilities.externalValidate === true) return [{
25048
+ kind: "command",
25049
+ command: modeContradictionSkipNotice(resolved.mode)
25050
+ }];
25051
+ return [];
25277
25052
  });
25278
- const currentRest = restAt(void 0);
25279
25053
  /**
25280
- * Whether `gtd start <name>` can start a process here: the workflow must
25281
- * reach a step from the tree the opening commit would capture. That step is
25282
- * what the opening commit's subject names.
25054
+ * Renders `resolved`'s complete validate script for `file`: the mode's
25055
+ * format/validate contradiction check, a guard for a not-yet-written file,
25056
+ * then the format/validate commands themselves (format first). gtd never
25057
+ * runs this script itself — `resolveMode`+`validateScriptFor` only render
25058
+ * text for a driver to execute (`gtd renders; the driver executes`).
25283
25059
  */
25284
- const startRefusal = (rest, resolved, startVars) => gen(function* () {
25285
- const host = yield* Host;
25286
- const { vars, env } = settingsFor(resolved, {}, host.env, startVars, yield* StartFlags);
25287
- const skills = yield* resolveScopeSkills(resolved.def, vars);
25288
- const access = yield* resolveScopeAccess(resolved.def, skills, vars);
25289
- const workspace = rest.setup.workspace;
25290
- const outcome = yield* promise(() => replay({
25291
- flow: resolved.def.flow,
25292
- episode: {
25293
- base: {
25294
- hash: "",
25295
- tree: pendingTree(workspace, rest.setup.pendingWorktree(), void 0)
25296
- },
25297
- commits: []
25060
+ const validateScriptFor = (resolved, file) => gen(function* () {
25061
+ return { script: emitScripts([
25062
+ ...yield* modeContradictionSteps(resolved),
25063
+ {
25064
+ kind: "command",
25065
+ command: fileExistsGuard(file)
25298
25066
  },
25299
- vars,
25300
- env,
25301
- start: "",
25302
- budgetBytes: rest.setup.budget,
25303
- skills,
25304
- configuredSkills: resolved.def.configuredSkills,
25305
- access,
25306
- configuredAccess: resolved.def.configuredAccess
25307
- }));
25308
- if (outcome.kind === "rest") return { firstStep: outcome.rest.name };
25309
- if (outcome.kind === "refused") return { refusal: outcome.message };
25310
- const why = outcome.kind === "ended" ? "the flow reaches no step" : outcome.message;
25311
- return { refusal: `workflow "${resolved.name}" cannot start — ${why}` };
25312
- });
25313
- /** The review window's diff base at the rest. */
25314
- const reviewBaseFor = (rest) => rest.step.request.options.base ?? rest.run.diffBase;
25315
- const renderRest = (rest) => succeed$1({
25316
- state: rest.state,
25317
- actor: rest.actor,
25318
- kind: rest.stepDef.kind,
25319
- content: rest.stepDef.content,
25320
- ...rest.hints,
25321
- ...rest.memory !== void 0 ? { memory: rest.memory } : {},
25322
- memoryResumed: rest.memoryResumed
25067
+ ...modeCommandSteps(resolved, file)
25068
+ ]).required };
25323
25069
  });
25070
+ //#endregion
25071
+ //#region src/step/planStep.ts
25324
25072
  /**
25325
- * Derived, restart-proof stall detection: the tree is clean, HEAD is an empty
25326
- * attempt at this very agent rest, and another dispatch would repeat it.
25073
+ * At the human review gate an unconditional `gtd uncheck '<file>'` runs
25074
+ * ahead of the commit, resetting every checkbox before `git add -A` picks it
25075
+ * up — a tick is read-progress, never sign-off.
25327
25076
  */
25328
- 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;
25329
- /** 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. */
25330
- const noProcessUnderway = (rest) => rest.run.workflow === void 0 && rest.state === rest.def.initial && rest.step.id.occurrence === 1;
25331
- /** `idle` means exactly one thing: no process underway, clean tree. */
25332
- const restIsIdle = (rest) => noProcessUnderway(rest) && rest.changes.length === 0;
25333
- const isHumanReviewGate = (def) => def.actor === "human" && def.mode === "review";
25334
- const approvalRefusal = (rest, approver) => {
25335
- if (rest.stepDef.approve !== true) return void 0;
25336
- const id = rest.step.id;
25337
- if (rest.changes.length > 0) return {
25338
- kind: "refusal",
25339
- message: `gtd land: approval-gate: ${formatStepId(id)} approves exactly the judged diff — revert your edits and run \`gtd land\` on a clean tree to approve, or run \`gtd abandon\``
25077
+ const uncheckStep = (snapshot) => snapshot.stepDef.actor === "human" && snapshot.stepDef.mode === "review" && snapshot.file !== void 0 ? [{
25078
+ kind: "uncheck",
25079
+ file: snapshot.file
25080
+ }] : [];
25081
+ const gitWriteOf = (landing, message) => {
25082
+ if (landing.only !== void 0) return {
25083
+ kind: "commitPath",
25084
+ path: landing.only,
25085
+ message
25340
25086
  };
25341
- if (isNone(approver)) return {
25342
- kind: "refusal",
25343
- message: `gtd land: approval-gate: ${formatStepId(id)} records who approved it, but git has no author identity — set user.name and user.email`
25087
+ if (landing.keepTurn !== void 0) return {
25088
+ kind: "keepTurn",
25089
+ ref: landing.keepTurn,
25090
+ message
25344
25091
  };
25345
- if (!isRecordableApproval({
25346
- step: id,
25347
- ...approver.value
25348
- })) return {
25349
- kind: "refusal",
25350
- message: `gtd land: approval-gate: ${formatStepId(id)} cannot record ${approver.value.name} <${approver.value.email}> as approver — it would not read back unchanged from its Gtd-Approved trailer (a control character, for example)`
25092
+ if (landing.spec.approved !== void 0) return {
25093
+ kind: "commitClean",
25094
+ message
25095
+ };
25096
+ return {
25097
+ kind: "commitAll",
25098
+ message
25351
25099
  };
25352
25100
  };
25353
- /** The landing a clean tree decides before any replay, if it decides one. */
25354
- const cleanLanding = (rest) => {
25355
- if (rest.changes.length > 0) return void 0;
25356
- const def = rest.stepDef;
25357
- if (def.kind === "prompt" && def.allowEmpty !== true) return {
25358
- kind: "attempt",
25359
- subject: formatSubject(rest.actor, rest.state)
25101
+ const planStep = (snapshot, opts = {}) => {
25102
+ const landing = snapshot.landing;
25103
+ if (landing.kind === "refusal") return {
25104
+ kind: "refusal",
25105
+ message: landing.message
25360
25106
  };
25361
- if (rest.actor === "human" && def.acceptClean !== true) return {
25107
+ if (landing.kind === "noop") return {
25362
25108
  kind: "noop",
25363
- settled: false
25109
+ state: snapshot.state,
25110
+ settled: landing.settled
25111
+ };
25112
+ const cost = opts.cost === void 0 ? {} : { cost: {
25113
+ cost: opts.cost,
25114
+ ...opts.model !== void 0 ? { model: opts.model } : {}
25115
+ } };
25116
+ if (landing.kind === "attempt") {
25117
+ const message = formatCommitMessage({
25118
+ actor: snapshot.actor,
25119
+ to: snapshot.state,
25120
+ ...cost
25121
+ });
25122
+ return {
25123
+ kind: "commit",
25124
+ state: snapshot.state,
25125
+ to: snapshot.state,
25126
+ subject: landing.subject,
25127
+ steps: [{
25128
+ kind: "gitWrite",
25129
+ write: {
25130
+ kind: "commitAll",
25131
+ message
25132
+ }
25133
+ }, {
25134
+ kind: "outcome",
25135
+ outcome: {
25136
+ kind: "commit",
25137
+ subject: landing.subject
25138
+ }
25139
+ }]
25140
+ };
25141
+ }
25142
+ const message = formatCommitMessage({
25143
+ ...landing.spec,
25144
+ ...cost,
25145
+ ...opts.judge !== void 0 && opts.judge.length > 0 ? { judge: opts.judge } : {}
25146
+ });
25147
+ const subject = message.split("\n")[0];
25148
+ const outcome = landing.to === snapshot.state ? {
25149
+ kind: "outcome",
25150
+ outcome: {
25151
+ kind: "commit",
25152
+ subject
25153
+ }
25154
+ } : {
25155
+ kind: "outcome",
25156
+ outcome: {
25157
+ kind: "transition",
25158
+ from: snapshot.state,
25159
+ to: landing.to
25160
+ }
25364
25161
  };
25365
- };
25366
- /** At the human review gate the pending tree lands with every tick cleared, as the landing script will. */
25367
- const reviewGateRewrite = (def) => {
25368
- const reviewFormat = steeringFormatFor("review");
25369
- if (!isHumanReviewGate(def) || def.file === void 0 || reviewFormat === void 0) return;
25370
25162
  return {
25371
- path: def.file,
25372
- apply: (content) => reviewFormat.clearTicks(content)
25373
- };
25374
- };
25375
- const multilineRefusal = (vars) => {
25376
- const multiline = multilineSetting(vars);
25377
- return multiline === void 0 ? void 0 : {
25378
- kind: "refusal",
25379
- message: `gtd: process setting "${multiline}" spans several lines — a process setting is pinned in a commit trailer and must be a single line`
25163
+ kind: "commit",
25164
+ state: snapshot.state,
25165
+ to: landing.to,
25166
+ subject,
25167
+ steps: [
25168
+ ...uncheckStep(snapshot),
25169
+ {
25170
+ kind: "gitWrite",
25171
+ write: gitWriteOf(landing, message)
25172
+ },
25173
+ ...landing.keepHistory !== void 0 ? [{
25174
+ kind: "gitWrite",
25175
+ write: {
25176
+ kind: "updateRef",
25177
+ ref: landing.keepHistory,
25178
+ target: "HEAD"
25179
+ }
25180
+ }] : [],
25181
+ outcome
25182
+ ]
25380
25183
  };
25381
25184
  };
25382
- const landingTarget = (outcome, rest) => {
25383
- if (outcome.kind === "refused") return succeed$1({
25384
- kind: "refusal",
25385
- message: outcome.message
25386
- });
25387
- if (outcome.kind === "divergence" || outcome.kind === "failed") return fail$4(new Error(outcome.message));
25388
- if (outcome.kind !== "rest") return succeed$1(rest.def.initial);
25389
- const reserved = initialStepRefusal(rest.run.workflow, outcome.rest.name, rest.def.initial);
25390
- return succeed$1(reserved === void 0 ? outcome.rest.name : reserved);
25391
- };
25185
+ //#endregion
25186
+ //#region src/step/accessRefusal.ts
25392
25187
  /**
25393
- * A step commit entering the default's first step is what ends an episode, so
25394
- * a named workflow landing there would read back as finished. Its opening
25395
- * commit may name that step: a bare opening is never read as a boundary.
25188
+ * The landing refusal for changes outside `write`, or `undefined` when every
25189
+ * path is allowed. `null` is unrestricted. The message is the fix prompt: gtd
25190
+ * runs no fix turn of its own.
25396
25191
  */
25397
- const initialStepRefusal = (workflow, step, initial) => workflow !== void 0 && step === initial ? {
25192
+ const accessRefusal = (changes, write) => {
25193
+ if (write === null) return void 0;
25194
+ const offending = [...new Set(changes.filter((c) => !write.some((glob) => globMatches(c.path, glob))).map((c) => c.path))];
25195
+ if (offending.length === 0) return void 0;
25196
+ return [
25197
+ ACCESS_REFUSAL,
25198
+ ...offending.map((path) => ` - ${path}`),
25199
+ `allowed: ${write.length === 0 ? "(nothing)" : write.join(", ")}`
25200
+ ].join("\n");
25201
+ };
25202
+ //#endregion
25203
+ //#region src/step/planStart.ts
25204
+ const refusal = (message) => ({
25398
25205
  kind: "refusal",
25399
- message: `gtd: workflow "${workflow}" reaches step "${step}", the default workflow's first step — that is where a finished process waits, so a started process must never land there; rename the step`
25400
- } : void 0;
25401
- const closingTrailers = (branch) => ({
25402
- version: GTD_VERSION,
25403
- ...isSome(branch) ? { branch: branch.value } : {}
25206
+ message
25404
25207
  });
25405
- const isSettledNoop = (rest, to) => rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state;
25406
- const keptTurn = (rest) => rest.stepDef.keep === true && rest.changes.length > 0 ? { keepTurn: keptRef(rest.run.startHash, rest.state) } : {};
25407
- const approvedTrailer = (rest, approver) => isSome(approver) ? { approved: {
25408
- step: rest.step.id,
25409
- ...approver.value
25410
- } } : {};
25411
- const landingTo = (rest, to, branch, approver, marks) => {
25412
- if (isSettledNoop(rest, to)) return {
25413
- kind: "noop",
25414
- settled: true
25415
- };
25416
- const underway = !noProcessUnderway(rest);
25417
- const starts = !underway && to !== rest.def.initial;
25418
- const refused = starts ? multilineRefusal(rest.vars) : void 0;
25419
- if (refused !== void 0) return refused;
25420
- const finishes = underway && to === rest.def.initial;
25208
+ const checkOverrides = (commandLabel, varOverrides, declaredNames, envNames) => gen(function* () {
25209
+ const environmentOnly = Object.keys(varOverrides).filter((n) => !declaredNames.includes(n) && envNames.includes(n));
25210
+ if (environmentOnly.length > 0) return yield* fail$4(new GtdUsageError(`${commandLabel}: --var pins process settings only; ${environmentOnly.join(", ")} ${environmentOnly.length === 1 ? "is an environment setting" : "are environment settings"} — set it as a workflow option in gtd.config.ts or with a GTD_<NAME> environment variable`));
25211
+ const undeclared = Object.keys(varOverrides).filter((n) => !declaredNames.includes(n));
25212
+ if (undeclared.length > 0) return yield* fail$4(new GtdUsageError(`${commandLabel}: --var name(s) not declared by this workflow: ${undeclared.join(", ")} — declared: ${declaredNames.length > 0 ? declaredNames.join(", ") : "(none)"}`));
25213
+ });
25214
+ /**
25215
+ * `gtd start <name>`: start a brand NEW process on a workflow, writing an
25216
+ * opening commit that names the workflow's first step and carries a
25217
+ * `Gtd-Workflow:` trailer, every process setting as a `Gtd-Var:` trailer, plus
25218
+ * and a `Gtd-Review-Base:` trailer when the workflow fixes the process's diff base.
25219
+ * All validation is a REFUSAL, not an Effect failure.
25220
+ */
25221
+ const planStart = (current, actor, start) => gen(function* () {
25222
+ const { workflow, first, commandLabel, vars: varOverrides } = start;
25223
+ const name = workflow.name;
25224
+ if (!current.idle) return refusal(`${commandLabel}: a process is already underway (resting at "${current.state}") — finish it, or run \`gtd abandon\`, before starting another`);
25225
+ if ("refusal" in first) return refusal(`${commandLabel}: ${first.refusal}`);
25226
+ const { firstStep } = first;
25227
+ yield* checkOverrides(commandLabel, varOverrides, Object.keys(workflow.vars), Object.keys(workflow.env));
25228
+ const baseOf = workflow.def.base;
25229
+ const flags = yield* StartFlags;
25230
+ const hostEnv = (yield* Host).env;
25231
+ const badFlag = ownFlagRefusal(workflow.vars, flags, hostEnv);
25232
+ if (badFlag !== void 0) return refusal(`${commandLabel}: ${badFlag}`);
25233
+ const vars = resolveStartVars(workflow.vars, flags, varOverrides, hostEnv);
25234
+ const multiline = multilineSetting(vars);
25235
+ if (multiline !== void 0) return refusal(`${commandLabel}: process setting "${multiline}" spans several lines — a process setting is pinned in a commit trailer and must be a single line`);
25236
+ const template = yield* try_({
25237
+ try: () => baseOf?.(name, vars),
25238
+ catch: (e) => /* @__PURE__ */ new Error(`${commandLabel}: ${e instanceof Error ? e.message : String(e)}`)
25239
+ });
25240
+ const base = template === void 0 ? void 0 : yield* resolveBase(commandLabel, template);
25241
+ if (typeof base === "object") return base;
25242
+ const subject = formatSubject(actor, firstStep);
25421
25243
  return {
25422
- kind: "commit",
25423
- to,
25424
- spec: {
25425
- actor: rest.actor,
25426
- from: rest.state,
25427
- to,
25428
- step: rest.step.id,
25429
- ...marks.length > 0 && { marks },
25430
- ...approvedTrailer(rest, approver),
25431
- ...startTrailers(rest, starts),
25432
- ...finishes ? closingTrailers(branch) : {}
25433
- },
25434
- ...finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {},
25435
- ...keptTurn(rest)
25244
+ kind: "start",
25245
+ workflow: name,
25246
+ subject,
25247
+ steps: [{
25248
+ kind: "gitWrite",
25249
+ write: {
25250
+ kind: "commitAll",
25251
+ message: formatCommitMessage({
25252
+ actor,
25253
+ to: firstStep,
25254
+ workflow: name,
25255
+ ...base !== void 0 ? { reviewBase: base } : {},
25256
+ vars
25257
+ })
25258
+ }
25259
+ }, {
25260
+ kind: "outcome",
25261
+ outcome: {
25262
+ kind: "commit",
25263
+ subject
25264
+ }
25265
+ }]
25436
25266
  };
25437
- };
25438
- const startTrailers = (rest, starts) => starts ? {
25439
- vars: rest.vars,
25440
- config: rest.config
25441
- } : {};
25442
- const outcomeMarks = (outcome) => outcome.kind === "rest" || outcome.kind === "ended" ? outcome.marks : [];
25267
+ });
25443
25268
  /**
25444
- * What landing the pending turn at `rest` does — a pure decision from a
25445
- * replay with the working tree as the pending turn. The target step is
25446
- * computed by replaying, never matched.
25269
+ * The workflow's diff base: `git merge-base <base> HEAD`, where a blank base
25270
+ * means the default branch. The merge-base is what gets pinned, so a base on
25271
+ * another branch reviews exactly this branch's own commits.
25447
25272
  */
25448
- const decideLanding = (rest, verdicts, branch, approver) => gen(function* () {
25449
- const unapproved = approvalRefusal(rest, approver);
25450
- if (unapproved !== void 0) return unapproved;
25451
- const early = cleanLanding(rest);
25452
- if (early !== void 0) return early;
25453
- if (rest.stepDef.kind === "prompt" && rest.stepDef.access?.write != null) {
25454
- const message = accessRefusal(rest.changes, rest.stepDef.access.write);
25455
- if (message !== void 0) return {
25456
- kind: "refusal",
25457
- message
25458
- };
25459
- }
25460
- const outcome = yield* promise(() => replayFor(rest.setup, {
25461
- tree: pendingTree(rest.setup.workspace, rest.setup.pendingWorktree(), reviewGateRewrite(rest.stepDef)),
25462
- ...verdicts !== void 0 ? { verdicts } : {}
25463
- }));
25464
- const to = yield* landingTarget(outcome, rest);
25465
- if (typeof to !== "string") return to;
25466
- return landingTo(rest, to, branch, approver, outcomeMarks(outcome));
25467
- });
25468
- /** Where landing the pending turn would leave the process, or `undefined` when it would land nothing. */
25469
- const previewLanding = (rest) => decideLanding(rest, void 0, none(), none()).pipe(map((landing) => landing.kind === "commit" ? landing.to : void 0), catch_(() => succeed$1(void 0)));
25470
- /** Every fact the pure landing planner needs, read once: the rest, its steering file, and the landing decision. */
25471
- const snapshotFromRest = (rest, verdicts) => gen(function* () {
25273
+ const resolveBase = (commandLabel, value) => gen(function* () {
25472
25274
  const git = yield* GitService;
25473
- const branch = yield* git.currentBranch();
25474
- const approver = rest.stepDef.approve === true ? yield* git.authorIdent() : none();
25475
- const landing = yield* decideLanding(rest, verdicts, branch, approver);
25275
+ const rendered = value.trim();
25276
+ const target = rendered === "" ? yield* git.defaultBranch() : rendered;
25277
+ const resolvedBase = yield* result(git.resolveRef(target));
25278
+ if (resolvedBase._tag === "Failure") return refusal(`${commandLabel}: "${target}" does not resolve to a commit`);
25279
+ const mergeBase = yield* git.mergeBase(resolvedBase.success, "HEAD");
25280
+ if (isNone(mergeBase)) return refusal(`${commandLabel}: "${target}" shares no common ancestor with HEAD`);
25281
+ if (mergeBase.value === (yield* git.resolveRef("HEAD"))) return refusal(`${commandLabel}: nothing to review: HEAD has no commits beyond ${target}`);
25282
+ return mergeBase.value;
25283
+ });
25284
+ //#endregion
25285
+ //#region src/step/strayPaths.ts
25286
+ /** Globs use the `access.write` dialect (`globMatches`). */
25287
+ const strayPaths = (touched, allow) => touched.filter((path) => !allow.some((glob) => globMatches(path, glob))).sort();
25288
+ //#endregion
25289
+ //#region src/edge/Edge.ts
25290
+ const EMPTY_TREE$1 = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
25291
+ const ACTORS = /* @__PURE__ */ new Set([
25292
+ "agent",
25293
+ "human",
25294
+ "check",
25295
+ "judge"
25296
+ ]);
25297
+ const isOpening = ({ parsed, step }) => parsed !== void 0 && step === void 0 && parsed.from === void 0 && parsed.actor === "human";
25298
+ const boundsEpisode = (def, { parsed, pin }) => pin !== "add" && (parsed === void 0 || !ACTORS.has(parsed.actor) || parsed.to === def.initial);
25299
+ /**
25300
+ * Where the episode HEAD belongs to begins, read off subjects alone. Walking
25301
+ * back from HEAD: a bare `gtd(human): <step>` commit with no `Gtd-Step` is what
25302
+ * `gtd start` writes — no landing ever produces one — and opens an
25303
+ * episode as its first commit (its `Gtd-Workflow` trailer names the workflow;
25304
+ * a legacy `--entry` opening has none); a commit that is not a gtd step commit, or one
25305
+ * entering the flow's first step (a finished episode), bounds the episode
25306
+ * from below.
25307
+ */
25308
+ const locateEpisode = (def, history) => {
25309
+ for (let i = history.length - 1; i >= 0; i--) {
25310
+ const message = parseCommitMessage(history[i].message);
25311
+ if (isOpening(message)) return {
25312
+ workflow: message.workflow,
25313
+ legacyOpening: message.workflow === void 0,
25314
+ baseIndex: i,
25315
+ processStart: i
25316
+ };
25317
+ if (boundsEpisode(def, message)) return {
25318
+ workflow: void 0,
25319
+ legacyOpening: false,
25320
+ baseIndex: i,
25321
+ processStart: i + 1
25322
+ };
25323
+ }
25476
25324
  return {
25477
- stepDef: rest.stepDef,
25478
- state: rest.state,
25479
- actor: rest.actor,
25480
- changes: rest.changes,
25481
- file: rest.hints.file,
25482
- landing
25325
+ workflow: void 0,
25326
+ legacyOpening: false,
25327
+ baseIndex: -1,
25328
+ processStart: 0
25483
25329
  };
25484
- });
25485
- const humanCommitsOf = (trace, entryCommit) => trace.filter((entry) => entry.actor === "human" && entry.hash !== entryCommit).map((entry) => ({
25486
- hash: entry.hash,
25487
- state: entry.state
25488
- }));
25489
- const summaryInput = (run, settings) => {
25490
- const entryCommit = run.trace[0].hash;
25491
- const { total, byModel } = costTotals(run.costEntries);
25330
+ };
25331
+ const headTurnOf = (head) => {
25332
+ if (head === void 0) return void 0;
25333
+ const message = parseCommitMessage(head.message);
25334
+ const subject = message.parsed;
25335
+ if (subject === void 0 || !ACTORS.has(subject.actor) || message.pin !== void 0) return void 0;
25492
25336
  return {
25493
- entryCommit,
25494
- processBase: run.startParentHash,
25495
- processTip: run.trace[run.trace.length - 1].hash,
25496
- humanCommits: humanCommitsOf(run.trace, entryCommit),
25497
- processCost: total,
25498
- processCostByModel: byModel,
25499
- marks: run.marks,
25500
- ...settings
25337
+ state: subject.to,
25338
+ actor: subject.actor,
25339
+ empty: head.touched.length === 0,
25340
+ step: message.step !== void 0
25501
25341
  };
25502
25342
  };
25503
- const summaryFor = (run) => gen(function* () {
25504
- const config = yield* (yield* ConfigService).load;
25505
- const host = yield* Host;
25506
- const resolved = yield* workflowOf(config, run);
25507
- const summary = resolved.def.summary;
25508
- if (summary === void 0 || run.trace.length === 0) return { prompt: void 0 };
25509
- return { prompt: summary(summaryInput(run, settingsFor(resolved, run.pinnedVars, host.env, {}, {}))) };
25510
- });
25511
- /** Repository-relative paths of the config files today's config and the process's own record name; one outside the repository cannot be read at a ref, so it is left out. */
25512
- const inRepoConfigPaths = (root, files, recorded) => {
25513
- const paths = new Set(Object.keys(recorded));
25514
- for (const file of files) paths.add(relative(root, resolve(root, file)).split(sep).join("/"));
25515
- return [...paths].filter((p) => p !== "" && !p.startsWith("..") && !isAbsolute(p)).sort();
25516
- };
25517
- const configRefusal = (command, tip, paths, reason = "A history ref replays under today's config, so it is unreadable after the config changes") => /* @__PURE__ */ new Error(`${command}: refused — config differs at ${tip.slice(0, 7)}: ${paths.join(", ")}. ${reason}`);
25518
- const differingConfigPaths = (git, workspace, root, config, run, hash) => gen(function* () {
25519
- const differing = [];
25520
- for (const path of inRepoConfigPaths(root, Object.keys(config.fingerprint), run.pinnedConfig?.files ?? {})) if ((yield* orElseSucceed(git.readFileAtRef(hash, path), () => void 0)) !== workspace.readSync(path)) differing.push(path);
25521
- return differing;
25343
+ const traceEntryOf = (hash, message) => ({
25344
+ state: message.parsed?.to ?? "",
25345
+ hash,
25346
+ actor: message.parsed?.actor ?? ""
25522
25347
  });
25523
- const notClosedError = (hash, outcome) => outcome.kind === "rest" ? /* @__PURE__ */ new Error(`gtd history: ${hash.slice(0, 7)} does not close a process under today's config`) : replayError(outcome) ?? /* @__PURE__ */ new Error("gtd history: replay failed");
25524
- const historySteps = (run) => {
25525
- const messages = new Map(run.episode.commits.map((c) => [c.hash, c.message]));
25526
- return run.trace.map((entry) => {
25527
- const message = parseCommitMessage(messages.get(entry.hash) ?? "");
25528
- return {
25529
- hash: entry.hash,
25530
- subject: (messages.get(entry.hash) ?? "").split("\n")[0] ?? "",
25531
- actor: entry.actor,
25532
- from: message.parsed?.from,
25533
- to: entry.state,
25534
- verdicts: message.judge,
25535
- ...optional("approval", approvalEntryOf(entry.hash, message)[0])
25536
- };
25537
- });
25348
+ const costEntriesOf = (hash, message) => message.cost.map((c) => ({
25349
+ cost: c.cost,
25350
+ model: c.model ?? "unspecified",
25351
+ hash
25352
+ }));
25353
+ const hashAt = (history, index) => history[index]?.hash;
25354
+ const historyFormatFault = (history, location) => formatFault(history.slice(location.baseIndex + 1).concat(history[location.baseIndex]?.message.startsWith("gtd") ? [history[location.baseIndex]] : []).map((c) => ({
25355
+ hash: c.hash,
25356
+ format: parseCommitMessage(c.message).format
25357
+ })));
25358
+ /** Fails on a history in a format this gtd does not read, before anything acts on it. */
25359
+ const runOf = (history, location, closingHash = void 0) => {
25360
+ const fault = historyFormatFault(history, location);
25361
+ return fault === void 0 ? succeed$1(buildRun(history, location, closingHash)) : fail$4(new Error(fault));
25538
25362
  };
25539
- const versionWarningFor = (recorded) => recorded === GTD_VERSION ? void 0 : `gtd: warning: this history was recorded by ${recorded === void 0 ? "an unknown gtd version" : `gtd ${recorded}`} and is replayed by gtd ${GTD_VERSION} — the engine the workflow runs on may have changed since`;
25540
- /**
25541
- * Replays the closed run at `tip` under today's config. The workflow is not
25542
- * fingerprinted for a version change: the engine under it moves with every
25543
- * upgrade, and the version warning is the only signal.
25544
- */
25545
- const historyTrail = (tip) => gen(function* () {
25546
- const git = yield* GitService;
25547
- const config = yield* (yield* ConfigService).load;
25548
- const hash = yield* git.resolveRef(tip);
25549
- const subject = yield* git.lastCommitSubject(hash);
25550
- const run = yield* runAtTip(git, config.workflow, {
25551
- hash,
25552
- subject
25553
- });
25554
- const { outcome, recorded, versionWarning } = yield* replayFinishedProcess(config, run, hash, {
25555
- command: "gtd history",
25556
- notEnded: (o) => notClosedError(hash, o)
25557
- });
25558
- return {
25559
- tip: hash,
25560
- steps: historySteps(run),
25561
- ended: outcome.via,
25562
- version: {
25563
- recorded,
25564
- running: GTD_VERSION
25565
- },
25566
- ...versionWarning !== void 0 ? { versionWarning } : {}
25567
- };
25568
- });
25569
- /** Refuses on config drift before replaying, so a stale config never runs the flow. */
25570
- const replayFinishedProcess = (config, run, hash, how) => gen(function* () {
25571
- const git = yield* GitService;
25572
- const workspace = yield* Workspace;
25573
- const host = yield* Host;
25574
- const recorded = parseCommitMessage(run.episode.commits.find((c) => c.hash === hash)?.message ?? "").version;
25575
- const versionWarning = versionWarningFor(recorded);
25576
- const withWarning = (err) => versionWarning === void 0 ? err : /* @__PURE__ */ new Error(`${err.message}\n${versionWarning}`);
25577
- const differing = yield* differingConfigPaths(git, workspace, host.root, config, run, hash);
25578
- if (differing.length > 0) return yield* fail$4(withWarning(configRefusal(how.command, hash, differing, how.configReason)));
25579
- const { setup } = yield* setupFor(config, run, workspace, host);
25580
- const outcome = yield* promise(() => replayFor(setup));
25581
- if (outcome.kind !== "ended") return yield* fail$4(withWarning(how.notEnded(outcome)));
25363
+ const approvalEntryOf = (hash, message) => message.approved === void 0 ? [] : [{
25364
+ step: formatStepId(message.approved.step),
25365
+ name: message.approved.name,
25366
+ email: message.approved.email,
25367
+ commit: hash
25368
+ }];
25369
+ const buildRun = (history, location, closingHash) => {
25370
+ const allProcessCommits = history.slice(location.processStart);
25371
+ const allParsed = allProcessCommits.map((c) => parseCommitMessage(c.message));
25372
+ const processCommits = allProcessCommits.filter((_, i) => allParsed[i].pin !== "add");
25373
+ const parsed = allParsed.filter((m) => m.pin !== "add");
25374
+ const first = parsed[0];
25375
+ const startParentHash = hashAt(history, location.processStart - 1) ?? EMPTY_TREE$1;
25376
+ const head = history[history.length - 1];
25582
25377
  return {
25583
- outcome,
25584
- recorded,
25585
- ...versionWarning !== void 0 ? { versionWarning } : {}
25378
+ workflow: location.workflow,
25379
+ legacyOpening: location.legacyOpening,
25380
+ startHash: hashAt(history, location.processStart) ?? head?.hash ?? EMPTY_TREE$1,
25381
+ startParentHash,
25382
+ diffBase: first?.reviewBase ?? startParentHash,
25383
+ trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i])),
25384
+ costEntries: parsed.flatMap((m, i) => costEntriesOf(processCommits[i].hash, m)),
25385
+ judgeVerdicts: parsed.flatMap((m) => m.judge),
25386
+ pinCommitted: allParsed.some((m) => m.pin === "add"),
25387
+ marks: parsed.flatMap((m) => m.marks),
25388
+ approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i])),
25389
+ pinnedVars: { ...first?.vars },
25390
+ headTurn: headTurnOf(head),
25391
+ closingHash,
25392
+ episode: {
25393
+ base: hashAt(history, location.baseIndex),
25394
+ commits: history.slice(location.baseIndex + 1)
25395
+ }
25586
25396
  };
25587
- });
25588
- //#endregion
25589
- //#region src/emit/GitScript.ts
25590
- const shellQuote = (value) => `'${value.replace(/'/g, "'\\''")}'`;
25591
- /**
25592
- * `--allow-empty` because gtd's workflow commits may be empty on purpose.
25593
- * Mirrors `Git.ts`'s retry: only re-tries without hooks on the specific
25594
- * "empty git commit" rejection message, discriminated from the captured
25595
- * output rather than the exit code, so a genuinely rejected commit (a lint
25596
- * error, a commit-msg hook) still fails.
25597
- */
25598
- const commitAllowEmpty = (message) => {
25599
- const m = shellQuote(message);
25600
- return [
25601
- `if ! out=$(git commit --allow-empty -m ${m} 2>&1); then`,
25602
- ` case "$out" in`,
25603
- ` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m} || exit 1 ;;`,
25604
- ` *) printf '%s\\n' "$out" >&2; exit 1 ;;`,
25605
- ` esac`,
25606
- `fi`
25607
- ].join("\n");
25608
25397
  };
25609
- const CHANGED_SINCE_LAND = "gtd: the working tree changed since `gtd land` — revert the edits and run `gtd land` again to approve";
25610
- /** `git add -A || exit 1` (an `&&` left side would not abort under `set -e`) then `commitAllowEmpty`. */
25611
- const commitAll = (message) => `git add -A || exit 1\n${commitAllowEmpty(message)}`;
25612
- /**
25613
- * An approval records exactly the judged tree: HEAD is pinned before the
25614
- * clean check and the commit is built over its tree with `commit-tree`, so no
25615
- * hook runs and none can add to it. A HEAD that moved fails the guarded
25616
- * `update-ref`. `commit-tree` ignores `commit.gpgSign`, so it is read here.
25617
- */
25618
- const commitClean = (message) => [
25619
- `gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
25620
- `if [ -n "$(git status --porcelain --untracked-files=all --ignore-submodules=none)" ]; then`,
25621
- ` printf '%s\\n' ${shellQuote(CHANGED_SINCE_LAND)} >&2`,
25622
- ` exit 1`,
25623
- `fi`,
25624
- `gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
25625
- `if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
25626
- `gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m ${shellQuote(message)}) || exit 1`,
25627
- `git update-ref -m ${shellQuote(`commit: ${message}`)} HEAD "$gtd_tip" "$gtd_head" || exit 1`
25628
- ].join("\n");
25629
- /** No current caller — see `Git.ts`'s `GitWriterOperations.commitAsIs` for why it's kept anyway. */
25630
- const commitAsIs = (message) => commitAllowEmpty(message);
25631
- /** No current caller — see `Git.ts`'s `GitWriterOperations.softResetTo` for why it's kept anyway. */
25632
- const softResetTo = (ref) => `git reset --soft ${shellQuote(ref)}`;
25633
- const mixedResetTo = (ref) => `git reset --mixed ${shellQuote(ref)}`;
25634
- const hardResetTo = (ref) => `git reset --hard ${shellQuote(ref)}`;
25635
- /**
25636
- * `git add -A` then `git reset --hard HEAD` — discards every pending change,
25637
- * tracked or untracked. Joined with `&&`: a failed stage must not reach the
25638
- * hard reset, or untracked survivors remain — the exact outcome this builder
25639
- * exists to avoid. No current caller — see `Git.ts`'s
25640
- * `GitWriterOperations.discardPending` for why it's kept anyway.
25641
- */
25642
- const discardPending = () => `git add -A && git reset --hard HEAD`;
25398
+ const PAGE_TIERS = [
25399
+ 32,
25400
+ 128,
25401
+ 512,
25402
+ Infinity
25403
+ ];
25643
25404
  /**
25644
- * POSIX sh (runs under dash). The marker is a `commit-tree` over HEAD's own
25645
- * tree, so no hook runs on it and its tree is the pre-turn tree by
25646
- * construction; `commit-tree` ignores `commit.gpgSign`, hence the explicit
25647
- * `-S`, read (and failing closed) before any object or ref is written; the
25648
- * index is already staged by then, so an early exit leaves the turn staged.
25649
- * The kept ref is deleted only after `reset -q` succeeds: it is then the
25650
- * turn's only handle.
25651
- * The branch moves before the final hard reset, so if that reset fails the
25652
- * marker has landed and the turn is still in the working tree (kept ref
25653
- * present); every other failure leaves the branch untouched.
25654
- */
25655
- const keepTurn = (refPrefix, message) => {
25656
- const keptRef = `${shellQuote(`${refPrefix}/`)}"$gtd_kept"`;
25657
- return [
25658
- `git add -A || exit 1`,
25659
- `gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
25660
- `if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
25661
- `gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
25662
- `gtd_tree=$(git write-tree) || exit 1`,
25663
- `gtd_kept=$(git commit-tree "$gtd_tree" -p "$gtd_head" -m ${shellQuote(message)}) || exit 1`,
25664
- `git update-ref ${keptRef} "$gtd_kept" || exit 1`,
25665
- `gtd_marker=$(printf '%s\\nGtd-Kept: %s' ${shellQuote(message)} "$gtd_kept")`,
25666
- `if ! gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m "$gtd_marker") || ! git update-ref -m ${shellQuote(`commit: ${message}`)} HEAD "$gtd_tip" "$gtd_head"; then`,
25667
- ` git reset -q && git update-ref -d ${keptRef}`,
25668
- ` exit 1`,
25669
- `fi`,
25670
- `git reset --hard HEAD`
25671
- ].join("\n");
25405
+ * `locateEpisode`'s own boundary test, subject-only: a commit that isn't a
25406
+ * gtd step commit, or one landing the flow's initial state, bounds an
25407
+ * episode from below — EXCEPT a bare `gtd(human): <x>` subject (no `→`),
25408
+ * which is NEVER a boundary here, whatever `<x>` is. That bare shape is
25409
+ * `locateEpisode`'s own `opening` branch (a started episode's first commit),
25410
+ * which takes priority there over the initial-state check no matter what the
25411
+ * workflow's first step is named — including one named after `def.initial`
25412
+ * itself, where the two branches would otherwise disagree. A bare human commit can
25413
+ * also be a self-loop step landing rather than a real opening (this
25414
+ * subject-only pass can't read the `Gtd-Step` trailer that would tell them
25415
+ * apart), so ruling it out here can only make `findBoundaryBase` walk a FEW
25416
+ * commits further back than strictly needed — never stop short of the
25417
+ * boundary `locateEpisode` would find once it has full commit bodies in
25418
+ * hand. That asymmetry is the only direction this approximation may err in;
25419
+ * it must never call a real boundary "not yet found".
25420
+ */
25421
+ const subjectIsBoundary = (def, subject) => {
25422
+ const parsed = parseCommitMessage(subject).parsed;
25423
+ if (parsed === void 0 || !ACTORS.has(parsed.actor)) return true;
25424
+ if (parsed.actor === "human" && parsed.from === void 0) return false;
25425
+ if (parsed.actor === "check" && parsed.from === PIN_STATES.add) return false;
25426
+ return parsed.to === def.initial;
25427
+ };
25428
+ /** The last page index (walking newest→oldest, same direction as `locateEpisode`) whose subject is a boundary, or `undefined` if none in `page`. */
25429
+ const boundaryIndexIn = (def, page) => {
25430
+ for (let i = page.length - 1; i >= 0; i--) if (subjectIsBoundary(def, page[i].subject)) return i;
25672
25431
  };
25673
- const updateRef = (ref, hash) => `git update-ref ${shellQuote(ref)} ${shellQuote(hash)}`;
25674
- /** `git update-ref -d <ref>` — idempotent: deleting a missing ref is already a no-op in real git. */
25675
- const deleteRef = (ref) => `git update-ref -d ${shellQuote(ref)}`;
25676
25432
  /**
25677
- * No `+` on the refspec: a local ref the remote's tip does not descend from
25678
- * fails the fetch instead of being overwritten (a fast-forward still applies).
25433
+ * `findBoundaryBase`'s result: `base` is the hash to pass `commitHistory` so
25434
+ * a `git.commitHistory(base, head)` call reads exactly the episode ending at
25435
+ * `head`, plus whatever a subject-only read couldn't rule out — never the
25436
+ * whole repository. `base` is deliberately the boundary commit's PARENT, not
25437
+ * the boundary commit itself: `commitHistory`'s range excludes `base`, and
25438
+ * `runOf` needs the boundary commit present in the read history
25439
+ * (`episode.base`, `startParentHash`). `base: undefined` means "read from the
25440
+ * repository root" — an empty repository, an unresolvable `head`, or an
25441
+ * episode that reaches the root commit, none of which cost anything extra
25442
+ * since that IS the whole history in those cases.
25443
+ *
25444
+ * `head` is the CONCRETE hash the literal `head` argument resolved to on this
25445
+ * call's first read — `undefined` only when it never resolved (an empty
25446
+ * repository, or an unresolvable ref). The caller must hand this hash, not
25447
+ * the original `head` argument, to its own `commitHistory` call: resolving a
25448
+ * symbolic ref (literal `HEAD`, most callers' default) a second time risks
25449
+ * reading a DIFFERENT commit than this function paged through, if something
25450
+ * moves HEAD in between — pinning both reads to one resolved hash is what
25451
+ * keeps the two-read split from disagreeing with itself.
25679
25452
  */
25680
- const fetchHistoryRefs = (remote) => [
25681
- `set -eu`,
25682
- `before=$(git for-each-ref --format='x' 'refs/gtd/history/' | wc -l)`,
25683
- `git fetch ${shellQuote(remote)} 'refs/gtd/history/*:refs/gtd/history/*'`,
25684
- `after=$(git for-each-ref --format='x' 'refs/gtd/history/' | wc -l)`,
25685
- `printf '%s history refs arrived\\n' "$((after - before))"`
25686
- ].join("\n");
25687
- /** Marks an emitted block as print-only — mirrors `OutcomeScript.ts`'s `OUTCOME_MARKER` verbatim, so `src/twins/EmittedScriptRecognizer.ts` recognizes either path's output identically. */
25688
- const OUTCOME_MARKER$1 = "# gtd: outcome (print-only)";
25689
- const printfLine$1 = (fmt, args) => `${OUTCOME_MARKER$1}\nprintf ${shellQuote(fmt.replace(/\n/g, "\\n"))} ${args.join(" ")}`;
25690
- const renderOutcome = (outcome) => {
25691
- switch (outcome.kind) {
25692
- case "transition": return printfLine$1("%s %s → %s\n", [
25693
- "'->'",
25694
- shellQuote(outcome.from),
25695
- shellQuote(outcome.to)
25696
- ]);
25697
- case "commit": return printfLine$1("%s %s\n", ["'[commit]'", shellQuote(outcome.subject)]);
25698
- case "note": return printfLine$1("%s\n", [shellQuote(outcome.text)]);
25453
+ const findBoundaryBase = (git, def, head) => gen(function* () {
25454
+ let pinnedHead = head;
25455
+ for (const pageSize of PAGE_TIERS) {
25456
+ const page = yield* git.subjectHistory(pageSize, 0, pinnedHead);
25457
+ if (pinnedHead === void 0) pinnedHead = page[page.length - 1]?.hash;
25458
+ const wholeHistory = !Number.isFinite(pageSize) || page.length < pageSize;
25459
+ const boundaryIdx = boundaryIndexIn(def, page);
25460
+ if (boundaryIdx === void 0 || boundaryIdx === 0) {
25461
+ if (wholeHistory) return {
25462
+ base: void 0,
25463
+ head: pinnedHead
25464
+ };
25465
+ continue;
25466
+ }
25467
+ return {
25468
+ base: page[boundaryIdx - 1].hash,
25469
+ head: pinnedHead
25470
+ };
25699
25471
  }
25700
- };
25701
- const renderGitWrite = (write) => {
25702
- switch (write.kind) {
25703
- case "commitAll": return commitAll(write.message);
25704
- case "commitClean": return commitClean(write.message);
25705
- case "updateRef": return updateRef(write.ref, write.target);
25706
- case "keepTurn": return keepTurn(write.ref, write.message);
25472
+ return {
25473
+ base: void 0,
25474
+ head: pinnedHead
25475
+ };
25476
+ });
25477
+ const historyUpTo = (git, def, head) => flatMap(findBoundaryBase(git, def, head), ({ base, head: resolvedHead }) => git.commitHistory(base, resolvedHead));
25478
+ const computeProcessRun = (git, def, head) => flatMap(historyUpTo(git, def, head), (history) => runOf(history, locateEpisode(def, history)));
25479
+ /** The run alone, never replaying — `gtd abandon` must work even when replay would refuse, short of a history it cannot read. */
25480
+ const currentRun = gen(function* () {
25481
+ const git = yield* GitService;
25482
+ const config = yield* (yield* ConfigService).load;
25483
+ return yield* computeProcessRun(git, config.workflow);
25484
+ });
25485
+ const runAtTip = (git, def, tip) => gen(function* () {
25486
+ if (tip === void 0) {
25487
+ const history = yield* historyUpTo(git, def, void 0);
25488
+ return yield* runOf(history, locateEpisode(def, history));
25489
+ }
25490
+ if (!(parseCommitMessage(tip.subject).parsed?.to === def.initial)) {
25491
+ const history = yield* historyUpTo(git, def, tip.hash);
25492
+ return yield* runOf(history, locateEpisode(def, history));
25707
25493
  }
25494
+ const parentRef = `${tip.hash}~1`;
25495
+ const before = yield* historyUpTo(git, def, parentRef);
25496
+ const location = locateEpisode(def, before);
25497
+ let closing = yield* git.commitHistory(parentRef, tip.hash);
25498
+ if (closing.length === 0) closing = yield* git.commitHistory(void 0, tip.hash);
25499
+ return yield* runOf([...before, ...closing], { ...location }, tip.hash);
25500
+ });
25501
+ const runAtHead = (git, def) => gen(function* () {
25502
+ return yield* runAtTip(git, def, (yield* git.subjectHistory(1, 0))[0]);
25503
+ });
25504
+ /** The process HEAD closes or sits inside — `gtd summary`'s run. */
25505
+ const summaryRun = gen(function* () {
25506
+ const git = yield* GitService;
25507
+ const def = (yield* (yield* ConfigService).load).workflow;
25508
+ return yield* runAtHead(git, def);
25509
+ });
25510
+ const commitTree = (workspace, hash) => {
25511
+ let entries;
25512
+ const list = () => entries ??= workspace.treeSync(hash);
25513
+ let sortedPaths;
25514
+ const contents = /* @__PURE__ */ new Map();
25515
+ return {
25516
+ paths: () => sortedPaths ??= [...list().keys()].sort(),
25517
+ read: (path) => {
25518
+ if (!list().has(path)) return void 0;
25519
+ if (!contents.has(path)) contents.set(path, workspace.readCommittedSync(path, hash));
25520
+ return contents.get(path);
25521
+ },
25522
+ id: (path) => list().get(path)
25523
+ };
25708
25524
  };
25709
25525
  /**
25710
- * Wraps `command` so a non-zero exit prints `prompt` plus the command's
25711
- * captured output before propagating that exit code — mirrors `Emit.ts`'s
25712
- * `failurePromptWrapper` (same reverse-import concern as `renderOutcome`
25713
- * above: `Emit.ts` imports `shellQuote` from here already).
25526
+ * The tree a landing would commit: the working tree, with any rewrite the
25527
+ * landing script applies first. `entries` is `setup.pendingWorktree()` —
25528
+ * already memoized — never a fresh `workspace.worktreeSync()` call; see that
25529
+ * field's own comment for why the memo lives there and not on `Workspace`.
25714
25530
  */
25715
- const failurePromptWrapper$1 = (command, prompt) => {
25716
- return [
25717
- `gtd_validate_status=0`,
25718
- `gtd_validate_out="$( {`,
25719
- command,
25720
- `} 2>&1 )" || gtd_validate_status=$?`,
25721
- `if [ "$gtd_validate_status" -ne 0 ]; then`,
25722
- ` printf '%s\\n\\n%s\\n' ${shellQuote(prompt)} "$gtd_validate_out"`,
25723
- ` exit "$gtd_validate_status"`,
25724
- `fi`
25725
- ].join("\n");
25726
- };
25727
- const renderLandStep = (step) => {
25728
- if (step.kind === "gitWrite") return renderGitWrite(step.write);
25729
- if (step.kind === "outcome") return renderOutcome(step.outcome);
25730
- if (step.kind === "uncheck") return `gtd uncheck ${shellQuote(step.file)}`;
25731
- return step.onFailure !== void 0 ? failurePromptWrapper$1(step.command, step.onFailure) : step.command;
25531
+ const pendingTree = (workspace, entries, rewrite) => {
25532
+ const paths = [...entries.keys()];
25533
+ const readWorktree = (path) => {
25534
+ try {
25535
+ return workspace.readSync(path);
25536
+ } catch {
25537
+ return;
25538
+ }
25539
+ };
25540
+ return {
25541
+ paths: () => paths,
25542
+ read: (path) => {
25543
+ if (!entries.has(path)) return void 0;
25544
+ const content = readWorktree(path);
25545
+ return content === void 0 || rewrite?.path !== path ? content : rewrite.apply(content);
25546
+ },
25547
+ id: (path) => rewrite?.path === path ? void 0 : entries.get(path)
25548
+ };
25732
25549
  };
25733
- const ScriptSurface = { render: (steps) => {
25734
- const rendered = steps.map(renderLandStep);
25735
- return rendered.length === 0 ? "" : ["set -eu", ...rendered].join("\n\n");
25736
- } };
25737
- //#endregion
25738
- //#region src/emit/Emit.ts
25739
- /** Exits 0 when the declared steering file is absent — nothing to format or validate yet. An OR list, not `if`: its left side failing never trips `set -e`. */
25740
- const fileExistsGuard = (file) => `[ -f ${shellQuote(file)} ] || exit 0`;
25741
- /** A mode or `ui.format` command finds the file it works on in `$GTD_FILE`. */
25742
- const withFileVar = (command, file) => `export GTD_FILE=${shellQuote(file)}\n${command}`;
25743
- const SIMPLE_LEADING_WORD_RE = /^[A-Za-z0-9_./-]+(?=[ \t]|$)/;
25744
- const SHELL_METACHARACTER_RE = /[|&;$<>(){}`]/;
25745
- const FILE_VAR_RE = /"\$GTD_FILE"|"\$\{GTD_FILE\}"|\$GTD_FILE\b|\$\{GTD_FILE\}/g;
25746
- /** `command` with `file` written in for `$GTD_FILE` — one line an agent can copy, equivalent to running it under `withFileVar`. */
25747
- const commandForFile = (command, file) => command.replace(FILE_VAR_RE, shellQuote(file));
25748
25550
  /**
25749
- * The leading word of a rendered `format:`/`validate:` command, when the
25750
- * command has exactly one unambiguous binary to probe — `undefined` for
25751
- * anything a shell metacharacter could hide a second command behind. A missing
25752
- * guard degrades to a raw exit; a wrong guard would refuse a working command.
25551
+ * The one resolver of both setting kinds. Process settings: the recorded
25552
+ * (pinned) value wins per name, anything unrecorded resolves live — the
25553
+ * fallback for a process started before pinning existed. Environment settings
25554
+ * are always live. `startOverrides` are `--var` values, passed only while a
25555
+ * process is being started.
25753
25556
  */
25754
- const extractLeadingBinary = (command) => {
25755
- if (command.includes("\n")) return void 0;
25756
- if (SHELL_METACHARACTER_RE.test(command.replace(FILE_VAR_RE, ""))) return void 0;
25757
- const match = SIMPLE_LEADING_WORD_RE.exec(command);
25758
- return match === null ? void 0 : match[0];
25557
+ const settingsFor = (workflow, pinnedVars, hostEnv, startOverrides = {}, startFlags) => ({
25558
+ vars: {
25559
+ ...resolveStartVars(workflow.vars, startFlags, startOverrides, hostEnv),
25560
+ ...pinnedVars
25561
+ },
25562
+ env: resolveVars(workflow.env, {}, hostEnv)
25563
+ });
25564
+ /** The workflow a run replays on: its pinned one, else the default. */
25565
+ const workflowOf = (config, run) => {
25566
+ if (run.legacyOpening) return fail$4(/* @__PURE__ */ new Error("gtd: this process was opened by `gtd --entry`, which no longer exists — run `gtd abandon` to start over"));
25567
+ if (run.workflow === void 0) return succeed$1(config.defaultWorkflow);
25568
+ const named = config.workflowNamed(run.workflow);
25569
+ if (named !== void 0) return succeed$1(named);
25570
+ return fail$4(/* @__PURE__ */ new Error(`gtd: this process runs workflow "${run.workflow}", which gtd.config.ts no longer defines — run \`gtd abandon\` to start over`));
25759
25571
  };
25760
25572
  /**
25761
- * Names a typo'd or uninstalled binary and the `$PATH` it was looked up on.
25762
- * `$PATH` is a shell word the DRIVER's shell expands at run time, so gtd never
25763
- * resolves it in-process. Exits 127, matching bash's "command not found".
25573
+ * `judgeBudgetBytes` is the one var that refuses rather than disabling its
25574
+ * mechanism when blanked: without a bound the judge payload is rejected
25575
+ * anyway. Absent falls back to a conservative default.
25764
25576
  */
25765
- const binaryGuard = (binary, mode, key) => {
25766
- const messageQ = shellQuote(`gtd: mode "${mode}": "${key}" command not found: ${binary}`);
25767
- return [
25768
- `command -v ${shellQuote(binary)} >/dev/null 2>&1 || {`,
25769
- ` printf '%s\\n%s\\n' ${messageQ} '$PATH: '"$PATH"`,
25770
- ` exit 127`,
25771
- `}`
25772
- ].join("\n");
25577
+ const DEFAULT_JUDGE_BUDGET_BYTES = 32768;
25578
+ const judgeBudgetBytes = (vars) => {
25579
+ const raw = vars.judgeBudgetBytes;
25580
+ if (raw === void 0) return DEFAULT_JUDGE_BUDGET_BYTES;
25581
+ const n = Number(raw);
25582
+ if (raw.trim() === "" || !Number.isInteger(n) || n <= 0) throw new Error(`"judgeBudgetBytes" must be a positive integer — got ${JSON.stringify(raw)} (blanking this var disables the payload bound rather than the mechanism it guards, so it is refused rather than defaulted)`);
25583
+ return n;
25584
+ };
25585
+ const replayFor = (setup, pending) => replay({
25586
+ flow: setup.def.flow,
25587
+ episode: {
25588
+ base: setup.base,
25589
+ commits: setup.episode
25590
+ },
25591
+ vars: setup.vars,
25592
+ env: setup.env,
25593
+ start: setup.run.diffBase,
25594
+ startTree: setup.run.diffBase === EMPTY_TREE$1 ? treeFromRecord({}) : commitTree(setup.workspace, setup.run.diffBase),
25595
+ budgetBytes: setup.budget,
25596
+ skills: setup.skills,
25597
+ configuredSkills: setup.def.configuredSkills,
25598
+ access: setup.access,
25599
+ configuredAccess: setup.def.configuredAccess,
25600
+ ...pending !== void 0 ? { pending } : {}
25601
+ });
25602
+ const replayError = (outcome) => {
25603
+ if (outcome.kind === "divergence" || outcome.kind === "failed") return new Error(outcome.message);
25604
+ if (outcome.kind === "refused") return /* @__PURE__ */ new Error(`gtd: ${outcome.message}`);
25605
+ if (outcome.kind === "ended") return /* @__PURE__ */ new Error("gtd: history ends the episode, but HEAD does not enter the default workflow's first step — the workflow changed under this process; run `gtd abandon` to start over");
25773
25606
  };
25607
+ const judgeDocument = (step) => step.request.kind === "judge" ? judgeJson(step.request.evidence, step.request.questions) : void 0;
25774
25608
  /**
25775
- * Prints `prompt` plus the captured output on a non-zero exit, then propagates
25776
- * it. The `{ … }` group inlines a multi-line `command` verbatim; the
25777
- * assignment sits left of `||` so `set -e` never trips on the failure itself.
25609
+ * The fixed sentence appended to a judge gate's message when a bounded read
25610
+ * dropped bytes to fit `judgeBudgetBytes` — a constant, so no workflow can
25611
+ * reword or drop it.
25778
25612
  */
25779
- const failurePromptWrapper = (command, prompt) => {
25780
- return [
25781
- `gtd_validate_status=0`,
25782
- `gtd_validate_out="$( {`,
25783
- command,
25784
- `} 2>&1 )" || gtd_validate_status=$?`,
25785
- `if [ "$gtd_validate_status" -ne 0 ]; then`,
25786
- ` printf '%s\\n\\n%s\\n' ${shellQuote(prompt)} "$gtd_validate_out"`,
25787
- ` exit "$gtd_validate_status"`,
25788
- `fi`
25789
- ].join("\n");
25613
+ const TRUNCATION_NOTICE = "Note: some evidence above was truncated to fit the judge's payload budget.";
25614
+ const DEFAULT_JUDGE_MESSAGE = "A judgment is pending. Run `gtd judge answer` and pipe a verdict, or land to take the conservative default with no verdict recorded.";
25615
+ const optional = (key, value) => value === void 0 ? {} : { [key]: value };
25616
+ const commonOf = (step) => {
25617
+ const options = step.request.options;
25618
+ return {
25619
+ actor: step.actor,
25620
+ ...optional("label", options.label),
25621
+ ...optional("file", options.file),
25622
+ ...optional("mode", options.mode)
25623
+ };
25790
25624
  };
25791
- const renderStep = (step) => {
25792
- if (step.kind === "command" && step.onFailure !== void 0) return failurePromptWrapper(step.command, step.onFailure);
25793
- return step.command;
25625
+ const promptDef = (common, request, skills, access) => ({
25626
+ ...common,
25627
+ kind: "prompt",
25628
+ content: request.prompt,
25629
+ ...optional("model", request.options.model),
25630
+ ...optional("system", request.options.system),
25631
+ ...optional("allowEmpty", request.options.allowEmpty),
25632
+ ...optional("keep", request.options.keep),
25633
+ ...optional("skills", skills),
25634
+ ...optional("access", access)
25635
+ });
25636
+ const scriptDef = (common, request) => ({
25637
+ ...common,
25638
+ kind: "script",
25639
+ content: request.body
25640
+ });
25641
+ const judgeDef = (common, request, step) => {
25642
+ const message = request.options.message ?? DEFAULT_JUDGE_MESSAGE;
25643
+ return {
25644
+ ...common,
25645
+ kind: "message",
25646
+ content: step.truncated ? `${message}\n\n${TRUNCATION_NOTICE}` : message,
25647
+ ...optional("judge", judgeDocument(step))
25648
+ };
25794
25649
  };
25795
- const assembleScript = (steps) => steps.length === 0 ? "" : ["set -eu", ...steps.map(renderStep)].join("\n\n");
25796
- /** Build both halves from already-rendered command strings; an empty (or omitted) array produces the empty-string result, so a driver checking `if [ -n "$required" ]` never has to special-case a bare preamble. */
25797
- const emitScripts = (required = [], optional = []) => ({
25798
- required: assembleScript(required),
25799
- optional: assembleScript(optional)
25650
+ const messageDef = (common, request, step) => ({
25651
+ ...common,
25652
+ kind: "message",
25653
+ content: request.options.message ?? request.options.label ?? step.name,
25654
+ ...optional("acceptClean", request.options.approve === true || request.options.acceptClean),
25655
+ ...optional("approve", request.options.approve)
25800
25656
  });
25801
- /** Exported so `src/twins/EmittedScriptRecognizer.ts` can recognize these by exact string comparison. `printf`, not `echo`, avoids shell-specific backslash-escape quirks. */
25802
- const DID_NOT_RUN_COMMENT = "# gtd emitted this and did NOT run it — run it with `sh` to apply it";
25803
- const PRESENTATION_ONLY_COMMENT = "# presentation only — safe to skip";
25804
- const PRESENTATION_FAILURE_WARNING = "printf 'gtd: presentation-only follow-up failed — continuing\\n' >&2";
25805
- /**
25806
- * A write command's single pasteable script (`gtd abandon | sh`,
25807
- * `gtd land --json=script`'s own field). `optional`, when non-empty, is
25808
- * wrapped in a subshell whose failure is swallowed — presentation-only, so it
25809
- * must never turn a landed turn into a non-zero exit.
25810
- */
25811
- const combinedScript = (required, optional) => {
25812
- if (required.length === 0) return "";
25813
- if (optional.length === 0) return `${DID_NOT_RUN_COMMENT}\n\n${required}`;
25814
- return [
25815
- DID_NOT_RUN_COMMENT,
25816
- "",
25817
- required,
25818
- "",
25819
- PRESENTATION_ONLY_COMMENT,
25820
- `(\n${optional}\n) || ${PRESENTATION_FAILURE_WARNING}`
25821
- ].join("\n");
25657
+ const stepDefOf = (step) => {
25658
+ const request = step.request;
25659
+ const common = commonOf(step);
25660
+ switch (request.kind) {
25661
+ case "agent": return promptDef(common, request, step.skills, step.access);
25662
+ case "run": return scriptDef(common, request);
25663
+ case "judge": return judgeDef(common, request, step);
25664
+ case "human": return messageDef(common, request, step);
25665
+ }
25822
25666
  };
25823
- //#endregion
25824
- //#region src/emit/OutcomeScript.ts
25825
- /**
25826
- * A `printf '<fmt>' <args...>` bash statement — `args` are already-valid bash
25827
- * tokens (a `shellQuote`d literal, a `"$(git …)"` substitution), never
25828
- * re-quoted here: a value reaches the script as a printf ARGUMENT, never
25829
- * interpolated into the format itself, so a subject or state name containing
25830
- * `%` can never be read as a conversion spec. Markers like `->` are arguments
25831
- * too, keeping every format string free of a leading `-` that a shell's
25832
- * `printf` could mistake for an option. A real newline in `fmt` is emitted as
25833
- * the two-character `\n` escape `printf` itself expands, keeping every emitted
25834
- * statement on one line.
25835
- */
25836
- const printfLine = (fmt, args) => `${OUTCOME_MARKER}\nprintf ${shellQuote(fmt.replace(/\n/g, "\\n"))} ${args.join(" ")}`;
25667
+ const ROOT_MEMORY_SCOPE_NAME = "root";
25837
25668
  /**
25838
- * Marks an emitted block as print-only. Load-bearing beyond documentation:
25839
- * `src/twins/EmittedScriptRecognizer.ts` recognizes an outcome block by
25840
- * this line, and a bare `printf` would be ambiguous with a workflow
25841
- * `script:` command that happens to start the same way.
25669
+ * An agent rest's memory key, `<scope>#<hash7>`: the hash is the commit the
25670
+ * current unbroken run into the scope started FROM, so the same run always
25671
+ * re-derives the same key. `resumed` is whether an agent turn in that run
25672
+ * already landed.
25842
25673
  */
25843
- const OUTCOME_MARKER = "# gtd: outcome (print-only)";
25844
- const FMT_ABANDON_NOOP = "no gtd process is underway (resting at \"%s\") — nothing to abandon\n";
25845
- const FMT_ABANDONED = "abandoned the process resting at \"%s\" — HEAD is back at %s (\"%s\"), resting at \"%s\".\nEverything the process produced is kept as uncommitted changes (`git status`); discard them with `git checkout -- . && git clean -fd .gtd` for a clean tree.\n";
25846
- const FMT_RESTORED = "restored the retained history — HEAD is back at %s (\"%s\"), resting at \"%s\". Resume with the loop, or `git reset` to any earlier turn to restart from there.\n";
25847
- /** `no gtd process is underway (resting at "<initial>") — nothing to abandon` — `gtd abandon`'s no-op plain-text line. */
25848
- const abandonNoopText = (initial) => renderFormat(FMT_ABANDON_NOOP, initial);
25849
- /** One already-rendered plain line. */
25850
- const noteOutcome = (text) => printfLine("%s\n", [shellQuote(text)]);
25674
+ const memoryOf = (trace, run) => {
25675
+ const rest = trace[trace.length - 1];
25676
+ if (rest === void 0 || rest.kind !== "agent") return {
25677
+ key: void 0,
25678
+ resumed: false
25679
+ };
25680
+ const start = scopeRunStart(trace, rest.memoryScope);
25681
+ const token = start <= 0 ? run.startParentHash : trace[start - 1].enteredAt;
25682
+ const resumed = trace.slice(Math.max(start, 0), trace.length - 1).some((s) => s.kind === "agent" && s.memoryScope === rest.memoryScope);
25683
+ return {
25684
+ key: `${rest.memoryScope || ROOT_MEMORY_SCOPE_NAME}#${token.slice(0, 7)}`,
25685
+ resumed
25686
+ };
25687
+ };
25688
+ const hintsOf = (def) => ({
25689
+ ...optional("model", def.model),
25690
+ ...optional("label", def.label),
25691
+ ...optional("file", def.file),
25692
+ ...optional("judge", def.judge),
25693
+ ...optional("system", def.system),
25694
+ ...optional("mode", def.mode),
25695
+ ...optional("skills", def.skills),
25696
+ ...optional("access", def.access)
25697
+ });
25698
+ const normalizeStatus = (raw) => raw === "A" ? "A" : raw === "D" ? "D" : "M";
25699
+ /** Per-model token totals, highest-cost first (ties broken by model name). */
25700
+ const costByModel = (entries) => {
25701
+ const byModel = /* @__PURE__ */ new Map();
25702
+ for (const entry of entries) byModel.set(entry.model, (byModel.get(entry.model) ?? 0) + entry.cost);
25703
+ return [...byModel.entries()].map(([model, cost]) => ({
25704
+ model,
25705
+ cost
25706
+ })).sort((a, b) => b.cost - a.cost || a.model.localeCompare(b.model));
25707
+ };
25708
+ const costTotals = (entries) => ({
25709
+ total: entries.reduce((sum, entry) => sum + entry.cost, 0),
25710
+ byModel: costByModel(entries)
25711
+ });
25712
+ const setupFor = (config, run, workspace, host, startOverrides = {}) => gen(function* () {
25713
+ const resolved = yield* workflowOf(config, run);
25714
+ const def = resolved.def;
25715
+ const startFlags = run.trace.length === 0 ? yield* StartFlags : {};
25716
+ const { vars, env } = settingsFor(resolved, run.pinnedVars, host.env, startOverrides, startFlags);
25717
+ const skills = yield* resolveScopeSkills(def, vars);
25718
+ const access = yield* resolveScopeAccess(def, skills, vars);
25719
+ const budget = yield* try_({
25720
+ try: () => judgeBudgetBytes(vars),
25721
+ catch: (e) => e instanceof Error ? e : new Error(String(e))
25722
+ });
25723
+ yield* workspace.episodeTrees(run.episode.base, run.episode.commits.map((c) => c.hash));
25724
+ const base = run.episode.base === void 0 ? {
25725
+ hash: EMPTY_TREE$1,
25726
+ tree: treeFromRecord({})
25727
+ } : {
25728
+ hash: run.episode.base,
25729
+ tree: commitTree(workspace, run.episode.base)
25730
+ };
25731
+ const git = yield* GitService;
25732
+ const episode = [];
25733
+ for (const c of run.episode.commits) {
25734
+ const tip = parseCommitMessage(c.message).kept;
25735
+ const exists = tip !== void 0 && isSome(yield* git.readRefOption(`${tip}^{commit}`));
25736
+ episode.push({
25737
+ hash: c.hash,
25738
+ message: c.message,
25739
+ tree: commitTree(workspace, c.hash),
25740
+ ...exists ? { kept: {
25741
+ hash: tip,
25742
+ tree: commitTree(workspace, tip)
25743
+ } } : {}
25744
+ });
25745
+ }
25746
+ let pendingMemo;
25747
+ return {
25748
+ resolved,
25749
+ def,
25750
+ vars,
25751
+ env,
25752
+ setup: {
25753
+ def,
25754
+ run,
25755
+ vars,
25756
+ env,
25757
+ budget,
25758
+ skills,
25759
+ access,
25760
+ workspace,
25761
+ base,
25762
+ episode,
25763
+ pendingWorktree: () => pendingMemo ??= workspace.worktreeSync()
25764
+ }
25765
+ };
25766
+ });
25767
+ const pendingChanges = (git, withIndex) => gen(function* () {
25768
+ const worktree = yield* git.changedPaths();
25769
+ if (!withIndex) return worktree;
25770
+ const seen = new Set(worktree.map((e) => e.path));
25771
+ return [...worktree, ...(yield* git.stagedPaths()).filter((e) => !seen.has(e.path))];
25772
+ });
25773
+ /** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
25774
+ const restAt = (ref, startOverrides = {}) => gen(function* () {
25775
+ const git = yield* GitService;
25776
+ const config = yield* (yield* ConfigService).load;
25777
+ const workspace = yield* Workspace;
25778
+ const host = yield* Host;
25779
+ const run = yield* computeProcessRun(git, config.workflow, ref);
25780
+ const { resolved, def, vars, env, setup } = yield* setupFor(config, run, workspace, host, startOverrides);
25781
+ const outcome = yield* promise(() => replayFor(setup));
25782
+ const pinned = headHasPin(workspace, run);
25783
+ const pin = pinBeatAt(outcome, run, def, pinned);
25784
+ const resting = restingOn(outcome, pin);
25785
+ if (resting === void 0) return yield* fail$4(replayError(outcome) ?? /* @__PURE__ */ new Error("gtd: replay found no rest"));
25786
+ const { reached, step } = resting;
25787
+ const { stepDef, state, memory } = restFacts(pin, step, reached.trace, run);
25788
+ yield* (yield* Narrator).narrate(`rest resolved: ${state} (awaits ${stepDef.actor})`);
25789
+ if (stepDef.mode !== void 0 && def.modes[stepDef.mode] === void 0) return yield* fail$4(/* @__PURE__ */ new Error(`gtd: config: step "${step.name}": mode "${stepDef.mode}" is not a mode this workflow knows (${knownModes(def).join(", ")})`));
25790
+ const changes = ref === void 0 ? (yield* pendingChanges(git, stepDef.approve === true)).map((e) => ({
25791
+ status: normalizeStatus(e.status),
25792
+ path: e.path
25793
+ })) : [];
25794
+ 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`);
25795
+ return {
25796
+ def,
25797
+ workflowName: resolved.name,
25798
+ state,
25799
+ stepDef,
25800
+ actor: stepDef.actor,
25801
+ pin,
25802
+ pinned,
25803
+ step,
25804
+ trace: reached.trace,
25805
+ run,
25806
+ vars,
25807
+ env,
25808
+ changes,
25809
+ memory: memory.key,
25810
+ memoryResumed: memory.resumed,
25811
+ hints: hintsOf(stepDef),
25812
+ cost: costTotals(run.costEntries),
25813
+ setup
25814
+ };
25815
+ });
25816
+ const currentRest = restAt(void 0);
25817
+ /** The step the process rests at and the trace that reached it — at an unpin, the flow's last step. */
25818
+ const restingOn = (outcome, pin) => {
25819
+ if (outcome.kind === "rest") return {
25820
+ reached: outcome,
25821
+ step: outcome.rest
25822
+ };
25823
+ if (outcome.kind !== "ended" || pin !== "remove") return void 0;
25824
+ const step = outcome.trace.at(-1);
25825
+ return step === void 0 ? void 0 : {
25826
+ reached: outcome,
25827
+ step
25828
+ };
25829
+ };
25830
+ /** A pin or unpin beat has its own script, no step's, and no agent memory. */
25831
+ const restFacts = (pin, step, trace, run) => pin === void 0 ? {
25832
+ stepDef: stepDefOf(step),
25833
+ state: step.name,
25834
+ memory: memoryOf(trace, run)
25835
+ } : {
25836
+ stepDef: pinStepDef(pin),
25837
+ state: PIN_STATES[pin],
25838
+ memory: {
25839
+ key: void 0,
25840
+ resumed: false
25841
+ }
25842
+ };
25843
+ const headHasPin = (workspace, run) => {
25844
+ const head = run.episode.commits.at(-1)?.hash ?? run.episode.base;
25845
+ return head !== void 0 && workspace.treeSync(head).has(".gtd/process.gtdpin");
25846
+ };
25847
+ const underwayAt = (run, step, def) => run.workflow !== void 0 || step.name !== def.initial || step.id.occurrence !== 1;
25851
25848
  /**
25852
- * Resolves the post-hoc short hash/subject from `head` (a commitish) in-script
25853
- * — this line runs AFTER the reset that made `head` the new HEAD, so neither
25854
- * value is knowable when gtd generates it.
25849
+ * A started process pins first — again, should a turn have deleted the pin;
25850
+ * a pinned flow that ended unpins before the closing commit.
25855
25851
  */
25856
- const abandonedOutcome = (from, head, state) => printfLine(FMT_ABANDONED, [
25857
- shellQuote(from),
25858
- `"$(git rev-parse --short ${shellQuote(head)})"`,
25859
- `"$(git log -1 --format=%s ${shellQuote(head)})"`,
25860
- shellQuote(state)
25861
- ]);
25862
- /** `gtd abandon`'s no-op outcome — the same wording `abandonNoopText` renders. */
25863
- const abandonNoopOutcome = (initial) => noteOutcome(abandonNoopText(initial));
25864
- /** Resolves the post-hoc short hash/subject from `to` (a commitish) in-script, for the same reason `abandonedOutcome` does. */
25865
- const restoredOutcome = (to, state) => printfLine(FMT_RESTORED, [
25866
- `"$(git rev-parse --short ${shellQuote(to)})"`,
25867
- `"$(git log -1 --format=%s ${shellQuote(to)})"`,
25868
- shellQuote(state)
25869
- ]);
25870
- //#endregion
25871
- //#region src/emit/ModeContradiction.ts
25852
+ const pinBeatAt = (outcome, run, def, pinned) => {
25853
+ if (flowEnded(outcome, run, def)) return pinned || run.pinCommitted ? "remove" : void 0;
25854
+ if (outcome.kind !== "rest" || pinned) return void 0;
25855
+ return underwayAt(run, outcome.rest, def) ? "add" : void 0;
25856
+ };
25857
+ const flowEnded = (outcome, run, def) => outcome.kind === "ended" || outcome.kind === "rest" && run.workflow === void 0 && outcome.rest.name === def.initial && underwayAt(run, outcome.rest, def);
25858
+ const pinStepDef = (pin) => ({
25859
+ actor: "check",
25860
+ kind: "script",
25861
+ content: pin === "add" ? pinScript(PIN_PATH) : unpinScript(PIN_PATH)
25862
+ });
25872
25863
  /**
25873
- * Written for two readers at once, since gtd validate's exit code carries no
25874
- * second meaning to distinguish them: the agent (this is a CONFIGURATION BUG,
25875
- * not the steering file — stop, don't edit it, or a fix-retry loop will
25876
- * thrash on the document) and the human (which mode, and the exact rendered
25877
- * `format:` command, to fix `gtd.config.ts` without reproducing the failure).
25864
+ * Whether `gtd start <name>` can start a process here: the workflow must
25865
+ * reach a step from the tree the opening commit would capture. That step is
25866
+ * what the opening commit's subject names.
25878
25867
  */
25879
- const contradictionMessage = (mode, formatCommand) => `gtd: mode "${mode}"'s format: command breaks its own validator — this is a CONFIGURATION BUG, not a problem with the steering file. Do NOT edit the steering file — stop and end your turn now.\nThe rendered format: command that ran:\n${formatCommand}\nWhat it turned mode "${mode}"'s own canonical sample into:`;
25868
+ const startRefusal = (rest, resolved, startVars) => gen(function* () {
25869
+ const host = yield* Host;
25870
+ const { vars, env } = settingsFor(resolved, {}, host.env, startVars, yield* StartFlags);
25871
+ const skills = yield* resolveScopeSkills(resolved.def, vars);
25872
+ const access = yield* resolveScopeAccess(resolved.def, skills, vars);
25873
+ const workspace = rest.setup.workspace;
25874
+ const outcome = yield* promise(() => replay({
25875
+ flow: resolved.def.flow,
25876
+ episode: {
25877
+ base: {
25878
+ hash: "",
25879
+ tree: pendingTree(workspace, rest.setup.pendingWorktree(), void 0)
25880
+ },
25881
+ commits: []
25882
+ },
25883
+ vars,
25884
+ env,
25885
+ start: "",
25886
+ budgetBytes: rest.setup.budget,
25887
+ skills,
25888
+ configuredSkills: resolved.def.configuredSkills,
25889
+ access,
25890
+ configuredAccess: resolved.def.configuredAccess
25891
+ }));
25892
+ if (outcome.kind === "rest") return { firstStep: outcome.rest.name };
25893
+ if (outcome.kind === "refused") return { refusal: outcome.message };
25894
+ const why = outcome.kind === "ended" ? "the flow reaches no step" : outcome.message;
25895
+ return { refusal: `workflow "${resolved.name}" cannot start — ${why}` };
25896
+ });
25897
+ /** The review window's diff base at the rest. */
25898
+ const reviewBaseFor = (rest) => rest.step.request.options.base ?? rest.run.diffBase;
25899
+ const renderRest = (rest) => succeed$1({
25900
+ state: rest.state,
25901
+ actor: rest.actor,
25902
+ kind: rest.stepDef.kind,
25903
+ content: rest.stepDef.content,
25904
+ ...rest.hints,
25905
+ ...rest.memory !== void 0 ? { memory: rest.memory } : {},
25906
+ memoryResumed: rest.memoryResumed,
25907
+ ...rest.pin !== void 0 ? { pin: rest.pin } : {}
25908
+ });
25880
25909
  /**
25881
- * Formats a copy of the mode's own canonical sample and re-validates it with
25882
- * `gtd check <mode>`, failing loudly when the round-trip breaks it — detects a
25883
- * mode whose `format:` contradicts its own `validate:` without ever touching
25884
- * the real steering file. `printf '%s'` over a `shellQuote`d sample, never a
25885
- * heredoc, since no delimiter chosen ahead of time can collide with the
25886
- * sample's own bytes. One bash fragment with no blank lines (so
25887
- * `src/emit/Emit.ts`'s `assembleScript` never splits it from a sibling step), meant
25888
- * to run BEFORE `[ -f <file> ] || exit 0` in the emitted validate script so it
25889
- * still fires at a first-write beat where the real steering file doesn't
25890
- * exist yet.
25910
+ * Derived, restart-proof stall detection: the tree is clean, HEAD is an empty
25911
+ * attempt at this very agent rest, and another dispatch would repeat it.
25891
25912
  */
25892
- const buildModeContradictionCheck = (inputs) => {
25893
- const { mode, samplePath, sample, formatCommand } = inputs;
25894
- const pathQ = shellQuote(samplePath);
25895
- const messageQ = shellQuote(contradictionMessage(mode, formatCommand));
25896
- return [
25897
- `printf '%s' ${shellQuote(sample)} > ${pathQ}`,
25898
- formatCommand,
25899
- `gtd check ${mode} ${pathQ} >/dev/null 2>&1 || {`,
25900
- ` printf '%s\\n' ${messageQ} >&2`,
25901
- ` cat ${pathQ} >&2`,
25902
- ` rm -f ${pathQ}`,
25903
- ` exit 1`,
25904
- `}`,
25905
- `rm -f ${pathQ}`
25906
- ].join("\n");
25913
+ 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;
25914
+ /** 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. */
25915
+ const noProcessUnderway = (rest) => rest.pin === void 0 && !underwayAt(rest.run, rest.step, rest.def);
25916
+ /** `idle` means exactly one thing: no process underway, clean tree. */
25917
+ const restIsIdle = (rest) => noProcessUnderway(rest) && rest.changes.length === 0;
25918
+ const isHumanReviewGate = (def) => def.actor === "human" && def.mode === "review";
25919
+ const approvalRefusal = (rest, approver) => {
25920
+ if (rest.stepDef.approve !== true) return void 0;
25921
+ const id = rest.step.id;
25922
+ if (rest.changes.length > 0) return {
25923
+ kind: "refusal",
25924
+ message: `gtd land: approval-gate: ${formatStepId(id)} approves exactly the judged diff — revert your edits and run \`gtd land\` on a clean tree to approve, or run \`gtd abandon\``
25925
+ };
25926
+ if (isNone(approver)) return {
25927
+ kind: "refusal",
25928
+ message: `gtd land: approval-gate: ${formatStepId(id)} records who approved it, but git has no author identity — set user.name and user.email`
25929
+ };
25930
+ if (!isRecordableApproval({
25931
+ step: id,
25932
+ ...approver.value
25933
+ })) return {
25934
+ kind: "refusal",
25935
+ message: `gtd land: approval-gate: ${formatStepId(id)} cannot record ${approver.value.name} <${approver.value.email}> as approver — it would not read back unchanged from its Gtd-Approved trailer (a control character, for example)`
25936
+ };
25907
25937
  };
25908
- /**
25909
- * Skip notice for a mode whose validator is EXTERNAL (a user `validate:`
25910
- * command, not one of the two built-in modes) — there's no in-process parser
25911
- * to round-trip a sample through. Printed to stderr since silence would read
25912
- * as a clean bill of health.
25913
- */
25914
- const modeContradictionSkipNotice = (mode) => {
25915
- return `printf '%s\\n' ${shellQuote(`gtd: mode "${mode}" has an external validate: command — skipping the format/validate contradiction check`)} >&2`;
25938
+ /** The landing a clean tree decides before any replay, if it decides one. */
25939
+ const cleanLanding = (rest) => {
25940
+ if (rest.changes.length > 0) return void 0;
25941
+ const def = rest.stepDef;
25942
+ if (def.kind === "prompt" && def.allowEmpty !== true) return {
25943
+ kind: "attempt",
25944
+ subject: formatSubject(rest.actor, rest.state)
25945
+ };
25946
+ if (rest.actor === "human" && def.acceptClean !== true) return {
25947
+ kind: "noop",
25948
+ settled: false
25949
+ };
25950
+ };
25951
+ /** At the human review gate the pending tree lands with every tick cleared, as the landing script will. */
25952
+ const reviewGateRewrite = (def) => {
25953
+ const reviewFormat = steeringFormatFor("review");
25954
+ if (!isHumanReviewGate(def) || def.file === void 0 || reviewFormat === void 0) return;
25955
+ return {
25956
+ path: def.file,
25957
+ apply: (content) => reviewFormat.clearTicks(content)
25958
+ };
25959
+ };
25960
+ const multilineRefusal = (vars) => {
25961
+ const multiline = multilineSetting(vars);
25962
+ return multiline === void 0 ? void 0 : {
25963
+ kind: "refusal",
25964
+ message: `gtd: process setting "${multiline}" spans several lines — a process setting is pinned in a commit trailer and must be a single line`
25965
+ };
25966
+ };
25967
+ const landingTarget = (outcome, rest) => {
25968
+ if (outcome.kind === "refused") return succeed$1({
25969
+ kind: "refusal",
25970
+ message: outcome.message
25971
+ });
25972
+ if (outcome.kind === "divergence" || outcome.kind === "failed") return fail$4(new Error(outcome.message));
25973
+ if (outcome.kind !== "rest") return succeed$1(rest.def.initial);
25974
+ const reserved = initialStepRefusal(rest.run.workflow, outcome.rest.name, rest.def.initial);
25975
+ return succeed$1(reserved === void 0 ? outcome.rest.name : reserved);
25916
25976
  };
25917
- //#endregion
25918
- //#region src/emit/SteeringMode.ts
25919
25977
  /**
25920
- * `format` alone (no live/external verdict) when there's no validator at
25921
- * all; a live in-process validator for a `"builtin"` validate OR a command
25922
- * that is exactly the format's own seeded string; `externalValidate` for any
25923
- * other command. Each arm returns directly — a flat sequence, not nested
25924
- * ternaries — so the four outcomes stay each their own case to read, not a
25925
- * boolean expression to re-derive.
25978
+ * A step commit entering the default's first step is what ends an episode, so
25979
+ * a named workflow landing there would read back as finished. Its opening
25980
+ * commit may name that step: a bare opening is never read as a boundary.
25926
25981
  */
25927
- const capabilitiesFor = (mode, format, validate) => {
25928
- if (format === void 0) return validate?.kind === "command" ? { externalValidate: true } : {};
25929
- if (validate?.kind === "builtin") return {
25930
- format,
25931
- liveValidate: format.validate
25982
+ const initialStepRefusal = (workflow, step, initial) => workflow !== void 0 && step === initial ? {
25983
+ kind: "refusal",
25984
+ message: `gtd: workflow "${workflow}" reaches step "${step}", the default workflow's first step — that is where a finished process waits, so a started process must never land there; rename the step`
25985
+ } : void 0;
25986
+ const closingTrailers = (branch) => ({
25987
+ version: GTD_VERSION,
25988
+ ...isSome(branch) ? { branch: branch.value } : {}
25989
+ });
25990
+ const isSettledNoop = (rest, to) => rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state;
25991
+ const keptTurn = (rest) => rest.stepDef.keep === true && rest.changes.length > 0 ? { keepTurn: keptRef(rest.run.startHash, rest.state) } : {};
25992
+ const approvedTrailer = (rest, approver) => isSome(approver) ? { approved: {
25993
+ step: rest.step.id,
25994
+ ...approver.value
25995
+ } } : {};
25996
+ const landingTo = (rest, to, branch, approver, marks) => {
25997
+ if (isSettledNoop(rest, to)) return {
25998
+ kind: "noop",
25999
+ settled: true
25932
26000
  };
25933
- if (validate === void 0) return { format };
25934
- if (isSeededValidateCommand(mode, validate.command)) return {
25935
- format,
25936
- liveValidate: format.validate
26001
+ const underway = !noProcessUnderway(rest);
26002
+ const starts = !underway && to !== rest.def.initial;
26003
+ const refused = starts ? multilineRefusal(rest.vars) : void 0;
26004
+ if (refused !== void 0) return refused;
26005
+ const { finishes, landsAt } = endingOf(rest, to, underway);
26006
+ return {
26007
+ kind: "commit",
26008
+ to: landsAt,
26009
+ spec: {
26010
+ actor: rest.actor,
26011
+ from: rest.state,
26012
+ to: landsAt,
26013
+ step: rest.step.id,
26014
+ ...marks.length > 0 && { marks },
26015
+ ...approvedTrailer(rest, approver),
26016
+ ...startTrailers(rest, starts),
26017
+ ...finishes ? closingTrailers(branch) : {}
26018
+ },
26019
+ ...finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {},
26020
+ ...keptTurn(rest)
25937
26021
  };
26022
+ };
26023
+ const endingOf = (rest, to, underway) => {
26024
+ const ends = underway && to === rest.def.initial;
25938
26025
  return {
25939
- format,
25940
- externalValidate: true
26026
+ finishes: ends && !rest.pinned,
26027
+ landsAt: ends && rest.pinned ? PIN_STATES.remove : to
26028
+ };
26029
+ };
26030
+ /** A pin or unpin commit carries the pin alone; the unpin commit closes the process. */
26031
+ const pinLanding = (rest, pin, branch) => {
26032
+ const change = rest.changes.find((c) => c.path === PIN_PATH)?.status;
26033
+ return pin === "add" ? pinCommit(rest, change) : unpinCommit(rest, change, branch);
26034
+ };
26035
+ const pinCommit = (rest, change) => {
26036
+ if (change === void 0 || change === "D") return pinRefusal("add");
26037
+ const to = rest.step.name;
26038
+ return {
26039
+ kind: "commit",
26040
+ to,
26041
+ spec: {
26042
+ actor: rest.actor,
26043
+ from: rest.state,
26044
+ to,
26045
+ pin: "add"
26046
+ },
26047
+ only: PIN_PATH
26048
+ };
26049
+ };
26050
+ const unpinCommit = (rest, change, branch) => {
26051
+ if (change !== "D" && rest.pinned) return pinRefusal("remove");
26052
+ const to = rest.def.initial;
26053
+ return {
26054
+ kind: "commit",
26055
+ to,
26056
+ spec: {
26057
+ actor: rest.actor,
26058
+ from: rest.state,
26059
+ to,
26060
+ pin: "remove",
26061
+ ...closingTrailers(branch)
26062
+ },
26063
+ keepHistory: processHistoryRef(rest.run.startHash),
26064
+ ...rest.pinned ? { only: PIN_PATH } : {}
25941
26065
  };
25942
26066
  };
26067
+ const pinRefusal = (pin) => ({
26068
+ kind: "refusal",
26069
+ message: pin === "add" ? `gtd land: ${PIN_PATH} is missing — run the pin beat's script (\`gtd next --json=content\`); if its \`gtd bundle\` fails, fix what it reports, or run \`gtd abandon\`` : `gtd land: ${PIN_PATH} is still there — run the unpin beat's script (\`gtd next --json=content\`), then land`
26070
+ });
26071
+ const startTrailers = (rest, starts) => starts ? { vars: rest.vars } : {};
26072
+ const outcomeMarks = (outcome) => outcome.kind === "rest" || outcome.kind === "ended" ? outcome.marks : [];
25943
26073
  /**
25944
- * Resolve a `mode:` name against `def.modes` plus the built-in registry
25945
- * (`src/workflow/SteeringFormats.ts`) — half by half: a declared `format:`/`validate:`
25946
- * wins, and an undeclared `validate:` falls back to the built-in format's own
25947
- * parser when the name is registered. `format` is set from the registry
25948
- * ALONE, independent of which half of `validate` wins — a declared validator
25949
- * overrides validation, not the format identity.
25950
- *
25951
- * `def: undefined` is the LSP's built-in-only fallback — resolving `mode`
25952
- * against the registry alone, with no workflow definition in hand (e.g. the
25953
- * basename dispatch for a `REVIEW.md` no config maps).
25954
- *
25955
- * The `"unknown"` arm carries the whole refusal message, naming what IS known
25956
- * when `def` is given — this is the one place that message is built, so it
25957
- * can never drift from what actually resolved.
26074
+ * What landing the pending turn at `rest` does — a pure decision from a
26075
+ * replay with the working tree as the pending turn. The target step is
26076
+ * computed by replaying, never matched.
25958
26077
  */
25959
- const resolveMode = (def, state, mode) => {
25960
- const declared = def?.modes?.[mode];
25961
- const format = steeringFormatFor(mode);
25962
- if (declared === void 0 && format === void 0) return {
25963
- kind: "unknown",
25964
- message: def !== void 0 ? `state "${state}": mode "${mode}" is not defined by the active workflow (known modes: ${knownModes(def).join(", ")})` : `mode "${mode}" is not a built-in steering format`
26078
+ const decideLanding = (rest, verdicts, branch, approver) => gen(function* () {
26079
+ if (rest.pin !== void 0) return pinLanding(rest, rest.pin, branch);
26080
+ const unapproved = approvalRefusal(rest, approver);
26081
+ if (unapproved !== void 0) return unapproved;
26082
+ const early = cleanLanding(rest);
26083
+ if (early !== void 0) return early;
26084
+ if (rest.stepDef.kind === "prompt" && rest.stepDef.access?.write != null) {
26085
+ const message = accessRefusal(rest.changes, rest.stepDef.access.write);
26086
+ if (message !== void 0) return {
26087
+ kind: "refusal",
26088
+ message
26089
+ };
26090
+ }
26091
+ const outcome = yield* promise(() => replayFor(rest.setup, {
26092
+ tree: pendingTree(rest.setup.workspace, rest.setup.pendingWorktree(), reviewGateRewrite(rest.stepDef)),
26093
+ ...verdicts !== void 0 ? { verdicts } : {}
26094
+ }));
26095
+ const to = yield* landingTarget(outcome, rest);
26096
+ if (typeof to !== "string") return to;
26097
+ return landingTo(rest, to, branch, approver, outcomeMarks(outcome));
26098
+ });
26099
+ /** Where landing the pending turn would leave the process, or `undefined` when it would land nothing. */
26100
+ const previewLanding = (rest) => decideLanding(rest, void 0, none(), none()).pipe(map((landing) => landing.kind === "commit" ? landing.to : void 0), catch_(() => succeed$1(void 0)));
26101
+ /** Every fact the pure landing planner needs, read once: the rest, its steering file, and the landing decision. */
26102
+ const snapshotFromRest = (rest, verdicts) => gen(function* () {
26103
+ const git = yield* GitService;
26104
+ const branch = yield* git.currentBranch();
26105
+ const approver = rest.stepDef.approve === true ? yield* git.authorIdent() : none();
26106
+ const landing = yield* decideLanding(rest, verdicts, branch, approver);
26107
+ return {
26108
+ stepDef: rest.stepDef,
26109
+ state: rest.state,
26110
+ actor: rest.actor,
26111
+ changes: rest.changes,
26112
+ file: rest.hints.file,
26113
+ landing
25965
26114
  };
25966
- const validate = declared?.validate !== void 0 ? {
25967
- kind: "command",
25968
- command: declared.validate
25969
- } : format !== void 0 ? {
25970
- kind: "builtin",
25971
- format
25972
- } : void 0;
26115
+ });
26116
+ const humanCommitsOf = (trace, entryCommit) => trace.filter((entry) => entry.actor === "human" && entry.hash !== entryCommit).map((entry) => ({
26117
+ hash: entry.hash,
26118
+ state: entry.state
26119
+ }));
26120
+ const summaryInput = (run, settings) => {
26121
+ const entryCommit = run.trace[0].hash;
26122
+ const { total, byModel } = costTotals(run.costEntries);
25973
26123
  return {
25974
- kind: "resolved",
25975
- mode,
25976
- ...format !== void 0 ? { format } : {},
25977
- ...declared?.format !== void 0 ? { formatCommand: declared.format } : {},
25978
- ...validate !== void 0 ? { validate } : {},
25979
- capabilities: capabilitiesFor(mode, format, validate)
26124
+ entryCommit,
26125
+ processBase: run.startParentHash,
26126
+ processTip: run.trace[run.trace.length - 1].hash,
26127
+ humanCommits: humanCommitsOf(run.trace, entryCommit),
26128
+ processCost: total,
26129
+ processCostByModel: byModel,
26130
+ marks: run.marks,
26131
+ ...settings
25980
26132
  };
25981
26133
  };
25982
- /** `gtd next`'s fix-retry instruction, printed ahead of the failing command's captured output. */
25983
- const fixPromptInstruction = (file) => `Your last turn does not pass its own validation script. Fix these format violations in ${file}, then finish:`;
25984
- /**
25985
- * Pushes a `binaryGuard` step ahead of `command` when its own leading word
25986
- * is unambiguous — BEFORE, not wrapped around: a guard that fails must stop
25987
- * the script on its own `exit 127`, without touching `failurePromptWrapper`'s
25988
- * captured-output path. Each command guards on its OWN leading word — a
25989
- * guard naming the wrong binary is worse than none.
25990
- */
25991
- const pushGuardedCommand = (steps, mode, key, command, file, onFailure) => {
25992
- const binary = extractLeadingBinary(command);
25993
- if (binary !== void 0) steps.push({
25994
- kind: "command",
25995
- command: binaryGuard(binary, mode, key)
25996
- });
25997
- steps.push({
25998
- kind: "command",
25999
- command: withFileVar(command, file),
26000
- ...onFailure !== void 0 ? { onFailure } : {}
26001
- });
26002
- };
26134
+ const summaryFor = (run) => gen(function* () {
26135
+ const config = yield* (yield* ConfigService).load;
26136
+ const host = yield* Host;
26137
+ const resolved = yield* workflowOf(config, run);
26138
+ const summary = resolved.def.summary;
26139
+ if (summary === void 0 || run.trace.length === 0) return { prompt: void 0 };
26140
+ return { prompt: summary(summaryInput(run, settingsFor(resolved, run.pinnedVars, host.env, {}, {}))) };
26141
+ });
26003
26142
  /**
26004
- * `resolved`'s `format:`/`validate:` as `EmitStep[]` — format first,
26005
- * validate last, the last one wrapped with `onFailure` when the validator is
26006
- * a command (a built-in's own in-process parser has no shell step to wrap:
26007
- * `gtd check`'s seeded command carries that instead). Each command gets its
26008
- * own `binaryGuard` immediately ahead of it.
26143
+ * Repository-relative paths of today's config files; one outside the
26144
+ * repository cannot be read at a ref, so it is left out. A file only the old
26145
+ * config read needs no check: whichever file imported it differs too.
26009
26146
  */
26010
- const modeCommandSteps = (resolved, file) => {
26011
- const steps = [];
26012
- if (resolved.formatCommand !== void 0) pushGuardedCommand(steps, resolved.mode, "format", resolved.formatCommand, file, void 0);
26013
- if (resolved.validate?.kind === "command") pushGuardedCommand(steps, resolved.mode, "validate", resolved.validate.command, file, fixPromptInstruction(file));
26014
- return steps;
26147
+ const inRepoConfigPaths = (root, files) => {
26148
+ const paths = /* @__PURE__ */ new Set();
26149
+ for (const file of files) paths.add(relative(root, resolve(root, file)).split(sep).join("/"));
26150
+ return [...paths].filter((p) => p !== "" && !p.startsWith("..") && !isAbsolute(p)).sort();
26015
26151
  };
26016
- /** `<Host.scratchDir>/gtd-mode-sample-<mode>-<pid>.md` — an absolute literal baked in at emit time, never a shell variable. `<pid>` avoids collisions between concurrent `gtd` processes. Never a `/tmp` literal or `mktemp` (`tests/tooling/no-tmp-assumption.test.ts` scans for both). */
26017
- const scratchSamplePath = (scratchDir, mode) => join(scratchDir, `gtd-mode-sample-${mode}-${process.pid}.md`);
26018
- /**
26019
- * The contradiction round-trip/skip-notice steps for `resolved`'s `mode:`.
26020
- * No `formatCommand` means nothing to round-trip: empty. Otherwise: a live
26021
- * built-in validator runs the round-trip against that format's own canonical
26022
- * sample; an external validator prints a one-line skip notice instead, since
26023
- * silence there would read as a clean bill of health; a format-only mode with
26024
- * neither has nothing to round-trip either.
26025
- */
26026
- const modeContradictionSteps = (resolved) => gen(function* () {
26027
- if (resolved.formatCommand === void 0) return [];
26028
- const { capabilities } = resolved;
26029
- if (capabilities.format !== void 0 && capabilities.liveValidate !== void 0) {
26030
- const { scratchDir } = yield* Host;
26031
- const samplePath = scratchSamplePath(scratchDir, resolved.mode);
26032
- const formatCommand = withFileVar(resolved.formatCommand, samplePath);
26033
- return [{
26034
- kind: "command",
26035
- command: buildModeContradictionCheck({
26036
- mode: resolved.mode,
26037
- samplePath,
26038
- sample: capabilities.format.sample,
26039
- formatCommand
26040
- })
26041
- }];
26042
- }
26043
- if (capabilities.externalValidate === true) return [{
26044
- kind: "command",
26045
- command: modeContradictionSkipNotice(resolved.mode)
26046
- }];
26047
- return [];
26152
+ const configRefusal = (command, tip, paths, reason = "A history ref replays under today's config, so it is unreadable after the config changes") => /* @__PURE__ */ new Error(`${command}: refused — config differs at ${tip.slice(0, 7)}: ${paths.join(", ")}. ${reason}`);
26153
+ const differingConfigPaths = (git, workspace, root, config, hash) => gen(function* () {
26154
+ const differing = [];
26155
+ for (const path of inRepoConfigPaths(root, Object.keys(config.fingerprint))) if ((yield* orElseSucceed(git.readFileAtRef(hash, path), () => void 0)) !== workspace.readSync(path)) differing.push(path);
26156
+ return differing;
26048
26157
  });
26158
+ const notClosedError = (hash, outcome) => outcome.kind === "rest" ? /* @__PURE__ */ new Error(`gtd history: ${hash.slice(0, 7)} does not close a process under today's config`) : replayError(outcome) ?? /* @__PURE__ */ new Error("gtd history: replay failed");
26159
+ const historySteps = (run) => {
26160
+ const messages = new Map(run.episode.commits.map((c) => [c.hash, c.message]));
26161
+ return run.trace.map((entry) => {
26162
+ const message = parseCommitMessage(messages.get(entry.hash) ?? "");
26163
+ return {
26164
+ hash: entry.hash,
26165
+ subject: (messages.get(entry.hash) ?? "").split("\n")[0] ?? "",
26166
+ actor: entry.actor,
26167
+ from: message.parsed?.from,
26168
+ to: entry.state,
26169
+ verdicts: message.judge,
26170
+ ...optional("approval", approvalEntryOf(entry.hash, message)[0])
26171
+ };
26172
+ });
26173
+ };
26174
+ const versionWarningFor = (recorded) => recorded === GTD_VERSION ? void 0 : `gtd: warning: this history was recorded by ${recorded === void 0 ? "an unknown gtd version" : `gtd ${recorded}`} and is replayed by gtd ${GTD_VERSION} — the engine the workflow runs on may have changed since`;
26049
26175
  /**
26050
- * Renders `resolved`'s complete validate script for `file`: the mode's
26051
- * format/validate contradiction check, a guard for a not-yet-written file,
26052
- * then the format/validate commands themselves (format first). gtd never
26053
- * runs this script itself — `resolveMode`+`validateScriptFor` only render
26054
- * text for a driver to execute (`gtd renders; the driver executes`).
26176
+ * Replays the closed run at `tip` under today's config. The workflow is not
26177
+ * fingerprinted for a version change: the engine under it moves with every
26178
+ * upgrade, and the version warning is the only signal.
26055
26179
  */
26056
- const validateScriptFor = (resolved, file) => gen(function* () {
26057
- return { script: emitScripts([
26058
- ...yield* modeContradictionSteps(resolved),
26059
- {
26060
- kind: "command",
26061
- command: fileExistsGuard(file)
26180
+ const historyTrail = (tip) => gen(function* () {
26181
+ const git = yield* GitService;
26182
+ const config = yield* (yield* ConfigService).load;
26183
+ const hash = yield* git.resolveRef(tip);
26184
+ const subject = yield* git.lastCommitSubject(hash);
26185
+ const run = yield* runAtTip(git, config.workflow, {
26186
+ hash,
26187
+ subject
26188
+ });
26189
+ const { outcome, recorded, versionWarning } = yield* replayFinishedProcess(config, run, hash, {
26190
+ command: "gtd history",
26191
+ notEnded: (o) => notClosedError(hash, o)
26192
+ });
26193
+ return {
26194
+ tip: hash,
26195
+ steps: historySteps(run),
26196
+ ended: outcome.via,
26197
+ version: {
26198
+ recorded,
26199
+ running: GTD_VERSION
26062
26200
  },
26063
- ...modeCommandSteps(resolved, file)
26064
- ]).required };
26201
+ ...versionWarning !== void 0 ? { versionWarning } : {}
26202
+ };
26203
+ });
26204
+ /** Refuses on config drift before replaying, so a stale config never runs the flow. */
26205
+ const replayFinishedProcess = (config, run, hash, how) => gen(function* () {
26206
+ const git = yield* GitService;
26207
+ const workspace = yield* Workspace;
26208
+ const host = yield* Host;
26209
+ const recorded = parseCommitMessage(run.episode.commits.find((c) => c.hash === hash)?.message ?? "").version;
26210
+ const versionWarning = versionWarningFor(recorded);
26211
+ const withWarning = (err) => versionWarning === void 0 ? err : /* @__PURE__ */ new Error(`${err.message}\n${versionWarning}`);
26212
+ const differing = yield* differingConfigPaths(git, workspace, host.root, config, hash);
26213
+ if (differing.length > 0) return yield* fail$4(withWarning(configRefusal(how.command, hash, differing, how.configReason)));
26214
+ const { setup } = yield* setupFor(config, run, workspace, host);
26215
+ const outcome = yield* promise(() => replayFor(setup));
26216
+ if (outcome.kind !== "ended") return yield* fail$4(withWarning(how.notEnded(outcome)));
26217
+ return {
26218
+ outcome,
26219
+ recorded,
26220
+ ...versionWarning !== void 0 ? { versionWarning } : {}
26221
+ };
26065
26222
  });
26066
26223
  //#endregion
26067
26224
  //#region src/cli/ToolCommands.ts
@@ -26118,7 +26275,7 @@ const runStraysCommand = (command, json, out) => gen(function* () {
26118
26275
  const narrator = yield* Narrator;
26119
26276
  const base = yield* resolveSide(git, command.base);
26120
26277
  const tip = yield* resolveSide(git, command.tip);
26121
- const touched = yield* git.diffPaths(base, tip);
26278
+ const touched = (yield* git.diffPaths(base, tip)).filter((path) => path !== PIN_PATH);
26122
26279
  const strays = strayPaths(touched, command.globs);
26123
26280
  const total = touched.length;
26124
26281
  if (json.kind === "select") yield* writeSelection(out, strays, json.path).pipe(catchIf((error) => error instanceof SelectorUsageError && /^\d+$/.test(json.path), () => void_));
@@ -27014,8 +27171,7 @@ const startWorkflow = (workflow, varOverrides, json, out, commandLabel) => gen(f
27014
27171
  workflow: resolved,
27015
27172
  first: started,
27016
27173
  commandLabel,
27017
- vars: varOverrides,
27018
- config: rest.config
27174
+ vars: varOverrides
27019
27175
  });
27020
27176
  if (plan.kind === "refusal") return yield* fail$4(new Error(plan.message));
27021
27177
  yield* writeScript(json, out, listingCommitted(landingScript(ScriptSurface.render(plan.steps)), rest.changes));
@@ -27127,6 +27283,10 @@ const runAbandonCommand = (json, out) => gen(function* () {
27127
27283
  kind: "gitWrite",
27128
27284
  command: mixedResetTo(run.startParentHash)
27129
27285
  },
27286
+ {
27287
+ kind: "gitWrite",
27288
+ command: unpinScript(PIN_PATH)
27289
+ },
27130
27290
  {
27131
27291
  kind: "outcome",
27132
27292
  command: abandonedOutcome(restState, run.startParentHash, initial)
@@ -27284,7 +27444,7 @@ const runCodeThreadsCheckCommand = () => gen(function* () {
27284
27444
  if (rest.run.trace.length === 0) return;
27285
27445
  const git = yield* GitService;
27286
27446
  const workspace = yield* Workspace;
27287
- const lines = (yield* git.changedPaths(rest.run.diffBase)).filter((c) => c.status !== "D").flatMap(({ path }) => {
27447
+ const lines = (yield* git.changedPaths(rest.run.diffBase)).filter((c) => c.status !== "D" && c.path !== ".gtd/process.gtdpin").flatMap(({ path }) => {
27288
27448
  const { threads, findings } = parseCodeThreads(path, workspace.atPath(path) ?? "");
27289
27449
  return [...threads.filter((t) => t.waitingOn === "human").map((t) => `${path}:${t.line + 1}: ${t.entries[0].text}`), ...findings.map((f) => `${path}:${(f.line ?? 0) + 1}: ${f.message}`)];
27290
27450
  });
@@ -28013,6 +28173,7 @@ const flagHeader = (row) => {
28013
28173
  };
28014
28174
  const GLOBAL_OPTIONS = [
28015
28175
  ["-v, --verbose", "Narrate on stderr what gtd resolved"],
28176
+ ["--live", "Run this gtd, not the process's pin; it may misread the process"],
28016
28177
  ["-h, --help", "Print help; gtd <command> --help for one command"],
28017
28178
  ["-V, --version", "Print the version"],
28018
28179
  ["--", "End the options: every word after it is an argument"]
@@ -28024,7 +28185,7 @@ const EXTRA_TOPICS = [[
28024
28185
  ], [
28025
28186
  "version",
28026
28187
  "Print the version",
28027
- "Print gtd's version."
28188
+ "Print gtd's version, and the version of a pinned process's gtd."
28028
28189
  ]];
28029
28190
  const EXAMPLES = ` $ gtd start list what can start
28030
28191
  $ gtd start <name> start a process
@@ -28865,6 +29026,17 @@ var InMemRepo = class InMemRepo {
28865
29026
  this.index = new Map(this.worktree);
28866
29027
  this.resetHard();
28867
29028
  }
29029
+ /** Twin of `GitScript.commitPath`: commits `path` as the worktree has it, every other staged change left staged. */
29030
+ commitPath(path, message) {
29031
+ if (!this.worktree.has(path) && !this.headTree().has(path) && !this.index.has(path)) return `error: pathspec '${path}' did not match any file(s) known to git`;
29032
+ const staged = new Map(this.index);
29033
+ this.index = new Map(this.headTree());
29034
+ this.stage(path);
29035
+ const failure = this.commitAsIs(message);
29036
+ this.index = staged;
29037
+ this.stage(path);
29038
+ return failure;
29039
+ }
28868
29040
  commitAllWithPrefix(prefix) {
28869
29041
  this.index = new Map(this.worktree);
28870
29042
  return this.commitAsIs(prefix);
@@ -29631,6 +29803,16 @@ const recognizeGitBuilders = (repo, block) => {
29631
29803
  }
29632
29804
  const conditionLine = conditionStatement(block);
29633
29805
  if (block.startsWith("git add -A || exit 1\ngtd_sign_cfg=")) return recognizeKeepTurn(repo, block);
29806
+ if (block.startsWith("git add -A -- ")) {
29807
+ const [path, message] = extractQuotedTokens(block);
29808
+ if (path === void 0 || message === void 0 || commitPath(path, message) !== block) return;
29809
+ const failure = repo.commitPath(path, message);
29810
+ return failure === void 0 ? { kind: "applied" } : {
29811
+ kind: "failed",
29812
+ error: failure,
29813
+ status: 1
29814
+ };
29815
+ }
29634
29816
  if (block.startsWith("git add -A || exit 1\n")) {
29635
29817
  const [message] = extractQuotedTokens(conditionLine);
29636
29818
  if (message !== void 0 && commitAll(message) === block) {
@@ -29708,6 +29890,19 @@ const recognizeGitBuilders = (repo, block) => {
29708
29890
  return;
29709
29891
  }
29710
29892
  };
29893
+ /** What the in-memory `gtd bundle` prints: a pin header, and no engine behind it. */
29894
+ const IN_MEMORY_PIN = "gtdpin 1 gtd in-memory\n";
29895
+ /** A pin or unpin beat's script. */
29896
+ const recognizePinScripts = (repo, block) => {
29897
+ if (block === pinScript(".gtd/process.gtdpin")) {
29898
+ repo.writeFile(PIN_PATH, IN_MEMORY_PIN);
29899
+ return { kind: "applied" };
29900
+ }
29901
+ if (block === unpinScript(".gtd/process.gtdpin")) {
29902
+ if (repo.readFile(".gtd/process.gtdpin") !== void 0) repo.deleteFile(PIN_PATH);
29903
+ return { kind: "applied" };
29904
+ }
29905
+ };
29711
29906
  /** The REAL `fileExistsGuard` block, re-derived from the extracted path and string-compared. The one recognizer that can report `{ kind: "stopped" }`: a real `exit 0` there ends the script successfully. */
29712
29907
  const recognizeFileExistsGuard = (repo, block) => {
29713
29908
  const [file] = extractQuotedTokens(block);
@@ -30073,6 +30268,7 @@ const recognizersFor = (repo, commands, git) => [
30073
30268
  (block) => recognizeBinaryGuard(commands, block),
30074
30269
  (block) => git ? recognizeGitBuilders(repo, block) : void 0,
30075
30270
  (block) => recognizeWriteLines(repo, block),
30271
+ (block) => recognizePinScripts(repo, block),
30076
30272
  (block) => recognizeFailurePromptWrapper(repo, commands, block),
30077
30273
  (block) => recognizePresentationSubshell(repo, commands, block, git),
30078
30274
  (block) => recognizeModeContradictionSkipNotice(block),
@@ -30310,12 +30506,16 @@ const expectedVerdict = (doc) => {
30310
30506
  return ["expected a JSON array, one entry per question — answer a boolean for a noul, a string for a choice, a number for a score; p between 0 and 1:", `[${questions.map(({ id, primitive }) => `{ "id": ${JSON.stringify(id)}, "answer": ${ANSWER[primitive] ?? "\"<answer>\""}, "p": 0.9 }`).join(", ")}]`].join("\n");
30311
30507
  };
30312
30508
  const quoted = (names) => names.map((name) => `"${name}"`).join(", ");
30313
- /** Who acts at `doc`: its scripted actor, or a check's own script under `checks: "run"`. A stall has none. */
30509
+ /** Who acts at `doc`: its scripted actor, or its own script at a pin beat or a check under `checks: "run"`. A stall has none. */
30314
30510
  const actorAt = (doc, actors, checks) => {
30315
30511
  if (doc.kind === "stalled") return {
30316
30512
  scripted: false,
30317
30513
  actor: void 0
30318
30514
  };
30515
+ if (doc.pin !== void 0) return {
30516
+ scripted: false,
30517
+ actor: runsItsScript
30518
+ };
30319
30519
  if (Object.hasOwn(actors, doc.state)) return {
30320
30520
  scripted: true,
30321
30521
  actor: actors[doc.state]