@pmelab/gtd 60.1.0 → 61.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 (48) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +7 -0
  3. package/dist/chunks/{testkit-BhTey1Do.mjs → testkit-C9sq0x07.mjs} +1695 -1454
  4. package/dist/chunks/{wire-DZ8R1RU9.mjs → wire-CPNSm_9N.mjs} +27 -12
  5. package/dist/gtd.bundle.mjs +1666 -1449
  6. package/dist/gtd.core.mjs +86 -86
  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/History.test.ts +10 -1
  12. package/src/cli/Metrics.test.ts +10 -1
  13. package/src/cli/Program.test.ts +30 -19
  14. package/src/cli/Program.ts +4 -1
  15. package/src/cli/Strays.ts +3 -2
  16. package/src/edge/Edge.test.ts +78 -25
  17. package/src/edge/Edge.ts +161 -28
  18. package/src/emit/GitScript.ts +21 -3
  19. package/src/emit/index.ts +3 -0
  20. package/src/gherkin/messages.test.ts +1 -0
  21. package/src/gherkin/module-config.feature +1 -0
  22. package/src/gherkin/more-steps.feature +1 -0
  23. package/src/gherkin/testkit.feature +2 -1
  24. package/src/install/Install.ts +9 -2
  25. package/src/replay/Format.test.ts +35 -8
  26. package/src/replay/Format.ts +7 -4
  27. package/src/replay/Replay.test.ts +54 -0
  28. package/src/replay/Replay.ts +61 -17
  29. package/src/replay/Trailers.test.ts +12 -12
  30. package/src/replay/Trailers.ts +35 -20
  31. package/src/replay/Tree.ts +11 -0
  32. package/src/replay/index.ts +3 -0
  33. package/src/step/LandStep.ts +1 -0
  34. package/src/step/RepoSnapshot.ts +2 -0
  35. package/src/step/planStart.test.ts +2 -2
  36. package/src/step/planStep.test.ts +4 -4
  37. package/src/step/planStep.ts +10 -8
  38. package/src/testkit/TestRepo.test.ts +4 -1
  39. package/src/testkit/TestRepo.ts +3 -1
  40. package/src/twins/EmittedScriptRecognizer.ts +32 -1
  41. package/src/twins/GitTiers.ts +23 -0
  42. package/src/twins/InMemRepo.ts +14 -0
  43. package/src/twins/PinBeats.ts +33 -0
  44. package/src/twins/index.ts +1 -0
  45. package/src/wire/BeatDocument.ts +26 -5
  46. package/src/wire/Demand.ts +16 -6
  47. package/src/wire/constants.ts +3 -0
  48. package/src/wire/types.ts +4 -0
@@ -12326,7 +12326,13 @@ The \`kind\` field (\`--json=kind\`) selects what to do:
12326
12326
  Land it immediately, no display needed.
12327
12327
  - \`script\` — run \`--json=content\` with \`sh -c\`, appending its output to
12328
12328
  \`--json=log\`, and ignore its exit code — the outcome lives in the tree,
12329
- not in the exit code.
12329
+ not in the exit code. Two script beats carry a \`pin\` mark: the **pin
12330
+ beat** right after a process starts (\`pin: "add"\`, its script writes
12331
+ \`gtd bundle\`'s output to \`.gtd/process.gtdpin\`) and the **unpin beat**
12332
+ once the flow has ended (\`pin: "remove"\`, its script deletes that file).
12333
+ Both run unattended, like any script beat. A driver may write or delete the
12334
+ file its own way instead of running the script; either way it then lands,
12335
+ and the unpin's landing is the commit that closes the process.
12330
12336
  - \`prompt\` — send plain \`gtd next\`'s own output to your agent CLI over
12331
12337
  stdin (it's the bare content at this kind, so no separate \`--json=content\`
12332
12338
  read is needed), using the accompanying \`session.id\`/\`session.resume\`/
@@ -12369,7 +12375,8 @@ skills, \`skills\` (an array of the skill names, already trimmed and split —
12369
12375
  \`label\`, \`file\`, \`mode\`, \`edges\`, \`judge\` (the pending judgment's rendered
12370
12376
  document). When a cost has been recorded (a prior \`gtd land --cost=<n>\`):
12371
12377
  \`cost\`, \`costByModel\`. Always \`approvals\` (an array of
12372
- \`{step, name, email, commit}\`, \`[]\` when none).
12378
+ \`{step, name, email, commit}\`, \`[]\` when none). Only at a pin or unpin
12379
+ beat: \`pin\` (\`"add"\` or \`"remove"\`).
12373
12380
 
12374
12381
  \`gtd land\` fields, in fixed order: \`schema\`, \`script\` (the POSIX sh to run), \`settled\`
12375
12382
  (true for a no-op at a \`script\` rest — stop immediately, nothing more to
@@ -28942,6 +28949,8 @@ const renderFormat = (fmt, ...args) => {
28942
28949
  };
28943
28950
  /** The first line of the landing refusal for a turn that wrote outside its `write` access — a stable marker drivers and tests grep for. */
28944
28951
  const ACCESS_REFUSAL = "gtd land: this turn wrote outside its write access — revert these paths, then run `gtd land` again:";
28952
+ /** Where a process keeps its pin: the engine and workflow it runs on, as `gtd bundle` prints them. */
28953
+ const PIN_PATH = ".gtd/process.gtdpin";
28945
28954
  //#endregion
28946
28955
  //#region src/wire/Demand.ts
28947
28956
  /**
@@ -28982,14 +28991,20 @@ const demandOf = (input) => {
28982
28991
  read: null,
28983
28992
  write: null
28984
28993
  };
28985
- return kind === "prompt" ? {
28994
+ if (kind === "prompt") return {
28986
28995
  kind,
28987
28996
  content,
28988
28997
  session,
28989
28998
  validate,
28990
28999
  skills,
28991
29000
  access
28992
- } : {
29001
+ };
29002
+ if (kind === "script" && rendered.pin !== void 0) return {
29003
+ kind,
29004
+ content,
29005
+ pin: rendered.pin
29006
+ };
29007
+ return {
28993
29008
  kind,
28994
29009
  content
28995
29010
  };
@@ -29042,33 +29057,39 @@ const assertNeverDemand = (demand) => {
29042
29057
  * falling through to "no session."
29043
29058
  */
29044
29059
  const dispatchFieldsOf = (demand) => {
29060
+ const none = {
29061
+ session: void 0,
29062
+ validate: void 0,
29063
+ skills: void 0,
29064
+ access: void 0,
29065
+ pin: void 0
29066
+ };
29045
29067
  switch (demand.kind) {
29046
29068
  case "prompt": return {
29069
+ ...none,
29047
29070
  session: demand.session,
29048
29071
  validate: demand.validate,
29049
29072
  skills: demand.skills,
29050
29073
  access: demand.access
29051
29074
  };
29075
+ case "script": return {
29076
+ ...none,
29077
+ pin: demand.pin
29078
+ };
29052
29079
  case "capture":
29053
29080
  case "message":
29054
- case "script":
29055
- case "stalled": return {
29056
- session: void 0,
29057
- validate: void 0,
29058
- skills: void 0,
29059
- access: void 0
29060
- };
29081
+ case "stalled": return none;
29061
29082
  default: return assertNeverDemand(demand);
29062
29083
  }
29063
29084
  };
29064
29085
  /**
29065
- * Flatten a `Demand` plus its `BeatStatus` into the single 25-key document
29086
+ * Flatten a `Demand` plus its `BeatStatus` into the single 26-key document
29066
29087
  * `gtd next --json` emits — the ONLY place the two are joined. `cost`/
29067
29088
  * `costByModel` are omitted together, exactly when no cost was recorded
29068
29089
  * (`cost <= 0`).
29069
29090
  */
29070
29091
  const beatDocument = (demand, status) => {
29071
- const { session, validate, skills, access } = dispatchFieldsOf(demand);
29092
+ const { session, validate, skills, access, pin } = dispatchFieldsOf(demand);
29072
29093
  const hasCost = status.cost > 0;
29073
29094
  return {
29074
29095
  schema: 1,
@@ -29080,6 +29101,7 @@ const beatDocument = (demand, status) => {
29080
29101
  model: status.model,
29081
29102
  system: status.system,
29082
29103
  validate,
29104
+ pin,
29083
29105
  log: status.log,
29084
29106
  state: status.state,
29085
29107
  actor: status.actor,
@@ -29417,11 +29439,20 @@ const diffTrees = (before, after) => {
29417
29439
  };
29418
29440
  };
29419
29441
  const isEmptyDiff = (diff) => diff.added.length === 0 && diff.modified.length === 0 && diff.deleted.length === 0;
29442
+ const withoutPath = (tree, hidden) => {
29443
+ let paths;
29444
+ return {
29445
+ paths: () => paths ??= tree.paths().filter((path) => path !== hidden),
29446
+ read: (path) => path === hidden ? void 0 : tree.read(path),
29447
+ ...tree.id !== void 0 ? { id: (path) => path === hidden ? void 0 : tree.id(path) } : {}
29448
+ };
29449
+ };
29450
+ const OLDEST_READABLE_FORMAT = 1;
29420
29451
  const short$1 = (hash) => hash.slice(0, 7);
29421
29452
  const formatFault = (commits) => {
29422
- const foreign = commits.find((c) => c.format !== 1);
29453
+ const foreign = commits.find((c) => c.format < OLDEST_READABLE_FORMAT || c.format > 2);
29423
29454
  if (foreign === void 0) return void 0;
29424
- 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`;
29455
+ 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`;
29425
29456
  };
29426
29457
  //#endregion
29427
29458
  //#region src/replay/Trailers.ts
@@ -29458,6 +29489,11 @@ const parseStepId = (raw) => {
29458
29489
  occurrence: Number(match[2])
29459
29490
  };
29460
29491
  };
29492
+ /** What a pin and an unpin beat rest at, and their commits name — neither is a step of the workflow. */
29493
+ const PIN_STATES = {
29494
+ add: "pin",
29495
+ remove: "unpin"
29496
+ };
29461
29497
  const isVerdict = (value) => {
29462
29498
  if (typeof value !== "object" || value === null) return false;
29463
29499
  const v = value;
@@ -29555,6 +29591,9 @@ const TRAILER_READERS = {
29555
29591
  "Gtd-Approved": (value, into) => {
29556
29592
  into.approved ??= decodeApproval(value);
29557
29593
  },
29594
+ "Gtd-Pin": (value, into) => {
29595
+ if (value === "add" || value === "remove") into.pin ??= value;
29596
+ },
29558
29597
  "Gtd-Format": (value, into) => {
29559
29598
  if (FORMAT_RE.test(value)) into.format ??= Number(value);
29560
29599
  },
@@ -29577,6 +29616,7 @@ const parseCommitMessage = (message) => {
29577
29616
  version: void 0,
29578
29617
  kept: void 0,
29579
29618
  approved: void 0,
29619
+ pin: void 0,
29580
29620
  judge: [],
29581
29621
  marks: [],
29582
29622
  vars: {},
@@ -29594,24 +29634,29 @@ const parseCommitMessage = (message) => {
29594
29634
  };
29595
29635
  const sortedEntries = (record = {}) => Object.entries(record).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
29596
29636
  /** Trailer order is fixed so the same spec always yields the same bytes. */
29637
+ const trailer = (name, value) => value === void 0 ? [] : [`Gtd-${name}: ${value}`];
29638
+ const costValue = (cost) => cost === void 0 ? void 0 : `${cost.cost}${cost.model !== void 0 ? ` ${cost.model}` : ""}`;
29597
29639
  const formatCommitMessage = (spec) => {
29598
- const lines = [];
29599
- if (spec.step !== void 0) lines.push(`Gtd-Step: ${formatStepId(spec.step)}`);
29600
- if (spec.workflow !== void 0) lines.push(`Gtd-Workflow: ${spec.workflow}`);
29601
- if (spec.reviewBase !== void 0) lines.push(`Gtd-Review-Base: ${spec.reviewBase}`);
29602
- for (const [name, value] of sortedEntries(spec.vars)) lines.push(`Gtd-Var: ${name}=${value}`);
29603
- for (const [path, hash] of sortedEntries(spec.config)) lines.push(`Gtd-Config: ${hash} ${encodePath(path)}`);
29604
- if (spec.cost !== void 0) lines.push(`Gtd-Cost: ${spec.cost.cost}${spec.cost.model !== void 0 ? ` ${spec.cost.model}` : ""}`);
29605
- for (const verdict of spec.judge ?? []) lines.push(`Gtd-Judge: ${JSON.stringify(verdict)}`);
29606
- for (const m of spec.marks ?? []) lines.push(`Gtd-Mark: ${m.name}=${m.value}`);
29607
- if (spec.approved !== void 0) lines.push(`Gtd-Approved: ${encodeApproval(spec.approved)}`);
29608
- if (spec.branch !== void 0) lines.push(`Gtd-Branch: ${spec.branch}`);
29609
- if (spec.version !== void 0) lines.push(`Gtd-Version: ${spec.version}`);
29610
- lines.push(`Gtd-Format: 1`);
29640
+ const lines = [
29641
+ ...trailer("Step", spec.step && formatStepId(spec.step)),
29642
+ ...trailer("Workflow", spec.workflow),
29643
+ ...trailer("Review-Base", spec.reviewBase),
29644
+ ...sortedEntries(spec.vars).map(([name, value]) => `Gtd-Var: ${name}=${value}`),
29645
+ ...sortedEntries(spec.config).map(([path, hash]) => `Gtd-Config: ${hash} ${encodePath(path)}`),
29646
+ ...trailer("Cost", costValue(spec.cost)),
29647
+ ...(spec.judge ?? []).map((verdict) => `Gtd-Judge: ${JSON.stringify(verdict)}`),
29648
+ ...(spec.marks ?? []).map((m) => `Gtd-Mark: ${m.name}=${m.value}`),
29649
+ ...trailer("Approved", spec.approved && encodeApproval(spec.approved)),
29650
+ ...trailer("Pin", spec.pin),
29651
+ ...trailer("Branch", spec.branch),
29652
+ ...trailer("Version", spec.version),
29653
+ `Gtd-Format: 2`
29654
+ ];
29611
29655
  return `${formatSubject(spec.actor, spec.to, spec.from)}\n\n${lines.join("\n")}`;
29612
29656
  };
29613
29657
  //#endregion
29614
29658
  //#region src/replay/Replay.ts
29659
+ const UNPIN_STATE = PIN_STATES.remove;
29615
29660
  const actorOfKind = (kind) => kind === "run" ? "check" : kind === "agent" ? "agent" : kind === "human" ? "human" : "judge";
29616
29661
  const memoryScopeOf = (name) => {
29617
29662
  const dot = name.lastIndexOf(".");
@@ -29745,7 +29790,33 @@ const modeWithoutFile = (step) => {
29745
29790
  if (options.mode === void 0 || options.file !== void 0) return void 0;
29746
29791
  return `gtd: step "${step.name}": mode requires file in ${step.kind}() options — name the steering file the mode checks`;
29747
29792
  };
29748
- const replay = async (input) => {
29793
+ const withoutPin = (input) => {
29794
+ const hide = (tree) => withoutPath(tree, PIN_PATH);
29795
+ return {
29796
+ ...input,
29797
+ episode: {
29798
+ base: {
29799
+ ...input.episode.base,
29800
+ tree: hide(input.episode.base.tree)
29801
+ },
29802
+ commits: input.episode.commits.map((c) => ({
29803
+ ...c,
29804
+ tree: hide(c.tree),
29805
+ ...c.kept !== void 0 ? { kept: {
29806
+ ...c.kept,
29807
+ tree: hide(c.kept.tree)
29808
+ } } : {}
29809
+ }))
29810
+ },
29811
+ ...input.startTree !== void 0 ? { startTree: hide(input.startTree) } : {},
29812
+ ...input.pending !== void 0 ? { pending: {
29813
+ ...input.pending,
29814
+ tree: hide(input.pending.tree)
29815
+ } } : {}
29816
+ };
29817
+ };
29818
+ const replay = async (raw) => {
29819
+ const input = withoutPin(raw);
29749
29820
  const commits = input.episode.commits.map((c) => ({
29750
29821
  ...c,
29751
29822
  parsed: parseCommitMessage(c.message)
@@ -29754,6 +29825,9 @@ const replay = async (input) => {
29754
29825
  let cursor = 0;
29755
29826
  let pendingUsed = false;
29756
29827
  let position = input.episode.base;
29828
+ const skipPins = () => {
29829
+ while (cursor < commits.length && commits[cursor].parsed.pin !== void 0) cursor++;
29830
+ };
29757
29831
  let previousPosition = input.episode.base;
29758
29832
  let expectedNext;
29759
29833
  const occurrences = /* @__PURE__ */ new Map();
@@ -29935,19 +30009,24 @@ const replay = async (input) => {
29935
30009
  if (unapproved !== void 0) return finish(unapproved);
29936
30010
  cursor++;
29937
30011
  advance(commit);
29938
- if (commit.parsed.kept !== void 0) if (commit.kept === void 0) keptTips.delete(step.name);
29939
- else keptTips.set(step.name, {
29940
- hash: commit.kept.hash,
29941
- before: commit.tree,
29942
- after: commit.kept.tree
29943
- });
30012
+ skipPins();
30013
+ noteKept(step, commit);
29944
30014
  const to = commit.parsed.parsed?.to;
29945
- if (to !== void 0) expectedNext = {
30015
+ if (to !== void 0 && to !== UNPIN_STATE) expectedNext = {
29946
30016
  name: to,
29947
30017
  hash: commit.hash
29948
30018
  };
29949
30019
  return replyFor(step.request, commit.parsed.judge);
29950
30020
  };
30021
+ const noteKept = (step, commit) => {
30022
+ if (commit.parsed.kept === void 0) return;
30023
+ if (commit.kept === void 0) keptTips.delete(step.name);
30024
+ else keptTips.set(step.name, {
30025
+ hash: commit.kept.hash,
30026
+ before: commit.tree,
30027
+ after: commit.kept.tree
30028
+ });
30029
+ };
29951
30030
  const landPending = (step, pending) => {
29952
30031
  pendingUsed = true;
29953
30032
  landed = step;
@@ -29964,7 +30043,10 @@ const replay = async (input) => {
29964
30043
  return replyFor(step.request, pending.verdicts ?? []);
29965
30044
  };
29966
30045
  const answer = (step) => {
29967
- while (cursor < commits.length && isAttemptAt(commits[cursor], step.name)) cursor++;
30046
+ while (cursor < commits.length && isAttemptAt(commits[cursor], step.name)) {
30047
+ cursor++;
30048
+ skipPins();
30049
+ }
29968
30050
  const commit = commits[cursor];
29969
30051
  if (commit !== void 0) return consumeCommit(step, commit);
29970
30052
  if (input.pending === void 0 || pendingUsed) return finish({
@@ -29977,6 +30059,7 @@ const replay = async (input) => {
29977
30059
  return landPending(step, input.pending);
29978
30060
  };
29979
30061
  const requestFault = (request) => {
30062
+ 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`;
29980
30063
  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)`;
29981
30064
  if (request.kind !== "agent") return void 0;
29982
30065
  const name = scoped(request.name);
@@ -30003,15 +30086,18 @@ const replay = async (input) => {
30003
30086
  const blocker = blockerAt(step);
30004
30087
  return blocker === void 0 ? answer(step) : finish(blocker);
30005
30088
  };
30006
- const endOfFlow = (via) => cursor < commits.length ? {
30007
- kind: "divergence",
30008
- message: `gtd: the flow ended, but commit ${short(commits[cursor].hash)} continues the episode — ${DIVERGED}`
30009
- } : {
30010
- kind: "ended",
30011
- via,
30012
- trace,
30013
- landed,
30014
- marks
30089
+ const endOfFlow = (via) => {
30090
+ skipPins();
30091
+ return cursor < commits.length ? {
30092
+ kind: "divergence",
30093
+ message: `gtd: the flow ended, but commit ${short(commits[cursor].hash)} continues the episode — ${DIVERGED}`
30094
+ } : {
30095
+ kind: "ended",
30096
+ via,
30097
+ trace,
30098
+ landed,
30099
+ marks
30100
+ };
30015
30101
  };
30016
30102
  const changesSince = (hash) => {
30017
30103
  const tree = treeAt(hash);
@@ -30074,10 +30160,11 @@ const replay = async (input) => {
30074
30160
  skillsFor: (localName) => resolveSkills(memoryScopeOf(scoped(localName))) ?? [],
30075
30161
  accessFor: (localName, file) => foldedAccessFor(resolveAccess(memoryScopeOf(scoped(localName))), file),
30076
30162
  head: () => {
30077
- const rest = commits.slice(cursor);
30163
+ const rest = commits.slice(cursor).filter((c) => c.parsed.pin === void 0);
30078
30164
  return rest.length > 0 && rest.every((c) => c.parsed.step === void 0) ? rest[rest.length - 1].hash : position.hash;
30079
30165
  }
30080
30166
  };
30167
+ skipPins();
30081
30168
  installContext(context);
30082
30169
  try {
30083
30170
  const flowDone = input.flow({}).then(() => {
@@ -31161,1539 +31248,1665 @@ const pin = (module, root, version) => gen(function* () {
31161
31248
  return Buffer.concat([Buffer.from(`gtdpin ${PIN_FORMAT} gtd ${version}\n`), gzipSync(source, { level: 9 })]);
31162
31249
  });
31163
31250
  //#endregion
31164
- //#region src/step/planStep.ts
31251
+ //#region src/emit/GitScript.ts
31252
+ const shellQuote$1 = (value) => `'${value.replace(/'/g, "'\\''")}'`;
31165
31253
  /**
31166
- * At the human review gate an unconditional `gtd uncheck '<file>'` runs
31167
- * ahead of the commit, resetting every checkbox before `git add -A` picks it
31168
- * up — a tick is read-progress, never sign-off.
31254
+ * `--allow-empty` because gtd's workflow commits may be empty on purpose.
31255
+ * Mirrors `Git.ts`'s retry: only re-tries without hooks on the specific
31256
+ * "empty git commit" rejection message, discriminated from the captured
31257
+ * output rather than the exit code, so a genuinely rejected commit (a lint
31258
+ * error, a commit-msg hook) still fails.
31169
31259
  */
31170
- const uncheckStep = (snapshot) => snapshot.stepDef.actor === "human" && snapshot.stepDef.mode === "review" && snapshot.file !== void 0 ? [{
31171
- kind: "uncheck",
31172
- file: snapshot.file
31173
- }] : [];
31174
- const planStep = (snapshot, opts = {}) => {
31175
- const landing = snapshot.landing;
31176
- if (landing.kind === "refusal") return {
31177
- kind: "refusal",
31178
- message: landing.message
31179
- };
31180
- if (landing.kind === "noop") return {
31181
- kind: "noop",
31182
- state: snapshot.state,
31183
- settled: landing.settled
31184
- };
31185
- const cost = opts.cost === void 0 ? {} : { cost: {
31186
- cost: opts.cost,
31187
- ...opts.model !== void 0 ? { model: opts.model } : {}
31188
- } };
31189
- if (landing.kind === "attempt") {
31190
- const message = formatCommitMessage({
31191
- actor: snapshot.actor,
31192
- to: snapshot.state,
31193
- ...cost
31194
- });
31195
- return {
31196
- kind: "commit",
31197
- state: snapshot.state,
31198
- to: snapshot.state,
31199
- subject: landing.subject,
31200
- steps: [{
31201
- kind: "gitWrite",
31202
- write: {
31203
- kind: "commitAll",
31204
- message
31205
- }
31206
- }, {
31207
- kind: "outcome",
31208
- outcome: {
31209
- kind: "commit",
31210
- subject: landing.subject
31211
- }
31212
- }]
31213
- };
31214
- }
31215
- const message = formatCommitMessage({
31216
- ...landing.spec,
31217
- ...cost,
31218
- ...opts.judge !== void 0 && opts.judge.length > 0 ? { judge: opts.judge } : {}
31219
- });
31220
- const subject = message.split("\n")[0];
31221
- const outcome = landing.to === snapshot.state ? {
31222
- kind: "outcome",
31223
- outcome: {
31224
- kind: "commit",
31225
- subject
31226
- }
31227
- } : {
31228
- kind: "outcome",
31229
- outcome: {
31230
- kind: "transition",
31231
- from: snapshot.state,
31232
- to: landing.to
31233
- }
31234
- };
31235
- return {
31236
- kind: "commit",
31237
- state: snapshot.state,
31238
- to: landing.to,
31239
- subject,
31240
- steps: [
31241
- ...uncheckStep(snapshot),
31242
- {
31243
- kind: "gitWrite",
31244
- write: landing.keepTurn !== void 0 ? {
31245
- kind: "keepTurn",
31246
- ref: landing.keepTurn,
31247
- message
31248
- } : landing.spec.approved !== void 0 ? {
31249
- kind: "commitClean",
31250
- message
31251
- } : {
31252
- kind: "commitAll",
31253
- message
31254
- }
31255
- },
31256
- ...landing.keepHistory !== void 0 ? [{
31257
- kind: "gitWrite",
31258
- write: {
31259
- kind: "updateRef",
31260
- ref: landing.keepHistory,
31261
- target: "HEAD"
31262
- }
31263
- }] : [],
31264
- outcome
31265
- ]
31266
- };
31260
+ const commitAllowEmpty = (message, only) => {
31261
+ const m = shellQuote$1(message);
31262
+ const paths = only === void 0 ? "" : ` -- ${shellQuote$1(only)}`;
31263
+ return [
31264
+ `if ! out=$(git commit --allow-empty -m ${m}${paths} 2>&1); then`,
31265
+ ` case "$out" in`,
31266
+ ` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m}${paths} || exit 1 ;;`,
31267
+ ` *) printf '%s\\n' "$out" >&2; exit 1 ;;`,
31268
+ ` esac`,
31269
+ `fi`
31270
+ ].join("\n");
31267
31271
  };
31268
- //#endregion
31269
- //#region src/step/accessRefusal.ts
31272
+ const CHANGED_SINCE_LAND = "gtd: the working tree changed since `gtd land` — revert the edits and run `gtd land` again to approve";
31273
+ /** `git add -A || exit 1` (an `&&` left side would not abort under `set -e`) then `commitAllowEmpty`. */
31274
+ const commitAll = (message) => `git add -A || exit 1\n${commitAllowEmpty(message)}`;
31275
+ /** Commits `path` alone, as the worktree has it; every other pending change stays pending. */
31276
+ const commitPath = (path, message) => `git add -A -- ${shellQuote$1(path)} || exit 1\n${commitAllowEmpty(message, path)}`;
31277
+ /** A pin beat's script. A failed `gtd bundle` leaves no half-written pin behind. */
31278
+ const pinScript = (path) => {
31279
+ const p = shellQuote$1(path);
31280
+ const slash = path.lastIndexOf("/");
31281
+ return `${slash === -1 ? "" : `mkdir -p ${shellQuote$1(path.slice(0, slash))}\n`}gtd bundle >${p} || { rm -f -- ${p}; exit 1; }`;
31282
+ };
31283
+ /** An unpin beat's script, and `gtd abandon`'s removal of the pin. */
31284
+ const unpinScript = (path) => `rm -f -- ${shellQuote$1(path)}`;
31270
31285
  /**
31271
- * The landing refusal for changes outside `write`, or `undefined` when every
31272
- * path is allowed. `null` is unrestricted. The message is the fix prompt: gtd
31273
- * runs no fix turn of its own.
31286
+ * An approval records exactly the judged tree: HEAD is pinned before the
31287
+ * clean check and the commit is built over its tree with `commit-tree`, so no
31288
+ * hook runs and none can add to it. A HEAD that moved fails the guarded
31289
+ * `update-ref`. `commit-tree` ignores `commit.gpgSign`, so it is read here.
31274
31290
  */
31275
- const accessRefusal = (changes, write) => {
31276
- if (write === null) return void 0;
31277
- const offending = [...new Set(changes.filter((c) => !write.some((glob) => globMatches(c.path, glob))).map((c) => c.path))];
31278
- if (offending.length === 0) return void 0;
31291
+ const commitClean = (message) => [
31292
+ `gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
31293
+ `if [ -n "$(git status --porcelain --untracked-files=all --ignore-submodules=none)" ]; then`,
31294
+ ` printf '%s\\n' ${shellQuote$1(CHANGED_SINCE_LAND)} >&2`,
31295
+ ` exit 1`,
31296
+ `fi`,
31297
+ `gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
31298
+ `if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
31299
+ `gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m ${shellQuote$1(message)}) || exit 1`,
31300
+ `git update-ref -m ${shellQuote$1(`commit: ${message}`)} HEAD "$gtd_tip" "$gtd_head" || exit 1`
31301
+ ].join("\n");
31302
+ const mixedResetTo = (ref) => `git reset --mixed ${shellQuote$1(ref)}`;
31303
+ const hardResetTo = (ref) => `git reset --hard ${shellQuote$1(ref)}`;
31304
+ /**
31305
+ * POSIX sh (runs under dash). The marker is a `commit-tree` over HEAD's own
31306
+ * tree, so no hook runs on it and its tree is the pre-turn tree by
31307
+ * construction; `commit-tree` ignores `commit.gpgSign`, hence the explicit
31308
+ * `-S`, read (and failing closed) before any object or ref is written; the
31309
+ * index is already staged by then, so an early exit leaves the turn staged.
31310
+ * The kept ref is deleted only after `reset -q` succeeds: it is then the
31311
+ * turn's only handle.
31312
+ * The branch moves before the final hard reset, so if that reset fails the
31313
+ * marker has landed and the turn is still in the working tree (kept ref
31314
+ * present); every other failure leaves the branch untouched.
31315
+ */
31316
+ const keepTurn = (refPrefix, message) => {
31317
+ const keptRef = `${shellQuote$1(`${refPrefix}/`)}"$gtd_kept"`;
31279
31318
  return [
31280
- ACCESS_REFUSAL,
31281
- ...offending.map((path) => ` - ${path}`),
31282
- `allowed: ${write.length === 0 ? "(nothing)" : write.join(", ")}`
31319
+ `git add -A || exit 1`,
31320
+ `gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
31321
+ `if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
31322
+ `gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
31323
+ `gtd_tree=$(git write-tree) || exit 1`,
31324
+ `gtd_kept=$(git commit-tree "$gtd_tree" -p "$gtd_head" -m ${shellQuote$1(message)}) || exit 1`,
31325
+ `git update-ref ${keptRef} "$gtd_kept" || exit 1`,
31326
+ `gtd_marker=$(printf '%s\\nGtd-Kept: %s' ${shellQuote$1(message)} "$gtd_kept")`,
31327
+ `if ! gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m "$gtd_marker") || ! git update-ref -m ${shellQuote$1(`commit: ${message}`)} HEAD "$gtd_tip" "$gtd_head"; then`,
31328
+ ` git reset -q && git update-ref -d ${keptRef}`,
31329
+ ` exit 1`,
31330
+ `fi`,
31331
+ `git reset --hard HEAD`
31283
31332
  ].join("\n");
31284
31333
  };
31285
- //#endregion
31286
- //#region src/step/planStart.ts
31287
- init_dist$1();
31288
- const refusal = (message) => ({
31289
- kind: "refusal",
31290
- message
31291
- });
31292
- const checkOverrides = (commandLabel, varOverrides, declaredNames, envNames) => gen(function* () {
31293
- const environmentOnly = Object.keys(varOverrides).filter((n) => !declaredNames.includes(n) && envNames.includes(n));
31294
- if (environmentOnly.length > 0) return yield* fail$8(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`));
31295
- const undeclared = Object.keys(varOverrides).filter((n) => !declaredNames.includes(n));
31296
- if (undeclared.length > 0) return yield* fail$8(new GtdUsageError(`${commandLabel}: --var name(s) not declared by this workflow: ${undeclared.join(", ")} — declared: ${declaredNames.length > 0 ? declaredNames.join(", ") : "(none)"}`));
31297
- });
31334
+ const updateRef = (ref, hash) => `git update-ref ${shellQuote$1(ref)} ${shellQuote$1(hash)}`;
31335
+ /** `git update-ref -d <ref>` — idempotent: deleting a missing ref is already a no-op in real git. */
31336
+ const deleteRef = (ref) => `git update-ref -d ${shellQuote$1(ref)}`;
31298
31337
  /**
31299
- * `gtd start <name>`: start a brand NEW process on a workflow, writing an
31300
- * opening commit that names the workflow's first step and carries a
31301
- * `Gtd-Workflow:` trailer, every process setting as a `Gtd-Var:` trailer, plus
31302
- * a `Gtd-Review-Base:` trailer when the workflow fixes the process's diff base,
31303
- * and a `Gtd-Config:` trailer per config file.
31304
- * All validation is a REFUSAL, not an Effect failure.
31338
+ * No `+` on the refspec: a local ref the remote's tip does not descend from
31339
+ * fails the fetch instead of being overwritten (a fast-forward still applies).
31305
31340
  */
31306
- const planStart = (current, actor, start) => gen(function* () {
31307
- const { workflow, first, commandLabel, vars: varOverrides } = start;
31308
- const name = workflow.name;
31309
- if (!current.idle) return refusal(`${commandLabel}: a process is already underway (resting at "${current.state}") — finish it, or run \`gtd abandon\`, before starting another`);
31310
- if ("refusal" in first) return refusal(`${commandLabel}: ${first.refusal}`);
31311
- const { firstStep } = first;
31312
- yield* checkOverrides(commandLabel, varOverrides, Object.keys(workflow.vars), Object.keys(workflow.env));
31313
- const baseOf = workflow.def.base;
31314
- const flags = yield* StartFlags;
31315
- const hostEnv = (yield* Host).env;
31316
- const badFlag = ownFlagRefusal(workflow.vars, flags, hostEnv);
31317
- if (badFlag !== void 0) return refusal(`${commandLabel}: ${badFlag}`);
31318
- const vars = resolveStartVars(workflow.vars, flags, varOverrides, hostEnv);
31319
- const multiline = multilineSetting(vars);
31320
- 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`);
31321
- const template = yield* try_({
31322
- try: () => baseOf?.(name, vars),
31323
- catch: (e) => /* @__PURE__ */ new Error(`${commandLabel}: ${e instanceof Error ? e.message : String(e)}`)
31324
- });
31325
- const base = template === void 0 ? void 0 : yield* resolveBase(commandLabel, template);
31326
- if (typeof base === "object") return base;
31327
- const subject = formatSubject(actor, firstStep);
31328
- return {
31329
- kind: "start",
31330
- workflow: name,
31331
- subject,
31332
- steps: [{
31333
- kind: "gitWrite",
31334
- write: {
31335
- kind: "commitAll",
31336
- message: formatCommitMessage({
31337
- actor,
31338
- to: firstStep,
31339
- workflow: name,
31340
- ...base !== void 0 ? { reviewBase: base } : {},
31341
- vars,
31342
- config: start.config
31343
- })
31344
- }
31345
- }, {
31346
- kind: "outcome",
31347
- outcome: {
31348
- kind: "commit",
31349
- subject
31350
- }
31351
- }]
31352
- };
31353
- });
31341
+ const fetchHistoryRefs = (remote) => [
31342
+ `set -eu`,
31343
+ `before=$(git for-each-ref --format='x' 'refs/gtd/history/' | wc -l)`,
31344
+ `git fetch ${shellQuote$1(remote)} 'refs/gtd/history/*:refs/gtd/history/*'`,
31345
+ `after=$(git for-each-ref --format='x' 'refs/gtd/history/' | wc -l)`,
31346
+ `printf '%s history refs arrived\\n' "$((after - before))"`
31347
+ ].join("\n");
31348
+ /** 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. */
31349
+ const OUTCOME_MARKER$1 = "# gtd: outcome (print-only)";
31350
+ const printfLine$1 = (fmt, args) => `${OUTCOME_MARKER$1}\nprintf ${shellQuote$1(fmt.replace(/\n/g, "\\n"))} ${args.join(" ")}`;
31351
+ const renderOutcome = (outcome) => {
31352
+ switch (outcome.kind) {
31353
+ case "transition": return printfLine$1("%s %s → %s\n", [
31354
+ "'->'",
31355
+ shellQuote$1(outcome.from),
31356
+ shellQuote$1(outcome.to)
31357
+ ]);
31358
+ case "commit": return printfLine$1("%s %s\n", ["'[commit]'", shellQuote$1(outcome.subject)]);
31359
+ case "note": return printfLine$1("%s\n", [shellQuote$1(outcome.text)]);
31360
+ }
31361
+ };
31362
+ const renderGitWrite = (write) => {
31363
+ switch (write.kind) {
31364
+ case "commitAll": return commitAll(write.message);
31365
+ case "commitPath": return commitPath(write.path, write.message);
31366
+ case "commitClean": return commitClean(write.message);
31367
+ case "updateRef": return updateRef(write.ref, write.target);
31368
+ case "keepTurn": return keepTurn(write.ref, write.message);
31369
+ }
31370
+ };
31354
31371
  /**
31355
- * The workflow's diff base: `git merge-base <base> HEAD`, where a blank base
31356
- * means the default branch. The merge-base is what gets pinned, so a base on
31357
- * another branch reviews exactly this branch's own commits.
31372
+ * Wraps `command` so a non-zero exit prints `prompt` plus the command's
31373
+ * captured output before propagating that exit code — mirrors `Emit.ts`'s
31374
+ * `failurePromptWrapper` (same reverse-import concern as `renderOutcome`
31375
+ * above: `Emit.ts` imports `shellQuote` from here already).
31358
31376
  */
31359
- const resolveBase = (commandLabel, value) => gen(function* () {
31360
- const git = yield* GitService;
31361
- const rendered = value.trim();
31362
- const target = rendered === "" ? yield* git.defaultBranch() : rendered;
31363
- const resolvedBase = yield* result(git.resolveRef(target));
31364
- if (resolvedBase._tag === "Failure") return refusal(`${commandLabel}: "${target}" does not resolve to a commit`);
31365
- const mergeBase = yield* git.mergeBase(resolvedBase.success, "HEAD");
31366
- if (isNone(mergeBase)) return refusal(`${commandLabel}: "${target}" shares no common ancestor with HEAD`);
31367
- if (mergeBase.value === (yield* git.resolveRef("HEAD"))) return refusal(`${commandLabel}: nothing to review: HEAD has no commits beyond ${target}`);
31368
- return mergeBase.value;
31369
- });
31370
- //#endregion
31371
- //#region src/step/strayPaths.ts
31372
- /** Globs use the `access.write` dialect (`globMatches`). */
31373
- const strayPaths = (touched, allow) => touched.filter((path) => !allow.some((glob) => globMatches(path, glob))).sort();
31377
+ const failurePromptWrapper$1 = (command, prompt) => {
31378
+ return [
31379
+ `gtd_validate_status=0`,
31380
+ `gtd_validate_out="$( {`,
31381
+ command,
31382
+ `} 2>&1 )" || gtd_validate_status=$?`,
31383
+ `if [ "$gtd_validate_status" -ne 0 ]; then`,
31384
+ ` printf '%s\\n\\n%s\\n' ${shellQuote$1(prompt)} "$gtd_validate_out"`,
31385
+ ` exit "$gtd_validate_status"`,
31386
+ `fi`
31387
+ ].join("\n");
31388
+ };
31389
+ const renderLandStep = (step) => {
31390
+ if (step.kind === "gitWrite") return renderGitWrite(step.write);
31391
+ if (step.kind === "outcome") return renderOutcome(step.outcome);
31392
+ if (step.kind === "uncheck") return `gtd uncheck ${shellQuote$1(step.file)}`;
31393
+ return step.onFailure !== void 0 ? failurePromptWrapper$1(step.command, step.onFailure) : step.command;
31394
+ };
31395
+ const ScriptSurface = { render: (steps) => {
31396
+ const rendered = steps.map(renderLandStep);
31397
+ return rendered.length === 0 ? "" : ["set -eu", ...rendered].join("\n\n");
31398
+ } };
31374
31399
  //#endregion
31375
- //#region src/edge/Edge.ts
31376
- init_dist$1();
31377
- init_steering();
31378
- const EMPTY_TREE$1 = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
31379
- const ACTORS = /* @__PURE__ */ new Set([
31380
- "agent",
31381
- "human",
31382
- "check",
31383
- "judge"
31384
- ]);
31400
+ //#region src/emit/Emit.ts
31401
+ /** 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`. */
31402
+ const fileExistsGuard = (file) => `[ -f ${shellQuote$1(file)} ] || exit 0`;
31403
+ /** A mode or `ui.format` command finds the file it works on in `$GTD_FILE`. */
31404
+ const withFileVar$1 = (command, file) => `export GTD_FILE=${shellQuote$1(file)}\n${command}`;
31405
+ const SIMPLE_LEADING_WORD_RE = /^[A-Za-z0-9_./-]+(?=[ \t]|$)/;
31406
+ const SHELL_METACHARACTER_RE = /[|&;$<>(){}`]/;
31407
+ const FILE_VAR_RE = /"\$GTD_FILE"|"\$\{GTD_FILE\}"|\$GTD_FILE\b|\$\{GTD_FILE\}/g;
31408
+ /** `command` with `file` written in for `$GTD_FILE` — one line an agent can copy, equivalent to running it under `withFileVar`. */
31409
+ const commandForFile = (command, file) => command.replace(FILE_VAR_RE, shellQuote$1(file));
31385
31410
  /**
31386
- * Where the episode HEAD belongs to begins, read off subjects alone. Walking
31387
- * back from HEAD: a bare `gtd(human): <step>` commit with no `Gtd-Step` is what
31388
- * `gtd start` writes — no landing ever produces one — and opens an
31389
- * episode as its first commit (its `Gtd-Workflow` trailer names the workflow;
31390
- * a legacy `--entry` opening has none); a commit that is not a gtd step commit, or one
31391
- * entering the flow's first step (a finished episode), bounds the episode
31392
- * from below.
31411
+ * The leading word of a rendered `format:`/`validate:` command, when the
31412
+ * command has exactly one unambiguous binary to probe — `undefined` for
31413
+ * anything a shell metacharacter could hide a second command behind. A missing
31414
+ * guard degrades to a raw exit; a wrong guard would refuse a working command.
31393
31415
  */
31394
- const locateEpisode = (def, history) => {
31395
- for (let i = history.length - 1; i >= 0; i--) {
31396
- const message = parseCommitMessage(history[i].message);
31397
- const subject = message.parsed;
31398
- if (subject !== void 0 && message.step === void 0 && subject.from === void 0 && subject.actor === "human") return {
31399
- workflow: message.workflow,
31400
- legacyOpening: message.workflow === void 0,
31401
- baseIndex: i,
31402
- processStart: i
31403
- };
31404
- if (subject === void 0 || !ACTORS.has(subject.actor) || subject.to === def.initial) return {
31405
- workflow: void 0,
31406
- legacyOpening: false,
31407
- baseIndex: i,
31408
- processStart: i + 1
31409
- };
31410
- }
31411
- return {
31412
- workflow: void 0,
31413
- legacyOpening: false,
31414
- baseIndex: -1,
31415
- processStart: 0
31416
- };
31417
- };
31418
- const headTurnOf = (head) => {
31419
- if (head === void 0) return void 0;
31420
- const message = parseCommitMessage(head.message);
31421
- const subject = message.parsed;
31422
- if (subject === void 0 || !ACTORS.has(subject.actor)) return void 0;
31423
- return {
31424
- state: subject.to,
31425
- actor: subject.actor,
31426
- empty: head.touched.length === 0,
31427
- step: message.step !== void 0
31428
- };
31429
- };
31430
- const traceEntryOf = (hash, message) => ({
31431
- state: message.parsed?.to ?? "",
31432
- hash,
31433
- actor: message.parsed?.actor ?? ""
31434
- });
31435
- const costEntriesOf = (hash, message) => message.cost.map((c) => ({
31436
- cost: c.cost,
31437
- model: c.model ?? "unspecified",
31438
- hash
31439
- }));
31440
- const hashAt = (history, index) => history[index]?.hash;
31441
- const historyFormatFault = (history, location) => formatFault(history.slice(location.baseIndex + 1).concat(history[location.baseIndex]?.message.startsWith("gtd") ? [history[location.baseIndex]] : []).map((c) => ({
31442
- hash: c.hash,
31443
- format: parseCommitMessage(c.message).format
31444
- })));
31445
- /** Fails on a history in a format this gtd does not read, before anything acts on it. */
31446
- const runOf = (history, location, closingHash = void 0) => {
31447
- const fault = historyFormatFault(history, location);
31448
- return fault === void 0 ? succeed$1(buildRun(history, location, closingHash)) : fail$8(new Error(fault));
31449
- };
31450
- const pinnedConfigOf = (history) => {
31451
- for (let i = history.length - 1; i >= 0; i--) {
31452
- const files = parseCommitMessage(history[i].message).config;
31453
- if (Object.keys(files).length > 0) return {
31454
- commit: history[i].hash,
31455
- files
31456
- };
31457
- }
31416
+ const extractLeadingBinary = (command) => {
31417
+ if (command.includes("\n")) return void 0;
31418
+ if (SHELL_METACHARACTER_RE.test(command.replace(FILE_VAR_RE, ""))) return void 0;
31419
+ const match = SIMPLE_LEADING_WORD_RE.exec(command);
31420
+ return match === null ? void 0 : match[0];
31458
31421
  };
31459
- const approvalEntryOf = (hash, message) => message.approved === void 0 ? [] : [{
31460
- step: formatStepId(message.approved.step),
31461
- name: message.approved.name,
31462
- email: message.approved.email,
31463
- commit: hash
31464
- }];
31465
- const buildRun = (history, location, closingHash) => {
31466
- const processCommits = history.slice(location.processStart);
31467
- const parsed = processCommits.map((c) => parseCommitMessage(c.message));
31468
- const first = parsed[0];
31469
- const startParentHash = hashAt(history, location.processStart - 1) ?? EMPTY_TREE$1;
31470
- const head = history[history.length - 1];
31471
- return {
31472
- workflow: location.workflow,
31473
- legacyOpening: location.legacyOpening,
31474
- startHash: hashAt(history, location.processStart) ?? head?.hash ?? EMPTY_TREE$1,
31475
- startParentHash,
31476
- diffBase: first?.reviewBase ?? startParentHash,
31477
- trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i])),
31478
- costEntries: parsed.flatMap((m, i) => costEntriesOf(processCommits[i].hash, m)),
31479
- judgeVerdicts: parsed.flatMap((m) => m.judge),
31480
- marks: parsed.flatMap((m) => m.marks),
31481
- approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i])),
31482
- pinnedVars: { ...first?.vars },
31483
- pinnedConfig: pinnedConfigOf(history.slice(Math.max(location.baseIndex, 0))),
31484
- headTurn: headTurnOf(head),
31485
- closingHash,
31486
- episode: {
31487
- base: hashAt(history, location.baseIndex),
31488
- commits: history.slice(location.baseIndex + 1)
31489
- }
31490
- };
31422
+ /**
31423
+ * Names a typo'd or uninstalled binary and the `$PATH` it was looked up on.
31424
+ * `$PATH` is a shell word the DRIVER's shell expands at run time, so gtd never
31425
+ * resolves it in-process. Exits 127, matching bash's "command not found".
31426
+ */
31427
+ const binaryGuard = (binary, mode, key) => {
31428
+ const messageQ = shellQuote$1(`gtd: mode "${mode}": "${key}" command not found: ${binary}`);
31429
+ return [
31430
+ `command -v ${shellQuote$1(binary)} >/dev/null 2>&1 || {`,
31431
+ ` printf '%s\\n%s\\n' ${messageQ} '$PATH: '"$PATH"`,
31432
+ ` exit 127`,
31433
+ `}`
31434
+ ].join("\n");
31491
31435
  };
31492
- const PAGE_TIERS = [
31493
- 32,
31494
- 128,
31495
- 512,
31496
- Infinity
31497
- ];
31498
31436
  /**
31499
- * `locateEpisode`'s own boundary test, subject-only: a commit that isn't a
31500
- * gtd step commit, or one landing the flow's initial state, bounds an
31501
- * episode from below — EXCEPT a bare `gtd(human): <x>` subject (no `→`),
31502
- * which is NEVER a boundary here, whatever `<x>` is. That bare shape is
31503
- * `locateEpisode`'s own `opening` branch (a started episode's first commit),
31504
- * which takes priority there over the initial-state check no matter what the
31505
- * workflow's first step is named — including one named after `def.initial`
31506
- * itself, where the two branches would otherwise disagree. A bare human commit can
31507
- * also be a self-loop step landing rather than a real opening (this
31508
- * subject-only pass can't read the `Gtd-Step` trailer that would tell them
31509
- * apart), so ruling it out here can only make `findBoundaryBase` walk a FEW
31510
- * commits further back than strictly needed — never stop short of the
31511
- * boundary `locateEpisode` would find once it has full commit bodies in
31512
- * hand. That asymmetry is the only direction this approximation may err in;
31513
- * it must never call a real boundary "not yet found".
31437
+ * Prints `prompt` plus the captured output on a non-zero exit, then propagates
31438
+ * it. The `{ … }` group inlines a multi-line `command` verbatim; the
31439
+ * assignment sits left of `||` so `set -e` never trips on the failure itself.
31514
31440
  */
31515
- const subjectIsBoundary = (def, subject) => {
31516
- const parsed = parseCommitMessage(subject).parsed;
31517
- if (parsed === void 0 || !ACTORS.has(parsed.actor)) return true;
31518
- if (parsed.actor === "human" && parsed.from === void 0) return false;
31519
- return parsed.to === def.initial;
31441
+ const failurePromptWrapper = (command, prompt) => {
31442
+ return [
31443
+ `gtd_validate_status=0`,
31444
+ `gtd_validate_out="$( {`,
31445
+ command,
31446
+ `} 2>&1 )" || gtd_validate_status=$?`,
31447
+ `if [ "$gtd_validate_status" -ne 0 ]; then`,
31448
+ ` printf '%s\\n\\n%s\\n' ${shellQuote$1(prompt)} "$gtd_validate_out"`,
31449
+ ` exit "$gtd_validate_status"`,
31450
+ `fi`
31451
+ ].join("\n");
31520
31452
  };
31521
- /** The last page index (walking newest→oldest, same direction as `locateEpisode`) whose subject is a boundary, or `undefined` if none in `page`. */
31522
- const boundaryIndexIn = (def, page) => {
31523
- for (let i = page.length - 1; i >= 0; i--) if (subjectIsBoundary(def, page[i].subject)) return i;
31453
+ const renderStep = (step) => {
31454
+ if (step.kind === "command" && step.onFailure !== void 0) return failurePromptWrapper(step.command, step.onFailure);
31455
+ return step.command;
31524
31456
  };
31457
+ const assembleScript = (steps) => steps.length === 0 ? "" : ["set -eu", ...steps.map(renderStep)].join("\n\n");
31458
+ /** 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. */
31459
+ const emitScripts = (required = [], optional = []) => ({
31460
+ required: assembleScript(required),
31461
+ optional: assembleScript(optional)
31462
+ });
31463
+ /** Exported so `src/twins/EmittedScriptRecognizer.ts` can recognize these by exact string comparison. `printf`, not `echo`, avoids shell-specific backslash-escape quirks. */
31464
+ const DID_NOT_RUN_COMMENT = "# gtd emitted this and did NOT run it — run it with `sh` to apply it";
31465
+ const PRESENTATION_ONLY_COMMENT = "# presentation only — safe to skip";
31466
+ const PRESENTATION_FAILURE_WARNING = "printf 'gtd: presentation-only follow-up failed — continuing\\n' >&2";
31525
31467
  /**
31526
- * `findBoundaryBase`'s result: `base` is the hash to pass `commitHistory` so
31527
- * a `git.commitHistory(base, head)` call reads exactly the episode ending at
31528
- * `head`, plus whatever a subject-only read couldn't rule out — never the
31529
- * whole repository. `base` is deliberately the boundary commit's PARENT, not
31530
- * the boundary commit itself: `commitHistory`'s range excludes `base`, and
31531
- * `runOf` needs the boundary commit present in the read history
31532
- * (`episode.base`, `startParentHash`). `base: undefined` means "read from the
31533
- * repository root" — an empty repository, an unresolvable `head`, or an
31534
- * episode that reaches the root commit, none of which cost anything extra
31535
- * since that IS the whole history in those cases.
31536
- *
31537
- * `head` is the CONCRETE hash the literal `head` argument resolved to on this
31538
- * call's first read — `undefined` only when it never resolved (an empty
31539
- * repository, or an unresolvable ref). The caller must hand this hash, not
31540
- * the original `head` argument, to its own `commitHistory` call: resolving a
31541
- * symbolic ref (literal `HEAD`, most callers' default) a second time risks
31542
- * reading a DIFFERENT commit than this function paged through, if something
31543
- * moves HEAD in between — pinning both reads to one resolved hash is what
31544
- * keeps the two-read split from disagreeing with itself.
31468
+ * A write command's single pasteable script (`gtd abandon | sh`,
31469
+ * `gtd land --json=script`'s own field). `optional`, when non-empty, is
31470
+ * wrapped in a subshell whose failure is swallowed — presentation-only, so it
31471
+ * must never turn a landed turn into a non-zero exit.
31545
31472
  */
31546
- const findBoundaryBase = (git, def, head) => gen(function* () {
31547
- let pinnedHead = head;
31548
- for (const pageSize of PAGE_TIERS) {
31549
- const page = yield* git.subjectHistory(pageSize, 0, pinnedHead);
31550
- if (pinnedHead === void 0) pinnedHead = page[page.length - 1]?.hash;
31551
- const wholeHistory = !Number.isFinite(pageSize) || page.length < pageSize;
31552
- const boundaryIdx = boundaryIndexIn(def, page);
31553
- if (boundaryIdx === void 0 || boundaryIdx === 0) {
31554
- if (wholeHistory) return {
31555
- base: void 0,
31556
- head: pinnedHead
31557
- };
31558
- continue;
31559
- }
31560
- return {
31561
- base: page[boundaryIdx - 1].hash,
31562
- head: pinnedHead
31563
- };
31564
- }
31565
- return {
31566
- base: void 0,
31567
- head: pinnedHead
31568
- };
31569
- });
31570
- const historyUpTo = (git, def, head) => flatMap(findBoundaryBase(git, def, head), ({ base, head: resolvedHead }) => git.commitHistory(base, resolvedHead));
31571
- const computeProcessRun = (git, def, head) => flatMap(historyUpTo(git, def, head), (history) => runOf(history, locateEpisode(def, history)));
31572
- const changedSinceStart = (pinned, current, workspace) => {
31573
- if (pinned === void 0) return [];
31574
- return changedConfigFiles(pinned.files, current).filter((path) => {
31575
- const committed = workspace.readCommittedSync(path, pinned.commit);
31576
- return current[path] === void 0 || committed === void 0 || contentHash(committed) !== current[path];
31577
- });
31578
- };
31579
- const warnConfigChanges = (run, current, workspace) => gen(function* () {
31580
- const changed = changedSinceStart(run.pinnedConfig, current, workspace);
31581
- if (changed.length === 0) return;
31582
- 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`);
31583
- });
31584
- /** The run alone, never replaying — `gtd abandon` must work even when replay would refuse, short of a history it cannot read. */
31585
- const currentRun = gen(function* () {
31586
- const git = yield* GitService;
31587
- const config = yield* (yield* ConfigService).load;
31588
- return yield* computeProcessRun(git, config.workflow);
31589
- });
31590
- const runAtTip = (git, def, tip) => gen(function* () {
31591
- if (tip === void 0) {
31592
- const history = yield* historyUpTo(git, def, void 0);
31593
- return yield* runOf(history, locateEpisode(def, history));
31594
- }
31595
- if (!(parseCommitMessage(tip.subject).parsed?.to === def.initial)) {
31596
- const history = yield* historyUpTo(git, def, tip.hash);
31597
- return yield* runOf(history, locateEpisode(def, history));
31598
- }
31599
- const parentRef = `${tip.hash}~1`;
31600
- const before = yield* historyUpTo(git, def, parentRef);
31601
- const location = locateEpisode(def, before);
31602
- let closing = yield* git.commitHistory(parentRef, tip.hash);
31603
- if (closing.length === 0) closing = yield* git.commitHistory(void 0, tip.hash);
31604
- return yield* runOf([...before, ...closing], { ...location }, tip.hash);
31605
- });
31606
- const runAtHead = (git, def) => gen(function* () {
31607
- return yield* runAtTip(git, def, (yield* git.subjectHistory(1, 0))[0]);
31608
- });
31609
- /** The process HEAD closes or sits inside — `gtd summary`'s run. */
31610
- const summaryRun = gen(function* () {
31611
- const git = yield* GitService;
31612
- const def = (yield* (yield* ConfigService).load).workflow;
31613
- return yield* runAtHead(git, def);
31614
- });
31615
- const commitTree = (workspace, hash) => {
31616
- let entries;
31617
- const list = () => entries ??= workspace.treeSync(hash);
31618
- let sortedPaths;
31619
- const contents = /* @__PURE__ */ new Map();
31620
- return {
31621
- paths: () => sortedPaths ??= [...list().keys()].sort(),
31622
- read: (path) => {
31623
- if (!list().has(path)) return void 0;
31624
- if (!contents.has(path)) contents.set(path, workspace.readCommittedSync(path, hash));
31625
- return contents.get(path);
31626
- },
31627
- id: (path) => list().get(path)
31628
- };
31473
+ const combinedScript = (required, optional) => {
31474
+ if (required.length === 0) return "";
31475
+ if (optional.length === 0) return `${DID_NOT_RUN_COMMENT}\n\n${required}`;
31476
+ return [
31477
+ DID_NOT_RUN_COMMENT,
31478
+ "",
31479
+ required,
31480
+ "",
31481
+ PRESENTATION_ONLY_COMMENT,
31482
+ `(\n${optional}\n) || ${PRESENTATION_FAILURE_WARNING}`
31483
+ ].join("\n");
31629
31484
  };
31485
+ //#endregion
31486
+ //#region src/emit/OutcomeScript.ts
31630
31487
  /**
31631
- * The tree a landing would commit: the working tree, with any rewrite the
31632
- * landing script applies first. `entries` is `setup.pendingWorktree()` —
31633
- * already memoized — never a fresh `workspace.worktreeSync()` call; see that
31634
- * field's own comment for why the memo lives there and not on `Workspace`.
31488
+ * A `printf '<fmt>' <args...>` bash statement — `args` are already-valid bash
31489
+ * tokens (a `shellQuote`d literal, a `"$(git …)"` substitution), never
31490
+ * re-quoted here: a value reaches the script as a printf ARGUMENT, never
31491
+ * interpolated into the format itself, so a subject or state name containing
31492
+ * `%` can never be read as a conversion spec. Markers like `->` are arguments
31493
+ * too, keeping every format string free of a leading `-` that a shell's
31494
+ * `printf` could mistake for an option. A real newline in `fmt` is emitted as
31495
+ * the two-character `\n` escape `printf` itself expands, keeping every emitted
31496
+ * statement on one line.
31635
31497
  */
31636
- const pendingTree = (workspace, entries, rewrite) => {
31637
- const paths = [...entries.keys()];
31638
- const readWorktree = (path) => {
31639
- try {
31640
- return workspace.readSync(path);
31641
- } catch {
31642
- return;
31643
- }
31644
- };
31645
- return {
31646
- paths: () => paths,
31647
- read: (path) => {
31648
- if (!entries.has(path)) return void 0;
31649
- const content = readWorktree(path);
31650
- return content === void 0 || rewrite?.path !== path ? content : rewrite.apply(content);
31651
- },
31652
- id: (path) => rewrite?.path === path ? void 0 : entries.get(path)
31653
- };
31654
- };
31498
+ const printfLine = (fmt, args) => `${OUTCOME_MARKER}\nprintf ${shellQuote$1(fmt.replace(/\n/g, "\\n"))} ${args.join(" ")}`;
31655
31499
  /**
31656
- * The one resolver of both setting kinds. Process settings: the recorded
31657
- * (pinned) value wins per name, anything unrecorded resolves live — the
31658
- * fallback for a process started before pinning existed. Environment settings
31659
- * are always live. `startOverrides` are `--var` values, passed only while a
31660
- * process is being started.
31500
+ * Marks an emitted block as print-only. Load-bearing beyond documentation:
31501
+ * `src/twins/EmittedScriptRecognizer.ts` recognizes an outcome block by
31502
+ * this line, and a bare `printf` would be ambiguous with a workflow
31503
+ * `script:` command that happens to start the same way.
31661
31504
  */
31662
- const settingsFor = (workflow, pinnedVars, hostEnv, startOverrides = {}, startFlags) => ({
31663
- vars: {
31664
- ...resolveStartVars(workflow.vars, startFlags, startOverrides, hostEnv),
31665
- ...pinnedVars
31666
- },
31667
- env: resolveVars(workflow.env, {}, hostEnv)
31668
- });
31669
- /** The workflow a run replays on: its pinned one, else the default. */
31670
- const workflowOf = (config, run) => {
31671
- if (run.legacyOpening) return fail$8(/* @__PURE__ */ new Error("gtd: this process was opened by `gtd --entry`, which no longer exists — run `gtd abandon` to start over"));
31672
- if (run.workflow === void 0) return succeed$1(config.defaultWorkflow);
31673
- const named = config.workflowNamed(run.workflow);
31674
- if (named !== void 0) return succeed$1(named);
31675
- return fail$8(/* @__PURE__ */ new Error(`gtd: this process runs workflow "${run.workflow}", which gtd.config.ts no longer defines — run \`gtd abandon\` to start over`));
31505
+ const OUTCOME_MARKER = "# gtd: outcome (print-only)";
31506
+ const FMT_ABANDON_NOOP = "no gtd process is underway (resting at \"%s\") — nothing to abandon\n";
31507
+ 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";
31508
+ 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";
31509
+ /** `no gtd process is underway (resting at "<initial>") — nothing to abandon` — `gtd abandon`'s no-op plain-text line. */
31510
+ const abandonNoopText = (initial) => renderFormat(FMT_ABANDON_NOOP, initial);
31511
+ /** One already-rendered plain line. */
31512
+ const noteOutcome = (text) => printfLine("%s\n", [shellQuote$1(text)]);
31513
+ /**
31514
+ * Resolves the post-hoc short hash/subject from `head` (a commitish) in-script
31515
+ * — this line runs AFTER the reset that made `head` the new HEAD, so neither
31516
+ * value is knowable when gtd generates it.
31517
+ */
31518
+ const abandonedOutcome = (from, head, state) => printfLine(FMT_ABANDONED, [
31519
+ shellQuote$1(from),
31520
+ `"$(git rev-parse --short ${shellQuote$1(head)})"`,
31521
+ `"$(git log -1 --format=%s ${shellQuote$1(head)})"`,
31522
+ shellQuote$1(state)
31523
+ ]);
31524
+ /** `gtd abandon`'s no-op outcome — the same wording `abandonNoopText` renders. */
31525
+ const abandonNoopOutcome = (initial) => noteOutcome(abandonNoopText(initial));
31526
+ /** Resolves the post-hoc short hash/subject from `to` (a commitish) in-script, for the same reason `abandonedOutcome` does. */
31527
+ const restoredOutcome = (to, state) => printfLine(FMT_RESTORED, [
31528
+ `"$(git rev-parse --short ${shellQuote$1(to)})"`,
31529
+ `"$(git log -1 --format=%s ${shellQuote$1(to)})"`,
31530
+ shellQuote$1(state)
31531
+ ]);
31532
+ //#endregion
31533
+ //#region src/emit/ModeContradiction.ts
31534
+ /**
31535
+ * Written for two readers at once, since gtd validate's exit code carries no
31536
+ * second meaning to distinguish them: the agent (this is a CONFIGURATION BUG,
31537
+ * not the steering file — stop, don't edit it, or a fix-retry loop will
31538
+ * thrash on the document) and the human (which mode, and the exact rendered
31539
+ * `format:` command, to fix `gtd.config.ts` without reproducing the failure).
31540
+ */
31541
+ 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:`;
31542
+ /**
31543
+ * Formats a copy of the mode's own canonical sample and re-validates it with
31544
+ * `gtd check <mode>`, failing loudly when the round-trip breaks it — detects a
31545
+ * mode whose `format:` contradicts its own `validate:` without ever touching
31546
+ * the real steering file. `printf '%s'` over a `shellQuote`d sample, never a
31547
+ * heredoc, since no delimiter chosen ahead of time can collide with the
31548
+ * sample's own bytes. One bash fragment with no blank lines (so
31549
+ * `src/emit/Emit.ts`'s `assembleScript` never splits it from a sibling step), meant
31550
+ * to run BEFORE `[ -f <file> ] || exit 0` in the emitted validate script so it
31551
+ * still fires at a first-write beat where the real steering file doesn't
31552
+ * exist yet.
31553
+ */
31554
+ const buildModeContradictionCheck = (inputs) => {
31555
+ const { mode, samplePath, sample, formatCommand } = inputs;
31556
+ const pathQ = shellQuote$1(samplePath);
31557
+ const messageQ = shellQuote$1(contradictionMessage(mode, formatCommand));
31558
+ return [
31559
+ `printf '%s' ${shellQuote$1(sample)} > ${pathQ}`,
31560
+ formatCommand,
31561
+ `gtd check ${mode} ${pathQ} >/dev/null 2>&1 || {`,
31562
+ ` printf '%s\\n' ${messageQ} >&2`,
31563
+ ` cat ${pathQ} >&2`,
31564
+ ` rm -f ${pathQ}`,
31565
+ ` exit 1`,
31566
+ `}`,
31567
+ `rm -f ${pathQ}`
31568
+ ].join("\n");
31676
31569
  };
31677
31570
  /**
31678
- * `judgeBudgetBytes` is the one var that refuses rather than disabling its
31679
- * mechanism when blanked: without a bound the judge payload is rejected
31680
- * anyway. Absent falls back to a conservative default.
31571
+ * Skip notice for a mode whose validator is EXTERNAL (a user `validate:`
31572
+ * command, not one of the two built-in modes) — there's no in-process parser
31573
+ * to round-trip a sample through. Printed to stderr since silence would read
31574
+ * as a clean bill of health.
31681
31575
  */
31682
- const DEFAULT_JUDGE_BUDGET_BYTES = 32768;
31683
- const judgeBudgetBytes = (vars) => {
31684
- const raw = vars.judgeBudgetBytes;
31685
- if (raw === void 0) return DEFAULT_JUDGE_BUDGET_BYTES;
31686
- const n = Number(raw);
31687
- 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)`);
31688
- return n;
31689
- };
31690
- const replayFor = (setup, pending) => replay({
31691
- flow: setup.def.flow,
31692
- episode: {
31693
- base: setup.base,
31694
- commits: setup.episode
31695
- },
31696
- vars: setup.vars,
31697
- env: setup.env,
31698
- start: setup.run.diffBase,
31699
- startTree: setup.run.diffBase === EMPTY_TREE$1 ? treeFromRecord({}) : commitTree(setup.workspace, setup.run.diffBase),
31700
- budgetBytes: setup.budget,
31701
- skills: setup.skills,
31702
- configuredSkills: setup.def.configuredSkills,
31703
- access: setup.access,
31704
- configuredAccess: setup.def.configuredAccess,
31705
- ...pending !== void 0 ? { pending } : {}
31706
- });
31707
- const replayError = (outcome) => {
31708
- if (outcome.kind === "divergence" || outcome.kind === "failed") return new Error(outcome.message);
31709
- if (outcome.kind === "refused") return /* @__PURE__ */ new Error(`gtd: ${outcome.message}`);
31710
- 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");
31576
+ const modeContradictionSkipNotice = (mode) => {
31577
+ return `printf '%s\\n' ${shellQuote$1(`gtd: mode "${mode}" has an external validate: command — skipping the format/validate contradiction check`)} >&2`;
31711
31578
  };
31712
- const judgeDocument = (step) => step.request.kind === "judge" ? judgeJson(step.request.evidence, step.request.questions) : void 0;
31579
+ //#endregion
31580
+ //#region src/emit/SteeringMode.ts
31581
+ init_dist$1();
31582
+ init_steering();
31713
31583
  /**
31714
- * The fixed sentence appended to a judge gate's message when a bounded read
31715
- * dropped bytes to fit `judgeBudgetBytes` — a constant, so no workflow can
31716
- * reword or drop it.
31584
+ * `format` alone (no live/external verdict) when there's no validator at
31585
+ * all; a live in-process validator for a `"builtin"` validate OR a command
31586
+ * that is exactly the format's own seeded string; `externalValidate` for any
31587
+ * other command. Each arm returns directly — a flat sequence, not nested
31588
+ * ternaries — so the four outcomes stay each their own case to read, not a
31589
+ * boolean expression to re-derive.
31717
31590
  */
31718
- const TRUNCATION_NOTICE = "Note: some evidence above was truncated to fit the judge's payload budget.";
31719
- 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.";
31720
- const optional = (key, value) => value === void 0 ? {} : { [key]: value };
31721
- const commonOf = (step) => {
31722
- const options = step.request.options;
31723
- return {
31724
- actor: step.actor,
31725
- ...optional("label", options.label),
31726
- ...optional("file", options.file),
31727
- ...optional("mode", options.mode)
31728
- };
31729
- };
31730
- const promptDef = (common, request, skills, access) => ({
31731
- ...common,
31732
- kind: "prompt",
31733
- content: request.prompt,
31734
- ...optional("model", request.options.model),
31735
- ...optional("system", request.options.system),
31736
- ...optional("allowEmpty", request.options.allowEmpty),
31737
- ...optional("keep", request.options.keep),
31738
- ...optional("skills", skills),
31739
- ...optional("access", access)
31740
- });
31741
- const scriptDef = (common, request) => ({
31742
- ...common,
31743
- kind: "script",
31744
- content: request.body
31745
- });
31746
- const judgeDef = (common, request, step) => {
31747
- const message = request.options.message ?? DEFAULT_JUDGE_MESSAGE;
31748
- return {
31749
- ...common,
31750
- kind: "message",
31751
- content: step.truncated ? `${message}\n\n${TRUNCATION_NOTICE}` : message,
31752
- ...optional("judge", judgeDocument(step))
31591
+ const capabilitiesFor = (mode, format, validate) => {
31592
+ if (format === void 0) return validate?.kind === "command" ? { externalValidate: true } : {};
31593
+ if (validate?.kind === "builtin") return {
31594
+ format,
31595
+ liveValidate: format.validate
31753
31596
  };
31754
- };
31755
- const messageDef = (common, request, step) => ({
31756
- ...common,
31757
- kind: "message",
31758
- content: request.options.message ?? request.options.label ?? step.name,
31759
- ...optional("acceptClean", request.options.approve === true || request.options.acceptClean),
31760
- ...optional("approve", request.options.approve)
31761
- });
31762
- const stepDefOf = (step) => {
31763
- const request = step.request;
31764
- const common = commonOf(step);
31765
- switch (request.kind) {
31766
- case "agent": return promptDef(common, request, step.skills, step.access);
31767
- case "run": return scriptDef(common, request);
31768
- case "judge": return judgeDef(common, request, step);
31769
- case "human": return messageDef(common, request, step);
31770
- }
31771
- };
31772
- const ROOT_MEMORY_SCOPE_NAME = "root";
31773
- /**
31774
- * An agent rest's memory key, `<scope>#<hash7>`: the hash is the commit the
31775
- * current unbroken run into the scope started FROM, so the same run always
31776
- * re-derives the same key. `resumed` is whether an agent turn in that run
31777
- * already landed.
31778
- */
31779
- const memoryOf = (trace, run) => {
31780
- const rest = trace[trace.length - 1];
31781
- if (rest === void 0 || rest.kind !== "agent") return {
31782
- key: void 0,
31783
- resumed: false
31597
+ if (validate === void 0) return { format };
31598
+ if (isSeededValidateCommand(mode, validate.command)) return {
31599
+ format,
31600
+ liveValidate: format.validate
31784
31601
  };
31785
- const start = scopeRunStart(trace, rest.memoryScope);
31786
- const token = start <= 0 ? run.startParentHash : trace[start - 1].enteredAt;
31787
- const resumed = trace.slice(Math.max(start, 0), trace.length - 1).some((s) => s.kind === "agent" && s.memoryScope === rest.memoryScope);
31788
31602
  return {
31789
- key: `${rest.memoryScope || ROOT_MEMORY_SCOPE_NAME}#${token.slice(0, 7)}`,
31790
- resumed
31603
+ format,
31604
+ externalValidate: true
31791
31605
  };
31792
31606
  };
31793
- const hintsOf = (def) => ({
31794
- ...optional("model", def.model),
31795
- ...optional("label", def.label),
31796
- ...optional("file", def.file),
31797
- ...optional("judge", def.judge),
31798
- ...optional("system", def.system),
31799
- ...optional("mode", def.mode),
31800
- ...optional("skills", def.skills),
31801
- ...optional("access", def.access)
31802
- });
31803
- const normalizeStatus = (raw) => raw === "A" ? "A" : raw === "D" ? "D" : "M";
31804
- /** Per-model token totals, highest-cost first (ties broken by model name). */
31805
- const costByModel = (entries) => {
31806
- const byModel = /* @__PURE__ */ new Map();
31807
- for (const entry of entries) byModel.set(entry.model, (byModel.get(entry.model) ?? 0) + entry.cost);
31808
- return [...byModel.entries()].map(([model, cost]) => ({
31809
- model,
31810
- cost
31811
- })).sort((a, b) => b.cost - a.cost || a.model.localeCompare(b.model));
31812
- };
31813
- const costTotals = (entries) => ({
31814
- total: entries.reduce((sum, entry) => sum + entry.cost, 0),
31815
- byModel: costByModel(entries)
31816
- });
31817
- const setupFor = (config, run, workspace, host, startOverrides = {}) => gen(function* () {
31818
- const resolved = yield* workflowOf(config, run);
31819
- const def = resolved.def;
31820
- const startFlags = run.trace.length === 0 ? yield* StartFlags : {};
31821
- const { vars, env } = settingsFor(resolved, run.pinnedVars, host.env, startOverrides, startFlags);
31822
- const skills = yield* resolveScopeSkills(def, vars);
31823
- const access = yield* resolveScopeAccess(def, skills, vars);
31824
- const budget = yield* try_({
31825
- try: () => judgeBudgetBytes(vars),
31826
- catch: (e) => e instanceof Error ? e : new Error(String(e))
31827
- });
31828
- yield* workspace.episodeTrees(run.episode.base, run.episode.commits.map((c) => c.hash));
31829
- const base = run.episode.base === void 0 ? {
31830
- hash: EMPTY_TREE$1,
31831
- tree: treeFromRecord({})
31832
- } : {
31833
- hash: run.episode.base,
31834
- tree: commitTree(workspace, run.episode.base)
31835
- };
31836
- const git = yield* GitService;
31837
- const episode = [];
31838
- for (const c of run.episode.commits) {
31839
- const tip = parseCommitMessage(c.message).kept;
31840
- const exists = tip !== void 0 && isSome(yield* git.readRefOption(`${tip}^{commit}`));
31841
- episode.push({
31842
- hash: c.hash,
31843
- message: c.message,
31844
- tree: commitTree(workspace, c.hash),
31845
- ...exists ? { kept: {
31846
- hash: tip,
31847
- tree: commitTree(workspace, tip)
31848
- } } : {}
31849
- });
31850
- }
31851
- let pendingMemo;
31852
- return {
31853
- resolved,
31854
- def,
31855
- vars,
31856
- env,
31857
- setup: {
31858
- def,
31859
- run,
31860
- vars,
31861
- env,
31862
- budget,
31863
- skills,
31864
- access,
31865
- workspace,
31866
- base,
31867
- episode,
31868
- pendingWorktree: () => pendingMemo ??= workspace.worktreeSync()
31869
- }
31870
- };
31871
- });
31872
- const pendingChanges = (git, withIndex) => gen(function* () {
31873
- const worktree = yield* git.changedPaths();
31874
- if (!withIndex) return worktree;
31875
- const seen = new Set(worktree.map((e) => e.path));
31876
- return [...worktree, ...(yield* git.stagedPaths()).filter((e) => !seen.has(e.path))];
31877
- });
31878
- /** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
31879
- const restAt = (ref, startOverrides = {}) => gen(function* () {
31880
- const git = yield* GitService;
31881
- const config = yield* (yield* ConfigService).load;
31882
- const workspace = yield* Workspace;
31883
- const host = yield* Host;
31884
- const run = yield* computeProcessRun(git, config.workflow, ref);
31885
- const { resolved, def, vars, env, setup } = yield* setupFor(config, run, workspace, host, startOverrides);
31886
- const outcome = yield* promise(() => replayFor(setup));
31887
- const error = replayError(outcome);
31888
- if (error !== void 0 || outcome.kind !== "rest") return yield* fail$8(error ?? /* @__PURE__ */ new Error("gtd: replay found no rest"));
31889
- const step = outcome.rest;
31890
- yield* (yield* Narrator).narrate(`rest resolved: ${step.name} (awaits ${step.actor})`);
31891
- const stepDef = stepDefOf(step);
31892
- if (stepDef.mode !== void 0 && def.modes[stepDef.mode] === void 0) return yield* fail$8(/* @__PURE__ */ new Error(`gtd: config: step "${step.name}": mode "${stepDef.mode}" is not a mode this workflow knows (${knownModes(def).join(", ")})`));
31893
- const changes = ref === void 0 ? (yield* pendingChanges(git, stepDef.approve === true)).map((e) => ({
31894
- status: normalizeStatus(e.status),
31895
- path: e.path
31896
- })) : [];
31897
- const memory = memoryOf(outcome.trace, run);
31898
- const configNow = config.fingerprint;
31899
- if (ref === void 0) yield* warnConfigChanges(run, configNow, workspace);
31607
+ /**
31608
+ * Resolve a `mode:` name against `def.modes` plus the built-in registry
31609
+ * (`src/workflow/SteeringFormats.ts`) — half by half: a declared `format:`/`validate:`
31610
+ * wins, and an undeclared `validate:` falls back to the built-in format's own
31611
+ * parser when the name is registered. `format` is set from the registry
31612
+ * ALONE, independent of which half of `validate` wins — a declared validator
31613
+ * overrides validation, not the format identity.
31614
+ *
31615
+ * `def: undefined` is the LSP's built-in-only fallback — resolving `mode`
31616
+ * against the registry alone, with no workflow definition in hand (e.g. the
31617
+ * basename dispatch for a `REVIEW.md` no config maps).
31618
+ *
31619
+ * The `"unknown"` arm carries the whole refusal message, naming what IS known
31620
+ * when `def` is given — this is the one place that message is built, so it
31621
+ * can never drift from what actually resolved.
31622
+ */
31623
+ const resolveMode = (def, state, mode) => {
31624
+ const declared = def?.modes?.[mode];
31625
+ const format = steeringFormatFor(mode);
31626
+ if (declared === void 0 && format === void 0) return {
31627
+ kind: "unknown",
31628
+ 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`
31629
+ };
31630
+ const validate = declared?.validate !== void 0 ? {
31631
+ kind: "command",
31632
+ command: declared.validate
31633
+ } : format !== void 0 ? {
31634
+ kind: "builtin",
31635
+ format
31636
+ } : void 0;
31900
31637
  return {
31901
- def,
31902
- workflowName: resolved.name,
31903
- state: step.name,
31904
- stepDef,
31905
- actor: step.actor,
31906
- step,
31907
- trace: outcome.trace,
31908
- run,
31909
- vars,
31910
- env,
31911
- config: configNow,
31912
- changes,
31913
- memory: memory.key,
31914
- memoryResumed: memory.resumed,
31915
- hints: hintsOf(stepDef),
31916
- cost: costTotals(run.costEntries),
31917
- setup
31638
+ kind: "resolved",
31639
+ mode,
31640
+ ...format !== void 0 ? { format } : {},
31641
+ ...declared?.format !== void 0 ? { formatCommand: declared.format } : {},
31642
+ ...validate !== void 0 ? { validate } : {},
31643
+ capabilities: capabilitiesFor(mode, format, validate)
31918
31644
  };
31645
+ };
31646
+ /** `gtd next`'s fix-retry instruction, printed ahead of the failing command's captured output. */
31647
+ const fixPromptInstruction = (file) => `Your last turn does not pass its own validation script. Fix these format violations in ${file}, then finish:`;
31648
+ /**
31649
+ * Pushes a `binaryGuard` step ahead of `command` when its own leading word
31650
+ * is unambiguous — BEFORE, not wrapped around: a guard that fails must stop
31651
+ * the script on its own `exit 127`, without touching `failurePromptWrapper`'s
31652
+ * captured-output path. Each command guards on its OWN leading word — a
31653
+ * guard naming the wrong binary is worse than none.
31654
+ */
31655
+ const pushGuardedCommand = (steps, mode, key, command, file, onFailure) => {
31656
+ const binary = extractLeadingBinary(command);
31657
+ if (binary !== void 0) steps.push({
31658
+ kind: "command",
31659
+ command: binaryGuard(binary, mode, key)
31660
+ });
31661
+ steps.push({
31662
+ kind: "command",
31663
+ command: withFileVar$1(command, file),
31664
+ ...onFailure !== void 0 ? { onFailure } : {}
31665
+ });
31666
+ };
31667
+ /**
31668
+ * `resolved`'s `format:`/`validate:` as `EmitStep[]` — format first,
31669
+ * validate last, the last one wrapped with `onFailure` when the validator is
31670
+ * a command (a built-in's own in-process parser has no shell step to wrap:
31671
+ * `gtd check`'s seeded command carries that instead). Each command gets its
31672
+ * own `binaryGuard` immediately ahead of it.
31673
+ */
31674
+ const modeCommandSteps = (resolved, file) => {
31675
+ const steps = [];
31676
+ if (resolved.formatCommand !== void 0) pushGuardedCommand(steps, resolved.mode, "format", resolved.formatCommand, file, void 0);
31677
+ if (resolved.validate?.kind === "command") pushGuardedCommand(steps, resolved.mode, "validate", resolved.validate.command, file, fixPromptInstruction(file));
31678
+ return steps;
31679
+ };
31680
+ /** `<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). */
31681
+ const scratchSamplePath = (scratchDir, mode) => join(scratchDir, `gtd-mode-sample-${mode}-${process.pid}.md`);
31682
+ /**
31683
+ * The contradiction round-trip/skip-notice steps for `resolved`'s `mode:`.
31684
+ * No `formatCommand` means nothing to round-trip: empty. Otherwise: a live
31685
+ * built-in validator runs the round-trip against that format's own canonical
31686
+ * sample; an external validator prints a one-line skip notice instead, since
31687
+ * silence there would read as a clean bill of health; a format-only mode with
31688
+ * neither has nothing to round-trip either.
31689
+ */
31690
+ const modeContradictionSteps = (resolved) => gen(function* () {
31691
+ if (resolved.formatCommand === void 0) return [];
31692
+ const { capabilities } = resolved;
31693
+ if (capabilities.format !== void 0 && capabilities.liveValidate !== void 0) {
31694
+ const { scratchDir } = yield* Host;
31695
+ const samplePath = scratchSamplePath(scratchDir, resolved.mode);
31696
+ const formatCommand = withFileVar$1(resolved.formatCommand, samplePath);
31697
+ return [{
31698
+ kind: "command",
31699
+ command: buildModeContradictionCheck({
31700
+ mode: resolved.mode,
31701
+ samplePath,
31702
+ sample: capabilities.format.sample,
31703
+ formatCommand
31704
+ })
31705
+ }];
31706
+ }
31707
+ if (capabilities.externalValidate === true) return [{
31708
+ kind: "command",
31709
+ command: modeContradictionSkipNotice(resolved.mode)
31710
+ }];
31711
+ return [];
31919
31712
  });
31920
- const currentRest = restAt(void 0);
31921
31713
  /**
31922
- * Whether `gtd start <name>` can start a process here: the workflow must
31923
- * reach a step from the tree the opening commit would capture. That step is
31924
- * what the opening commit's subject names.
31714
+ * Renders `resolved`'s complete validate script for `file`: the mode's
31715
+ * format/validate contradiction check, a guard for a not-yet-written file,
31716
+ * then the format/validate commands themselves (format first). gtd never
31717
+ * runs this script itself — `resolveMode`+`validateScriptFor` only render
31718
+ * text for a driver to execute (`gtd renders; the driver executes`).
31925
31719
  */
31926
- const startRefusal = (rest, resolved, startVars) => gen(function* () {
31927
- const host = yield* Host;
31928
- const { vars, env } = settingsFor(resolved, {}, host.env, startVars, yield* StartFlags);
31929
- const skills = yield* resolveScopeSkills(resolved.def, vars);
31930
- const access = yield* resolveScopeAccess(resolved.def, skills, vars);
31931
- const workspace = rest.setup.workspace;
31932
- const outcome = yield* promise(() => replay({
31933
- flow: resolved.def.flow,
31934
- episode: {
31935
- base: {
31936
- hash: "",
31937
- tree: pendingTree(workspace, rest.setup.pendingWorktree(), void 0)
31938
- },
31939
- commits: []
31720
+ const validateScriptFor = (resolved, file) => gen(function* () {
31721
+ return { script: emitScripts([
31722
+ ...yield* modeContradictionSteps(resolved),
31723
+ {
31724
+ kind: "command",
31725
+ command: fileExistsGuard(file)
31940
31726
  },
31941
- vars,
31942
- env,
31943
- start: "",
31944
- budgetBytes: rest.setup.budget,
31945
- skills,
31946
- configuredSkills: resolved.def.configuredSkills,
31947
- access,
31948
- configuredAccess: resolved.def.configuredAccess
31949
- }));
31950
- if (outcome.kind === "rest") return { firstStep: outcome.rest.name };
31951
- if (outcome.kind === "refused") return { refusal: outcome.message };
31952
- const why = outcome.kind === "ended" ? "the flow reaches no step" : outcome.message;
31953
- return { refusal: `workflow "${resolved.name}" cannot start — ${why}` };
31954
- });
31955
- /** The review window's diff base at the rest. */
31956
- const reviewBaseFor = (rest) => rest.step.request.options.base ?? rest.run.diffBase;
31957
- const renderRest = (rest) => succeed$1({
31958
- state: rest.state,
31959
- actor: rest.actor,
31960
- kind: rest.stepDef.kind,
31961
- content: rest.stepDef.content,
31962
- ...rest.hints,
31963
- ...rest.memory !== void 0 ? { memory: rest.memory } : {},
31964
- memoryResumed: rest.memoryResumed
31727
+ ...modeCommandSteps(resolved, file)
31728
+ ]).required };
31965
31729
  });
31730
+ //#endregion
31731
+ //#region src/step/planStep.ts
31966
31732
  /**
31967
- * Derived, restart-proof stall detection: the tree is clean, HEAD is an empty
31968
- * attempt at this very agent rest, and another dispatch would repeat it.
31733
+ * At the human review gate an unconditional `gtd uncheck '<file>'` runs
31734
+ * ahead of the commit, resetting every checkbox before `git add -A` picks it
31735
+ * up — a tick is read-progress, never sign-off.
31969
31736
  */
31970
- 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;
31971
- /** 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. */
31972
- const noProcessUnderway = (rest) => rest.run.workflow === void 0 && rest.state === rest.def.initial && rest.step.id.occurrence === 1;
31973
- /** `idle` means exactly one thing: no process underway, clean tree. */
31974
- const restIsIdle = (rest) => noProcessUnderway(rest) && rest.changes.length === 0;
31975
- const isHumanReviewGate = (def) => def.actor === "human" && def.mode === "review";
31976
- const approvalRefusal = (rest, approver) => {
31977
- if (rest.stepDef.approve !== true) return void 0;
31978
- const id = rest.step.id;
31979
- if (rest.changes.length > 0) return {
31980
- kind: "refusal",
31981
- 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\``
31737
+ const uncheckStep = (snapshot) => snapshot.stepDef.actor === "human" && snapshot.stepDef.mode === "review" && snapshot.file !== void 0 ? [{
31738
+ kind: "uncheck",
31739
+ file: snapshot.file
31740
+ }] : [];
31741
+ const gitWriteOf = (landing, message) => {
31742
+ if (landing.only !== void 0) return {
31743
+ kind: "commitPath",
31744
+ path: landing.only,
31745
+ message
31982
31746
  };
31983
- if (isNone(approver)) return {
31984
- kind: "refusal",
31985
- message: `gtd land: approval-gate: ${formatStepId(id)} records who approved it, but git has no author identity — set user.name and user.email`
31747
+ if (landing.keepTurn !== void 0) return {
31748
+ kind: "keepTurn",
31749
+ ref: landing.keepTurn,
31750
+ message
31986
31751
  };
31987
- if (!isRecordableApproval({
31988
- step: id,
31989
- ...approver.value
31990
- })) return {
31991
- kind: "refusal",
31992
- 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)`
31752
+ if (landing.spec.approved !== void 0) return {
31753
+ kind: "commitClean",
31754
+ message
31755
+ };
31756
+ return {
31757
+ kind: "commitAll",
31758
+ message
31993
31759
  };
31994
31760
  };
31995
- /** The landing a clean tree decides before any replay, if it decides one. */
31996
- const cleanLanding = (rest) => {
31997
- if (rest.changes.length > 0) return void 0;
31998
- const def = rest.stepDef;
31999
- if (def.kind === "prompt" && def.allowEmpty !== true) return {
32000
- kind: "attempt",
32001
- subject: formatSubject(rest.actor, rest.state)
31761
+ const planStep = (snapshot, opts = {}) => {
31762
+ const landing = snapshot.landing;
31763
+ if (landing.kind === "refusal") return {
31764
+ kind: "refusal",
31765
+ message: landing.message
32002
31766
  };
32003
- if (rest.actor === "human" && def.acceptClean !== true) return {
31767
+ if (landing.kind === "noop") return {
32004
31768
  kind: "noop",
32005
- settled: false
31769
+ state: snapshot.state,
31770
+ settled: landing.settled
31771
+ };
31772
+ const cost = opts.cost === void 0 ? {} : { cost: {
31773
+ cost: opts.cost,
31774
+ ...opts.model !== void 0 ? { model: opts.model } : {}
31775
+ } };
31776
+ if (landing.kind === "attempt") {
31777
+ const message = formatCommitMessage({
31778
+ actor: snapshot.actor,
31779
+ to: snapshot.state,
31780
+ ...cost
31781
+ });
31782
+ return {
31783
+ kind: "commit",
31784
+ state: snapshot.state,
31785
+ to: snapshot.state,
31786
+ subject: landing.subject,
31787
+ steps: [{
31788
+ kind: "gitWrite",
31789
+ write: {
31790
+ kind: "commitAll",
31791
+ message
31792
+ }
31793
+ }, {
31794
+ kind: "outcome",
31795
+ outcome: {
31796
+ kind: "commit",
31797
+ subject: landing.subject
31798
+ }
31799
+ }]
31800
+ };
31801
+ }
31802
+ const message = formatCommitMessage({
31803
+ ...landing.spec,
31804
+ ...cost,
31805
+ ...opts.judge !== void 0 && opts.judge.length > 0 ? { judge: opts.judge } : {}
31806
+ });
31807
+ const subject = message.split("\n")[0];
31808
+ const outcome = landing.to === snapshot.state ? {
31809
+ kind: "outcome",
31810
+ outcome: {
31811
+ kind: "commit",
31812
+ subject
31813
+ }
31814
+ } : {
31815
+ kind: "outcome",
31816
+ outcome: {
31817
+ kind: "transition",
31818
+ from: snapshot.state,
31819
+ to: landing.to
31820
+ }
32006
31821
  };
32007
- };
32008
- /** At the human review gate the pending tree lands with every tick cleared, as the landing script will. */
32009
- const reviewGateRewrite = (def) => {
32010
- const reviewFormat = steeringFormatFor("review");
32011
- if (!isHumanReviewGate(def) || def.file === void 0 || reviewFormat === void 0) return;
32012
31822
  return {
32013
- path: def.file,
32014
- apply: (content) => reviewFormat.clearTicks(content)
32015
- };
32016
- };
32017
- const multilineRefusal = (vars) => {
32018
- const multiline = multilineSetting(vars);
32019
- return multiline === void 0 ? void 0 : {
32020
- kind: "refusal",
32021
- message: `gtd: process setting "${multiline}" spans several lines — a process setting is pinned in a commit trailer and must be a single line`
31823
+ kind: "commit",
31824
+ state: snapshot.state,
31825
+ to: landing.to,
31826
+ subject,
31827
+ steps: [
31828
+ ...uncheckStep(snapshot),
31829
+ {
31830
+ kind: "gitWrite",
31831
+ write: gitWriteOf(landing, message)
31832
+ },
31833
+ ...landing.keepHistory !== void 0 ? [{
31834
+ kind: "gitWrite",
31835
+ write: {
31836
+ kind: "updateRef",
31837
+ ref: landing.keepHistory,
31838
+ target: "HEAD"
31839
+ }
31840
+ }] : [],
31841
+ outcome
31842
+ ]
32022
31843
  };
32023
31844
  };
32024
- const landingTarget = (outcome, rest) => {
32025
- if (outcome.kind === "refused") return succeed$1({
32026
- kind: "refusal",
32027
- message: outcome.message
32028
- });
32029
- if (outcome.kind === "divergence" || outcome.kind === "failed") return fail$8(new Error(outcome.message));
32030
- if (outcome.kind !== "rest") return succeed$1(rest.def.initial);
32031
- const reserved = initialStepRefusal(rest.run.workflow, outcome.rest.name, rest.def.initial);
32032
- return succeed$1(reserved === void 0 ? outcome.rest.name : reserved);
32033
- };
31845
+ //#endregion
31846
+ //#region src/step/accessRefusal.ts
32034
31847
  /**
32035
- * A step commit entering the default's first step is what ends an episode, so
32036
- * a named workflow landing there would read back as finished. Its opening
32037
- * commit may name that step: a bare opening is never read as a boundary.
31848
+ * The landing refusal for changes outside `write`, or `undefined` when every
31849
+ * path is allowed. `null` is unrestricted. The message is the fix prompt: gtd
31850
+ * runs no fix turn of its own.
32038
31851
  */
32039
- const initialStepRefusal = (workflow, step, initial) => workflow !== void 0 && step === initial ? {
31852
+ const accessRefusal = (changes, write) => {
31853
+ if (write === null) return void 0;
31854
+ const offending = [...new Set(changes.filter((c) => !write.some((glob) => globMatches(c.path, glob))).map((c) => c.path))];
31855
+ if (offending.length === 0) return void 0;
31856
+ return [
31857
+ ACCESS_REFUSAL,
31858
+ ...offending.map((path) => ` - ${path}`),
31859
+ `allowed: ${write.length === 0 ? "(nothing)" : write.join(", ")}`
31860
+ ].join("\n");
31861
+ };
31862
+ //#endregion
31863
+ //#region src/step/planStart.ts
31864
+ init_dist$1();
31865
+ const refusal = (message) => ({
32040
31866
  kind: "refusal",
32041
- 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`
32042
- } : void 0;
32043
- const closingTrailers = (branch) => ({
32044
- version: GTD_VERSION,
32045
- ...isSome(branch) ? { branch: branch.value } : {}
31867
+ message
32046
31868
  });
32047
- const isSettledNoop = (rest, to) => rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state;
32048
- const keptTurn = (rest) => rest.stepDef.keep === true && rest.changes.length > 0 ? { keepTurn: keptRef(rest.run.startHash, rest.state) } : {};
32049
- const approvedTrailer = (rest, approver) => isSome(approver) ? { approved: {
32050
- step: rest.step.id,
32051
- ...approver.value
32052
- } } : {};
32053
- const landingTo = (rest, to, branch, approver, marks) => {
32054
- if (isSettledNoop(rest, to)) return {
32055
- kind: "noop",
32056
- settled: true
32057
- };
32058
- const underway = !noProcessUnderway(rest);
32059
- const starts = !underway && to !== rest.def.initial;
32060
- const refused = starts ? multilineRefusal(rest.vars) : void 0;
32061
- if (refused !== void 0) return refused;
32062
- const finishes = underway && to === rest.def.initial;
31869
+ const checkOverrides = (commandLabel, varOverrides, declaredNames, envNames) => gen(function* () {
31870
+ const environmentOnly = Object.keys(varOverrides).filter((n) => !declaredNames.includes(n) && envNames.includes(n));
31871
+ if (environmentOnly.length > 0) return yield* fail$8(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`));
31872
+ const undeclared = Object.keys(varOverrides).filter((n) => !declaredNames.includes(n));
31873
+ if (undeclared.length > 0) return yield* fail$8(new GtdUsageError(`${commandLabel}: --var name(s) not declared by this workflow: ${undeclared.join(", ")} — declared: ${declaredNames.length > 0 ? declaredNames.join(", ") : "(none)"}`));
31874
+ });
31875
+ /**
31876
+ * `gtd start <name>`: start a brand NEW process on a workflow, writing an
31877
+ * opening commit that names the workflow's first step and carries a
31878
+ * `Gtd-Workflow:` trailer, every process setting as a `Gtd-Var:` trailer, plus
31879
+ * a `Gtd-Review-Base:` trailer when the workflow fixes the process's diff base,
31880
+ * and a `Gtd-Config:` trailer per config file.
31881
+ * All validation is a REFUSAL, not an Effect failure.
31882
+ */
31883
+ const planStart = (current, actor, start) => gen(function* () {
31884
+ const { workflow, first, commandLabel, vars: varOverrides } = start;
31885
+ const name = workflow.name;
31886
+ if (!current.idle) return refusal(`${commandLabel}: a process is already underway (resting at "${current.state}") — finish it, or run \`gtd abandon\`, before starting another`);
31887
+ if ("refusal" in first) return refusal(`${commandLabel}: ${first.refusal}`);
31888
+ const { firstStep } = first;
31889
+ yield* checkOverrides(commandLabel, varOverrides, Object.keys(workflow.vars), Object.keys(workflow.env));
31890
+ const baseOf = workflow.def.base;
31891
+ const flags = yield* StartFlags;
31892
+ const hostEnv = (yield* Host).env;
31893
+ const badFlag = ownFlagRefusal(workflow.vars, flags, hostEnv);
31894
+ if (badFlag !== void 0) return refusal(`${commandLabel}: ${badFlag}`);
31895
+ const vars = resolveStartVars(workflow.vars, flags, varOverrides, hostEnv);
31896
+ const multiline = multilineSetting(vars);
31897
+ 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`);
31898
+ const template = yield* try_({
31899
+ try: () => baseOf?.(name, vars),
31900
+ catch: (e) => /* @__PURE__ */ new Error(`${commandLabel}: ${e instanceof Error ? e.message : String(e)}`)
31901
+ });
31902
+ const base = template === void 0 ? void 0 : yield* resolveBase(commandLabel, template);
31903
+ if (typeof base === "object") return base;
31904
+ const subject = formatSubject(actor, firstStep);
32063
31905
  return {
32064
- kind: "commit",
32065
- to,
32066
- spec: {
32067
- actor: rest.actor,
32068
- from: rest.state,
32069
- to,
32070
- step: rest.step.id,
32071
- ...marks.length > 0 && { marks },
32072
- ...approvedTrailer(rest, approver),
32073
- ...startTrailers(rest, starts),
32074
- ...finishes ? closingTrailers(branch) : {}
32075
- },
32076
- ...finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {},
32077
- ...keptTurn(rest)
31906
+ kind: "start",
31907
+ workflow: name,
31908
+ subject,
31909
+ steps: [{
31910
+ kind: "gitWrite",
31911
+ write: {
31912
+ kind: "commitAll",
31913
+ message: formatCommitMessage({
31914
+ actor,
31915
+ to: firstStep,
31916
+ workflow: name,
31917
+ ...base !== void 0 ? { reviewBase: base } : {},
31918
+ vars,
31919
+ config: start.config
31920
+ })
31921
+ }
31922
+ }, {
31923
+ kind: "outcome",
31924
+ outcome: {
31925
+ kind: "commit",
31926
+ subject
31927
+ }
31928
+ }]
32078
31929
  };
32079
- };
32080
- const startTrailers = (rest, starts) => starts ? {
32081
- vars: rest.vars,
32082
- config: rest.config
32083
- } : {};
32084
- const outcomeMarks = (outcome) => outcome.kind === "rest" || outcome.kind === "ended" ? outcome.marks : [];
31930
+ });
32085
31931
  /**
32086
- * What landing the pending turn at `rest` does — a pure decision from a
32087
- * replay with the working tree as the pending turn. The target step is
32088
- * computed by replaying, never matched.
31932
+ * The workflow's diff base: `git merge-base <base> HEAD`, where a blank base
31933
+ * means the default branch. The merge-base is what gets pinned, so a base on
31934
+ * another branch reviews exactly this branch's own commits.
32089
31935
  */
32090
- const decideLanding = (rest, verdicts, branch, approver) => gen(function* () {
32091
- const unapproved = approvalRefusal(rest, approver);
32092
- if (unapproved !== void 0) return unapproved;
32093
- const early = cleanLanding(rest);
32094
- if (early !== void 0) return early;
32095
- if (rest.stepDef.kind === "prompt" && rest.stepDef.access?.write != null) {
32096
- const message = accessRefusal(rest.changes, rest.stepDef.access.write);
32097
- if (message !== void 0) return {
32098
- kind: "refusal",
32099
- message
31936
+ const resolveBase = (commandLabel, value) => gen(function* () {
31937
+ const git = yield* GitService;
31938
+ const rendered = value.trim();
31939
+ const target = rendered === "" ? yield* git.defaultBranch() : rendered;
31940
+ const resolvedBase = yield* result(git.resolveRef(target));
31941
+ if (resolvedBase._tag === "Failure") return refusal(`${commandLabel}: "${target}" does not resolve to a commit`);
31942
+ const mergeBase = yield* git.mergeBase(resolvedBase.success, "HEAD");
31943
+ if (isNone(mergeBase)) return refusal(`${commandLabel}: "${target}" shares no common ancestor with HEAD`);
31944
+ if (mergeBase.value === (yield* git.resolveRef("HEAD"))) return refusal(`${commandLabel}: nothing to review: HEAD has no commits beyond ${target}`);
31945
+ return mergeBase.value;
31946
+ });
31947
+ //#endregion
31948
+ //#region src/step/strayPaths.ts
31949
+ /** Globs use the `access.write` dialect (`globMatches`). */
31950
+ const strayPaths = (touched, allow) => touched.filter((path) => !allow.some((glob) => globMatches(path, glob))).sort();
31951
+ //#endregion
31952
+ //#region src/edge/Edge.ts
31953
+ init_dist$1();
31954
+ init_steering();
31955
+ const EMPTY_TREE$1 = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
31956
+ const ACTORS = /* @__PURE__ */ new Set([
31957
+ "agent",
31958
+ "human",
31959
+ "check",
31960
+ "judge"
31961
+ ]);
31962
+ const isOpening = ({ parsed, step }) => parsed !== void 0 && step === void 0 && parsed.from === void 0 && parsed.actor === "human";
31963
+ const boundsEpisode = (def, { parsed, pin }) => pin !== "add" && (parsed === void 0 || !ACTORS.has(parsed.actor) || parsed.to === def.initial);
31964
+ /**
31965
+ * Where the episode HEAD belongs to begins, read off subjects alone. Walking
31966
+ * back from HEAD: a bare `gtd(human): <step>` commit with no `Gtd-Step` is what
31967
+ * `gtd start` writes — no landing ever produces one — and opens an
31968
+ * episode as its first commit (its `Gtd-Workflow` trailer names the workflow;
31969
+ * a legacy `--entry` opening has none); a commit that is not a gtd step commit, or one
31970
+ * entering the flow's first step (a finished episode), bounds the episode
31971
+ * from below.
31972
+ */
31973
+ const locateEpisode = (def, history) => {
31974
+ for (let i = history.length - 1; i >= 0; i--) {
31975
+ const message = parseCommitMessage(history[i].message);
31976
+ if (isOpening(message)) return {
31977
+ workflow: message.workflow,
31978
+ legacyOpening: message.workflow === void 0,
31979
+ baseIndex: i,
31980
+ processStart: i
31981
+ };
31982
+ if (boundsEpisode(def, message)) return {
31983
+ workflow: void 0,
31984
+ legacyOpening: false,
31985
+ baseIndex: i,
31986
+ processStart: i + 1
32100
31987
  };
32101
31988
  }
32102
- const outcome = yield* promise(() => replayFor(rest.setup, {
32103
- tree: pendingTree(rest.setup.workspace, rest.setup.pendingWorktree(), reviewGateRewrite(rest.stepDef)),
32104
- ...verdicts !== void 0 ? { verdicts } : {}
32105
- }));
32106
- const to = yield* landingTarget(outcome, rest);
32107
- if (typeof to !== "string") return to;
32108
- return landingTo(rest, to, branch, approver, outcomeMarks(outcome));
32109
- });
32110
- /** Where landing the pending turn would leave the process, or `undefined` when it would land nothing. */
32111
- const previewLanding = (rest) => decideLanding(rest, void 0, none(), none()).pipe(map((landing) => landing.kind === "commit" ? landing.to : void 0), catch_(() => succeed$1(void 0)));
32112
- /** Every fact the pure landing planner needs, read once: the rest, its steering file, and the landing decision. */
32113
- const snapshotFromRest = (rest, verdicts) => gen(function* () {
32114
- const git = yield* GitService;
32115
- const branch = yield* git.currentBranch();
32116
- const approver = rest.stepDef.approve === true ? yield* git.authorIdent() : none();
32117
- const landing = yield* decideLanding(rest, verdicts, branch, approver);
32118
31989
  return {
32119
- stepDef: rest.stepDef,
32120
- state: rest.state,
32121
- actor: rest.actor,
32122
- changes: rest.changes,
32123
- file: rest.hints.file,
32124
- landing
31990
+ workflow: void 0,
31991
+ legacyOpening: false,
31992
+ baseIndex: -1,
31993
+ processStart: 0
32125
31994
  };
32126
- });
32127
- const humanCommitsOf = (trace, entryCommit) => trace.filter((entry) => entry.actor === "human" && entry.hash !== entryCommit).map((entry) => ({
32128
- hash: entry.hash,
32129
- state: entry.state
32130
- }));
32131
- const summaryInput = (run, settings) => {
32132
- const entryCommit = run.trace[0].hash;
32133
- const { total, byModel } = costTotals(run.costEntries);
31995
+ };
31996
+ const headTurnOf = (head) => {
31997
+ if (head === void 0) return void 0;
31998
+ const message = parseCommitMessage(head.message);
31999
+ const subject = message.parsed;
32000
+ if (subject === void 0 || !ACTORS.has(subject.actor) || message.pin !== void 0) return void 0;
32134
32001
  return {
32135
- entryCommit,
32136
- processBase: run.startParentHash,
32137
- processTip: run.trace[run.trace.length - 1].hash,
32138
- humanCommits: humanCommitsOf(run.trace, entryCommit),
32139
- processCost: total,
32140
- processCostByModel: byModel,
32141
- marks: run.marks,
32142
- ...settings
32002
+ state: subject.to,
32003
+ actor: subject.actor,
32004
+ empty: head.touched.length === 0,
32005
+ step: message.step !== void 0
32143
32006
  };
32144
32007
  };
32145
- const summaryFor = (run) => gen(function* () {
32146
- const config = yield* (yield* ConfigService).load;
32147
- const host = yield* Host;
32148
- const resolved = yield* workflowOf(config, run);
32149
- const summary = resolved.def.summary;
32150
- if (summary === void 0 || run.trace.length === 0) return { prompt: void 0 };
32151
- return { prompt: summary(summaryInput(run, settingsFor(resolved, run.pinnedVars, host.env, {}, {}))) };
32008
+ const traceEntryOf = (hash, message) => ({
32009
+ state: message.parsed?.to ?? "",
32010
+ hash,
32011
+ actor: message.parsed?.actor ?? ""
32152
32012
  });
32153
- /** 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. */
32154
- const inRepoConfigPaths = (root, files, recorded) => {
32155
- const paths = new Set(Object.keys(recorded));
32156
- for (const file of files) paths.add(relative(root, resolve(root, file)).split(sep).join("/"));
32157
- return [...paths].filter((p) => p !== "" && !p.startsWith("..") && !isAbsolute(p)).sort();
32013
+ const costEntriesOf = (hash, message) => message.cost.map((c) => ({
32014
+ cost: c.cost,
32015
+ model: c.model ?? "unspecified",
32016
+ hash
32017
+ }));
32018
+ const hashAt = (history, index) => history[index]?.hash;
32019
+ const historyFormatFault = (history, location) => formatFault(history.slice(location.baseIndex + 1).concat(history[location.baseIndex]?.message.startsWith("gtd") ? [history[location.baseIndex]] : []).map((c) => ({
32020
+ hash: c.hash,
32021
+ format: parseCommitMessage(c.message).format
32022
+ })));
32023
+ /** Fails on a history in a format this gtd does not read, before anything acts on it. */
32024
+ const runOf = (history, location, closingHash = void 0) => {
32025
+ const fault = historyFormatFault(history, location);
32026
+ return fault === void 0 ? succeed$1(buildRun(history, location, closingHash)) : fail$8(new Error(fault));
32158
32027
  };
32159
- 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}`);
32160
- const differingConfigPaths = (git, workspace, root, config, run, hash) => gen(function* () {
32161
- const differing = [];
32162
- 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);
32163
- return differing;
32164
- });
32165
- 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");
32166
- const historySteps = (run) => {
32167
- const messages = new Map(run.episode.commits.map((c) => [c.hash, c.message]));
32168
- return run.trace.map((entry) => {
32169
- const message = parseCommitMessage(messages.get(entry.hash) ?? "");
32170
- return {
32171
- hash: entry.hash,
32172
- subject: (messages.get(entry.hash) ?? "").split("\n")[0] ?? "",
32173
- actor: entry.actor,
32174
- from: message.parsed?.from,
32175
- to: entry.state,
32176
- verdicts: message.judge,
32177
- ...optional("approval", approvalEntryOf(entry.hash, message)[0])
32028
+ const pinnedConfigOf = (history) => {
32029
+ for (let i = history.length - 1; i >= 0; i--) {
32030
+ const files = parseCommitMessage(history[i].message).config;
32031
+ if (Object.keys(files).length > 0) return {
32032
+ commit: history[i].hash,
32033
+ files
32178
32034
  };
32179
- });
32035
+ }
32180
32036
  };
32181
- 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`;
32182
- /**
32183
- * Replays the closed run at `tip` under today's config. The workflow is not
32184
- * fingerprinted for a version change: the engine under it moves with every
32185
- * upgrade, and the version warning is the only signal.
32186
- */
32187
- const historyTrail = (tip) => gen(function* () {
32188
- const git = yield* GitService;
32189
- const config = yield* (yield* ConfigService).load;
32190
- const hash = yield* git.resolveRef(tip);
32191
- const subject = yield* git.lastCommitSubject(hash);
32192
- const run = yield* runAtTip(git, config.workflow, {
32193
- hash,
32194
- subject
32195
- });
32196
- const { outcome, recorded, versionWarning } = yield* replayFinishedProcess(config, run, hash, {
32197
- command: "gtd history",
32198
- notEnded: (o) => notClosedError(hash, o)
32199
- });
32200
- return {
32201
- tip: hash,
32202
- steps: historySteps(run),
32203
- ended: outcome.via,
32204
- version: {
32205
- recorded,
32206
- running: GTD_VERSION
32207
- },
32208
- ...versionWarning !== void 0 ? { versionWarning } : {}
32209
- };
32210
- });
32211
- /** Refuses on config drift before replaying, so a stale config never runs the flow. */
32212
- const replayFinishedProcess = (config, run, hash, how) => gen(function* () {
32213
- const git = yield* GitService;
32214
- const workspace = yield* Workspace;
32215
- const host = yield* Host;
32216
- const recorded = parseCommitMessage(run.episode.commits.find((c) => c.hash === hash)?.message ?? "").version;
32217
- const versionWarning = versionWarningFor(recorded);
32218
- const withWarning = (err) => versionWarning === void 0 ? err : /* @__PURE__ */ new Error(`${err.message}\n${versionWarning}`);
32219
- const differing = yield* differingConfigPaths(git, workspace, host.root, config, run, hash);
32220
- if (differing.length > 0) return yield* fail$8(withWarning(configRefusal(how.command, hash, differing, how.configReason)));
32221
- const { setup } = yield* setupFor(config, run, workspace, host);
32222
- const outcome = yield* promise(() => replayFor(setup));
32223
- if (outcome.kind !== "ended") return yield* fail$8(withWarning(how.notEnded(outcome)));
32037
+ const approvalEntryOf = (hash, message) => message.approved === void 0 ? [] : [{
32038
+ step: formatStepId(message.approved.step),
32039
+ name: message.approved.name,
32040
+ email: message.approved.email,
32041
+ commit: hash
32042
+ }];
32043
+ const buildRun = (history, location, closingHash) => {
32044
+ const allProcessCommits = history.slice(location.processStart);
32045
+ const allParsed = allProcessCommits.map((c) => parseCommitMessage(c.message));
32046
+ const processCommits = allProcessCommits.filter((_, i) => allParsed[i].pin !== "add");
32047
+ const parsed = allParsed.filter((m) => m.pin !== "add");
32048
+ const first = parsed[0];
32049
+ const startParentHash = hashAt(history, location.processStart - 1) ?? EMPTY_TREE$1;
32050
+ const head = history[history.length - 1];
32224
32051
  return {
32225
- outcome,
32226
- recorded,
32227
- ...versionWarning !== void 0 ? { versionWarning } : {}
32052
+ workflow: location.workflow,
32053
+ legacyOpening: location.legacyOpening,
32054
+ startHash: hashAt(history, location.processStart) ?? head?.hash ?? EMPTY_TREE$1,
32055
+ startParentHash,
32056
+ diffBase: first?.reviewBase ?? startParentHash,
32057
+ trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i])),
32058
+ costEntries: parsed.flatMap((m, i) => costEntriesOf(processCommits[i].hash, m)),
32059
+ judgeVerdicts: parsed.flatMap((m) => m.judge),
32060
+ pinCommitted: allParsed.some((m) => m.pin === "add"),
32061
+ marks: parsed.flatMap((m) => m.marks),
32062
+ approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i])),
32063
+ pinnedVars: { ...first?.vars },
32064
+ pinnedConfig: pinnedConfigOf(history.slice(Math.max(location.baseIndex, 0))),
32065
+ headTurn: headTurnOf(head),
32066
+ closingHash,
32067
+ episode: {
32068
+ base: hashAt(history, location.baseIndex),
32069
+ commits: history.slice(location.baseIndex + 1)
32070
+ }
32228
32071
  };
32229
- });
32230
- //#endregion
32231
- //#region src/emit/GitScript.ts
32232
- const shellQuote$1 = (value) => `'${value.replace(/'/g, "'\\''")}'`;
32233
- /**
32234
- * `--allow-empty` because gtd's workflow commits may be empty on purpose.
32235
- * Mirrors `Git.ts`'s retry: only re-tries without hooks on the specific
32236
- * "empty git commit" rejection message, discriminated from the captured
32237
- * output rather than the exit code, so a genuinely rejected commit (a lint
32238
- * error, a commit-msg hook) still fails.
32239
- */
32240
- const commitAllowEmpty = (message) => {
32241
- const m = shellQuote$1(message);
32242
- return [
32243
- `if ! out=$(git commit --allow-empty -m ${m} 2>&1); then`,
32244
- ` case "$out" in`,
32245
- ` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m} || exit 1 ;;`,
32246
- ` *) printf '%s\\n' "$out" >&2; exit 1 ;;`,
32247
- ` esac`,
32248
- `fi`
32249
- ].join("\n");
32250
32072
  };
32251
- const CHANGED_SINCE_LAND = "gtd: the working tree changed since `gtd land` — revert the edits and run `gtd land` again to approve";
32252
- /** `git add -A || exit 1` (an `&&` left side would not abort under `set -e`) then `commitAllowEmpty`. */
32253
- const commitAll = (message) => `git add -A || exit 1\n${commitAllowEmpty(message)}`;
32254
- /**
32255
- * An approval records exactly the judged tree: HEAD is pinned before the
32256
- * clean check and the commit is built over its tree with `commit-tree`, so no
32257
- * hook runs and none can add to it. A HEAD that moved fails the guarded
32258
- * `update-ref`. `commit-tree` ignores `commit.gpgSign`, so it is read here.
32259
- */
32260
- const commitClean = (message) => [
32261
- `gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
32262
- `if [ -n "$(git status --porcelain --untracked-files=all --ignore-submodules=none)" ]; then`,
32263
- ` printf '%s\\n' ${shellQuote$1(CHANGED_SINCE_LAND)} >&2`,
32264
- ` exit 1`,
32265
- `fi`,
32266
- `gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
32267
- `if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
32268
- `gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m ${shellQuote$1(message)}) || exit 1`,
32269
- `git update-ref -m ${shellQuote$1(`commit: ${message}`)} HEAD "$gtd_tip" "$gtd_head" || exit 1`
32270
- ].join("\n");
32271
- const mixedResetTo = (ref) => `git reset --mixed ${shellQuote$1(ref)}`;
32272
- const hardResetTo = (ref) => `git reset --hard ${shellQuote$1(ref)}`;
32073
+ const PAGE_TIERS = [
32074
+ 32,
32075
+ 128,
32076
+ 512,
32077
+ Infinity
32078
+ ];
32273
32079
  /**
32274
- * POSIX sh (runs under dash). The marker is a `commit-tree` over HEAD's own
32275
- * tree, so no hook runs on it and its tree is the pre-turn tree by
32276
- * construction; `commit-tree` ignores `commit.gpgSign`, hence the explicit
32277
- * `-S`, read (and failing closed) before any object or ref is written; the
32278
- * index is already staged by then, so an early exit leaves the turn staged.
32279
- * The kept ref is deleted only after `reset -q` succeeds: it is then the
32280
- * turn's only handle.
32281
- * The branch moves before the final hard reset, so if that reset fails the
32282
- * marker has landed and the turn is still in the working tree (kept ref
32283
- * present); every other failure leaves the branch untouched.
32080
+ * `locateEpisode`'s own boundary test, subject-only: a commit that isn't a
32081
+ * gtd step commit, or one landing the flow's initial state, bounds an
32082
+ * episode from below — EXCEPT a bare `gtd(human): <x>` subject (no `→`),
32083
+ * which is NEVER a boundary here, whatever `<x>` is. That bare shape is
32084
+ * `locateEpisode`'s own `opening` branch (a started episode's first commit),
32085
+ * which takes priority there over the initial-state check no matter what the
32086
+ * workflow's first step is named — including one named after `def.initial`
32087
+ * itself, where the two branches would otherwise disagree. A bare human commit can
32088
+ * also be a self-loop step landing rather than a real opening (this
32089
+ * subject-only pass can't read the `Gtd-Step` trailer that would tell them
32090
+ * apart), so ruling it out here can only make `findBoundaryBase` walk a FEW
32091
+ * commits further back than strictly needed — never stop short of the
32092
+ * boundary `locateEpisode` would find once it has full commit bodies in
32093
+ * hand. That asymmetry is the only direction this approximation may err in;
32094
+ * it must never call a real boundary "not yet found".
32284
32095
  */
32285
- const keepTurn = (refPrefix, message) => {
32286
- const keptRef = `${shellQuote$1(`${refPrefix}/`)}"$gtd_kept"`;
32287
- return [
32288
- `git add -A || exit 1`,
32289
- `gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
32290
- `if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
32291
- `gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
32292
- `gtd_tree=$(git write-tree) || exit 1`,
32293
- `gtd_kept=$(git commit-tree "$gtd_tree" -p "$gtd_head" -m ${shellQuote$1(message)}) || exit 1`,
32294
- `git update-ref ${keptRef} "$gtd_kept" || exit 1`,
32295
- `gtd_marker=$(printf '%s\\nGtd-Kept: %s' ${shellQuote$1(message)} "$gtd_kept")`,
32296
- `if ! gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m "$gtd_marker") || ! git update-ref -m ${shellQuote$1(`commit: ${message}`)} HEAD "$gtd_tip" "$gtd_head"; then`,
32297
- ` git reset -q && git update-ref -d ${keptRef}`,
32298
- ` exit 1`,
32299
- `fi`,
32300
- `git reset --hard HEAD`
32301
- ].join("\n");
32096
+ const subjectIsBoundary = (def, subject) => {
32097
+ const parsed = parseCommitMessage(subject).parsed;
32098
+ if (parsed === void 0 || !ACTORS.has(parsed.actor)) return true;
32099
+ if (parsed.actor === "human" && parsed.from === void 0) return false;
32100
+ if (parsed.actor === "check" && parsed.from === PIN_STATES.add) return false;
32101
+ return parsed.to === def.initial;
32102
+ };
32103
+ /** The last page index (walking newest→oldest, same direction as `locateEpisode`) whose subject is a boundary, or `undefined` if none in `page`. */
32104
+ const boundaryIndexIn = (def, page) => {
32105
+ for (let i = page.length - 1; i >= 0; i--) if (subjectIsBoundary(def, page[i].subject)) return i;
32302
32106
  };
32303
- const updateRef = (ref, hash) => `git update-ref ${shellQuote$1(ref)} ${shellQuote$1(hash)}`;
32304
- /** `git update-ref -d <ref>` — idempotent: deleting a missing ref is already a no-op in real git. */
32305
- const deleteRef = (ref) => `git update-ref -d ${shellQuote$1(ref)}`;
32306
32107
  /**
32307
- * No `+` on the refspec: a local ref the remote's tip does not descend from
32308
- * fails the fetch instead of being overwritten (a fast-forward still applies).
32108
+ * `findBoundaryBase`'s result: `base` is the hash to pass `commitHistory` so
32109
+ * a `git.commitHistory(base, head)` call reads exactly the episode ending at
32110
+ * `head`, plus whatever a subject-only read couldn't rule out — never the
32111
+ * whole repository. `base` is deliberately the boundary commit's PARENT, not
32112
+ * the boundary commit itself: `commitHistory`'s range excludes `base`, and
32113
+ * `runOf` needs the boundary commit present in the read history
32114
+ * (`episode.base`, `startParentHash`). `base: undefined` means "read from the
32115
+ * repository root" — an empty repository, an unresolvable `head`, or an
32116
+ * episode that reaches the root commit, none of which cost anything extra
32117
+ * since that IS the whole history in those cases.
32118
+ *
32119
+ * `head` is the CONCRETE hash the literal `head` argument resolved to on this
32120
+ * call's first read — `undefined` only when it never resolved (an empty
32121
+ * repository, or an unresolvable ref). The caller must hand this hash, not
32122
+ * the original `head` argument, to its own `commitHistory` call: resolving a
32123
+ * symbolic ref (literal `HEAD`, most callers' default) a second time risks
32124
+ * reading a DIFFERENT commit than this function paged through, if something
32125
+ * moves HEAD in between — pinning both reads to one resolved hash is what
32126
+ * keeps the two-read split from disagreeing with itself.
32309
32127
  */
32310
- const fetchHistoryRefs = (remote) => [
32311
- `set -eu`,
32312
- `before=$(git for-each-ref --format='x' 'refs/gtd/history/' | wc -l)`,
32313
- `git fetch ${shellQuote$1(remote)} 'refs/gtd/history/*:refs/gtd/history/*'`,
32314
- `after=$(git for-each-ref --format='x' 'refs/gtd/history/' | wc -l)`,
32315
- `printf '%s history refs arrived\\n' "$((after - before))"`
32316
- ].join("\n");
32317
- /** 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. */
32318
- const OUTCOME_MARKER$1 = "# gtd: outcome (print-only)";
32319
- const printfLine$1 = (fmt, args) => `${OUTCOME_MARKER$1}\nprintf ${shellQuote$1(fmt.replace(/\n/g, "\\n"))} ${args.join(" ")}`;
32320
- const renderOutcome = (outcome) => {
32321
- switch (outcome.kind) {
32322
- case "transition": return printfLine$1("%s %s → %s\n", [
32323
- "'->'",
32324
- shellQuote$1(outcome.from),
32325
- shellQuote$1(outcome.to)
32326
- ]);
32327
- case "commit": return printfLine$1("%s %s\n", ["'[commit]'", shellQuote$1(outcome.subject)]);
32328
- case "note": return printfLine$1("%s\n", [shellQuote$1(outcome.text)]);
32128
+ const findBoundaryBase = (git, def, head) => gen(function* () {
32129
+ let pinnedHead = head;
32130
+ for (const pageSize of PAGE_TIERS) {
32131
+ const page = yield* git.subjectHistory(pageSize, 0, pinnedHead);
32132
+ if (pinnedHead === void 0) pinnedHead = page[page.length - 1]?.hash;
32133
+ const wholeHistory = !Number.isFinite(pageSize) || page.length < pageSize;
32134
+ const boundaryIdx = boundaryIndexIn(def, page);
32135
+ if (boundaryIdx === void 0 || boundaryIdx === 0) {
32136
+ if (wholeHistory) return {
32137
+ base: void 0,
32138
+ head: pinnedHead
32139
+ };
32140
+ continue;
32141
+ }
32142
+ return {
32143
+ base: page[boundaryIdx - 1].hash,
32144
+ head: pinnedHead
32145
+ };
32329
32146
  }
32147
+ return {
32148
+ base: void 0,
32149
+ head: pinnedHead
32150
+ };
32151
+ });
32152
+ const historyUpTo = (git, def, head) => flatMap(findBoundaryBase(git, def, head), ({ base, head: resolvedHead }) => git.commitHistory(base, resolvedHead));
32153
+ const computeProcessRun = (git, def, head) => flatMap(historyUpTo(git, def, head), (history) => runOf(history, locateEpisode(def, history)));
32154
+ const changedSinceStart = (pinned, current, workspace) => {
32155
+ if (pinned === void 0) return [];
32156
+ return changedConfigFiles(pinned.files, current).filter((path) => {
32157
+ const committed = workspace.readCommittedSync(path, pinned.commit);
32158
+ return current[path] === void 0 || committed === void 0 || contentHash(committed) !== current[path];
32159
+ });
32330
32160
  };
32331
- const renderGitWrite = (write) => {
32332
- switch (write.kind) {
32333
- case "commitAll": return commitAll(write.message);
32334
- case "commitClean": return commitClean(write.message);
32335
- case "updateRef": return updateRef(write.ref, write.target);
32336
- case "keepTurn": return keepTurn(write.ref, write.message);
32161
+ const warnConfigChanges = (run, current, workspace) => gen(function* () {
32162
+ const changed = changedSinceStart(run.pinnedConfig, current, workspace);
32163
+ if (changed.length === 0) return;
32164
+ 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`);
32165
+ });
32166
+ /** The run alone, never replaying — `gtd abandon` must work even when replay would refuse, short of a history it cannot read. */
32167
+ const currentRun = gen(function* () {
32168
+ const git = yield* GitService;
32169
+ const config = yield* (yield* ConfigService).load;
32170
+ return yield* computeProcessRun(git, config.workflow);
32171
+ });
32172
+ const runAtTip = (git, def, tip) => gen(function* () {
32173
+ if (tip === void 0) {
32174
+ const history = yield* historyUpTo(git, def, void 0);
32175
+ return yield* runOf(history, locateEpisode(def, history));
32176
+ }
32177
+ if (!(parseCommitMessage(tip.subject).parsed?.to === def.initial)) {
32178
+ const history = yield* historyUpTo(git, def, tip.hash);
32179
+ return yield* runOf(history, locateEpisode(def, history));
32337
32180
  }
32181
+ const parentRef = `${tip.hash}~1`;
32182
+ const before = yield* historyUpTo(git, def, parentRef);
32183
+ const location = locateEpisode(def, before);
32184
+ let closing = yield* git.commitHistory(parentRef, tip.hash);
32185
+ if (closing.length === 0) closing = yield* git.commitHistory(void 0, tip.hash);
32186
+ return yield* runOf([...before, ...closing], { ...location }, tip.hash);
32187
+ });
32188
+ const runAtHead = (git, def) => gen(function* () {
32189
+ return yield* runAtTip(git, def, (yield* git.subjectHistory(1, 0))[0]);
32190
+ });
32191
+ /** The process HEAD closes or sits inside — `gtd summary`'s run. */
32192
+ const summaryRun = gen(function* () {
32193
+ const git = yield* GitService;
32194
+ const def = (yield* (yield* ConfigService).load).workflow;
32195
+ return yield* runAtHead(git, def);
32196
+ });
32197
+ const commitTree = (workspace, hash) => {
32198
+ let entries;
32199
+ const list = () => entries ??= workspace.treeSync(hash);
32200
+ let sortedPaths;
32201
+ const contents = /* @__PURE__ */ new Map();
32202
+ return {
32203
+ paths: () => sortedPaths ??= [...list().keys()].sort(),
32204
+ read: (path) => {
32205
+ if (!list().has(path)) return void 0;
32206
+ if (!contents.has(path)) contents.set(path, workspace.readCommittedSync(path, hash));
32207
+ return contents.get(path);
32208
+ },
32209
+ id: (path) => list().get(path)
32210
+ };
32338
32211
  };
32339
32212
  /**
32340
- * Wraps `command` so a non-zero exit prints `prompt` plus the command's
32341
- * captured output before propagating that exit code — mirrors `Emit.ts`'s
32342
- * `failurePromptWrapper` (same reverse-import concern as `renderOutcome`
32343
- * above: `Emit.ts` imports `shellQuote` from here already).
32213
+ * The tree a landing would commit: the working tree, with any rewrite the
32214
+ * landing script applies first. `entries` is `setup.pendingWorktree()` —
32215
+ * already memoized — never a fresh `workspace.worktreeSync()` call; see that
32216
+ * field's own comment for why the memo lives there and not on `Workspace`.
32344
32217
  */
32345
- const failurePromptWrapper$1 = (command, prompt) => {
32346
- return [
32347
- `gtd_validate_status=0`,
32348
- `gtd_validate_out="$( {`,
32349
- command,
32350
- `} 2>&1 )" || gtd_validate_status=$?`,
32351
- `if [ "$gtd_validate_status" -ne 0 ]; then`,
32352
- ` printf '%s\\n\\n%s\\n' ${shellQuote$1(prompt)} "$gtd_validate_out"`,
32353
- ` exit "$gtd_validate_status"`,
32354
- `fi`
32355
- ].join("\n");
32356
- };
32357
- const renderLandStep = (step) => {
32358
- if (step.kind === "gitWrite") return renderGitWrite(step.write);
32359
- if (step.kind === "outcome") return renderOutcome(step.outcome);
32360
- if (step.kind === "uncheck") return `gtd uncheck ${shellQuote$1(step.file)}`;
32361
- return step.onFailure !== void 0 ? failurePromptWrapper$1(step.command, step.onFailure) : step.command;
32218
+ const pendingTree = (workspace, entries, rewrite) => {
32219
+ const paths = [...entries.keys()];
32220
+ const readWorktree = (path) => {
32221
+ try {
32222
+ return workspace.readSync(path);
32223
+ } catch {
32224
+ return;
32225
+ }
32226
+ };
32227
+ return {
32228
+ paths: () => paths,
32229
+ read: (path) => {
32230
+ if (!entries.has(path)) return void 0;
32231
+ const content = readWorktree(path);
32232
+ return content === void 0 || rewrite?.path !== path ? content : rewrite.apply(content);
32233
+ },
32234
+ id: (path) => rewrite?.path === path ? void 0 : entries.get(path)
32235
+ };
32362
32236
  };
32363
- const ScriptSurface = { render: (steps) => {
32364
- const rendered = steps.map(renderLandStep);
32365
- return rendered.length === 0 ? "" : ["set -eu", ...rendered].join("\n\n");
32366
- } };
32367
- //#endregion
32368
- //#region src/emit/Emit.ts
32369
- /** 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`. */
32370
- const fileExistsGuard = (file) => `[ -f ${shellQuote$1(file)} ] || exit 0`;
32371
- /** A mode or `ui.format` command finds the file it works on in `$GTD_FILE`. */
32372
- const withFileVar$1 = (command, file) => `export GTD_FILE=${shellQuote$1(file)}\n${command}`;
32373
- const SIMPLE_LEADING_WORD_RE = /^[A-Za-z0-9_./-]+(?=[ \t]|$)/;
32374
- const SHELL_METACHARACTER_RE = /[|&;$<>(){}`]/;
32375
- const FILE_VAR_RE = /"\$GTD_FILE"|"\$\{GTD_FILE\}"|\$GTD_FILE\b|\$\{GTD_FILE\}/g;
32376
- /** `command` with `file` written in for `$GTD_FILE` — one line an agent can copy, equivalent to running it under `withFileVar`. */
32377
- const commandForFile = (command, file) => command.replace(FILE_VAR_RE, shellQuote$1(file));
32378
32237
  /**
32379
- * The leading word of a rendered `format:`/`validate:` command, when the
32380
- * command has exactly one unambiguous binary to probe — `undefined` for
32381
- * anything a shell metacharacter could hide a second command behind. A missing
32382
- * guard degrades to a raw exit; a wrong guard would refuse a working command.
32238
+ * The one resolver of both setting kinds. Process settings: the recorded
32239
+ * (pinned) value wins per name, anything unrecorded resolves live — the
32240
+ * fallback for a process started before pinning existed. Environment settings
32241
+ * are always live. `startOverrides` are `--var` values, passed only while a
32242
+ * process is being started.
32383
32243
  */
32384
- const extractLeadingBinary = (command) => {
32385
- if (command.includes("\n")) return void 0;
32386
- if (SHELL_METACHARACTER_RE.test(command.replace(FILE_VAR_RE, ""))) return void 0;
32387
- const match = SIMPLE_LEADING_WORD_RE.exec(command);
32388
- return match === null ? void 0 : match[0];
32244
+ const settingsFor = (workflow, pinnedVars, hostEnv, startOverrides = {}, startFlags) => ({
32245
+ vars: {
32246
+ ...resolveStartVars(workflow.vars, startFlags, startOverrides, hostEnv),
32247
+ ...pinnedVars
32248
+ },
32249
+ env: resolveVars(workflow.env, {}, hostEnv)
32250
+ });
32251
+ /** The workflow a run replays on: its pinned one, else the default. */
32252
+ const workflowOf = (config, run) => {
32253
+ if (run.legacyOpening) return fail$8(/* @__PURE__ */ new Error("gtd: this process was opened by `gtd --entry`, which no longer exists — run `gtd abandon` to start over"));
32254
+ if (run.workflow === void 0) return succeed$1(config.defaultWorkflow);
32255
+ const named = config.workflowNamed(run.workflow);
32256
+ if (named !== void 0) return succeed$1(named);
32257
+ return fail$8(/* @__PURE__ */ new Error(`gtd: this process runs workflow "${run.workflow}", which gtd.config.ts no longer defines — run \`gtd abandon\` to start over`));
32389
32258
  };
32390
32259
  /**
32391
- * Names a typo'd or uninstalled binary and the `$PATH` it was looked up on.
32392
- * `$PATH` is a shell word the DRIVER's shell expands at run time, so gtd never
32393
- * resolves it in-process. Exits 127, matching bash's "command not found".
32260
+ * `judgeBudgetBytes` is the one var that refuses rather than disabling its
32261
+ * mechanism when blanked: without a bound the judge payload is rejected
32262
+ * anyway. Absent falls back to a conservative default.
32394
32263
  */
32395
- const binaryGuard = (binary, mode, key) => {
32396
- const messageQ = shellQuote$1(`gtd: mode "${mode}": "${key}" command not found: ${binary}`);
32397
- return [
32398
- `command -v ${shellQuote$1(binary)} >/dev/null 2>&1 || {`,
32399
- ` printf '%s\\n%s\\n' ${messageQ} '$PATH: '"$PATH"`,
32400
- ` exit 127`,
32401
- `}`
32402
- ].join("\n");
32264
+ const DEFAULT_JUDGE_BUDGET_BYTES = 32768;
32265
+ const judgeBudgetBytes = (vars) => {
32266
+ const raw = vars.judgeBudgetBytes;
32267
+ if (raw === void 0) return DEFAULT_JUDGE_BUDGET_BYTES;
32268
+ const n = Number(raw);
32269
+ 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)`);
32270
+ return n;
32271
+ };
32272
+ const replayFor = (setup, pending) => replay({
32273
+ flow: setup.def.flow,
32274
+ episode: {
32275
+ base: setup.base,
32276
+ commits: setup.episode
32277
+ },
32278
+ vars: setup.vars,
32279
+ env: setup.env,
32280
+ start: setup.run.diffBase,
32281
+ startTree: setup.run.diffBase === EMPTY_TREE$1 ? treeFromRecord({}) : commitTree(setup.workspace, setup.run.diffBase),
32282
+ budgetBytes: setup.budget,
32283
+ skills: setup.skills,
32284
+ configuredSkills: setup.def.configuredSkills,
32285
+ access: setup.access,
32286
+ configuredAccess: setup.def.configuredAccess,
32287
+ ...pending !== void 0 ? { pending } : {}
32288
+ });
32289
+ const replayError = (outcome) => {
32290
+ if (outcome.kind === "divergence" || outcome.kind === "failed") return new Error(outcome.message);
32291
+ if (outcome.kind === "refused") return /* @__PURE__ */ new Error(`gtd: ${outcome.message}`);
32292
+ 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");
32403
32293
  };
32294
+ const judgeDocument = (step) => step.request.kind === "judge" ? judgeJson(step.request.evidence, step.request.questions) : void 0;
32404
32295
  /**
32405
- * Prints `prompt` plus the captured output on a non-zero exit, then propagates
32406
- * it. The `{ … }` group inlines a multi-line `command` verbatim; the
32407
- * assignment sits left of `||` so `set -e` never trips on the failure itself.
32296
+ * The fixed sentence appended to a judge gate's message when a bounded read
32297
+ * dropped bytes to fit `judgeBudgetBytes` — a constant, so no workflow can
32298
+ * reword or drop it.
32408
32299
  */
32409
- const failurePromptWrapper = (command, prompt) => {
32410
- return [
32411
- `gtd_validate_status=0`,
32412
- `gtd_validate_out="$( {`,
32413
- command,
32414
- `} 2>&1 )" || gtd_validate_status=$?`,
32415
- `if [ "$gtd_validate_status" -ne 0 ]; then`,
32416
- ` printf '%s\\n\\n%s\\n' ${shellQuote$1(prompt)} "$gtd_validate_out"`,
32417
- ` exit "$gtd_validate_status"`,
32418
- `fi`
32419
- ].join("\n");
32300
+ const TRUNCATION_NOTICE = "Note: some evidence above was truncated to fit the judge's payload budget.";
32301
+ 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.";
32302
+ const optional = (key, value) => value === void 0 ? {} : { [key]: value };
32303
+ const commonOf = (step) => {
32304
+ const options = step.request.options;
32305
+ return {
32306
+ actor: step.actor,
32307
+ ...optional("label", options.label),
32308
+ ...optional("file", options.file),
32309
+ ...optional("mode", options.mode)
32310
+ };
32420
32311
  };
32421
- const renderStep = (step) => {
32422
- if (step.kind === "command" && step.onFailure !== void 0) return failurePromptWrapper(step.command, step.onFailure);
32423
- return step.command;
32312
+ const promptDef = (common, request, skills, access) => ({
32313
+ ...common,
32314
+ kind: "prompt",
32315
+ content: request.prompt,
32316
+ ...optional("model", request.options.model),
32317
+ ...optional("system", request.options.system),
32318
+ ...optional("allowEmpty", request.options.allowEmpty),
32319
+ ...optional("keep", request.options.keep),
32320
+ ...optional("skills", skills),
32321
+ ...optional("access", access)
32322
+ });
32323
+ const scriptDef = (common, request) => ({
32324
+ ...common,
32325
+ kind: "script",
32326
+ content: request.body
32327
+ });
32328
+ const judgeDef = (common, request, step) => {
32329
+ const message = request.options.message ?? DEFAULT_JUDGE_MESSAGE;
32330
+ return {
32331
+ ...common,
32332
+ kind: "message",
32333
+ content: step.truncated ? `${message}\n\n${TRUNCATION_NOTICE}` : message,
32334
+ ...optional("judge", judgeDocument(step))
32335
+ };
32424
32336
  };
32425
- const assembleScript = (steps) => steps.length === 0 ? "" : ["set -eu", ...steps.map(renderStep)].join("\n\n");
32426
- /** 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. */
32427
- const emitScripts = (required = [], optional = []) => ({
32428
- required: assembleScript(required),
32429
- optional: assembleScript(optional)
32337
+ const messageDef = (common, request, step) => ({
32338
+ ...common,
32339
+ kind: "message",
32340
+ content: request.options.message ?? request.options.label ?? step.name,
32341
+ ...optional("acceptClean", request.options.approve === true || request.options.acceptClean),
32342
+ ...optional("approve", request.options.approve)
32430
32343
  });
32431
- /** Exported so `src/twins/EmittedScriptRecognizer.ts` can recognize these by exact string comparison. `printf`, not `echo`, avoids shell-specific backslash-escape quirks. */
32432
- const DID_NOT_RUN_COMMENT = "# gtd emitted this and did NOT run it — run it with `sh` to apply it";
32433
- const PRESENTATION_ONLY_COMMENT = "# presentation only — safe to skip";
32434
- const PRESENTATION_FAILURE_WARNING = "printf 'gtd: presentation-only follow-up failed — continuing\\n' >&2";
32435
- /**
32436
- * A write command's single pasteable script (`gtd abandon | sh`,
32437
- * `gtd land --json=script`'s own field). `optional`, when non-empty, is
32438
- * wrapped in a subshell whose failure is swallowed — presentation-only, so it
32439
- * must never turn a landed turn into a non-zero exit.
32440
- */
32441
- const combinedScript = (required, optional) => {
32442
- if (required.length === 0) return "";
32443
- if (optional.length === 0) return `${DID_NOT_RUN_COMMENT}\n\n${required}`;
32444
- return [
32445
- DID_NOT_RUN_COMMENT,
32446
- "",
32447
- required,
32448
- "",
32449
- PRESENTATION_ONLY_COMMENT,
32450
- `(\n${optional}\n) || ${PRESENTATION_FAILURE_WARNING}`
32451
- ].join("\n");
32344
+ const stepDefOf = (step) => {
32345
+ const request = step.request;
32346
+ const common = commonOf(step);
32347
+ switch (request.kind) {
32348
+ case "agent": return promptDef(common, request, step.skills, step.access);
32349
+ case "run": return scriptDef(common, request);
32350
+ case "judge": return judgeDef(common, request, step);
32351
+ case "human": return messageDef(common, request, step);
32352
+ }
32452
32353
  };
32453
- //#endregion
32454
- //#region src/emit/OutcomeScript.ts
32455
- /**
32456
- * A `printf '<fmt>' <args...>` bash statement — `args` are already-valid bash
32457
- * tokens (a `shellQuote`d literal, a `"$(git …)"` substitution), never
32458
- * re-quoted here: a value reaches the script as a printf ARGUMENT, never
32459
- * interpolated into the format itself, so a subject or state name containing
32460
- * `%` can never be read as a conversion spec. Markers like `->` are arguments
32461
- * too, keeping every format string free of a leading `-` that a shell's
32462
- * `printf` could mistake for an option. A real newline in `fmt` is emitted as
32463
- * the two-character `\n` escape `printf` itself expands, keeping every emitted
32464
- * statement on one line.
32465
- */
32466
- const printfLine = (fmt, args) => `${OUTCOME_MARKER}\nprintf ${shellQuote$1(fmt.replace(/\n/g, "\\n"))} ${args.join(" ")}`;
32467
- /**
32468
- * Marks an emitted block as print-only. Load-bearing beyond documentation:
32469
- * `src/twins/EmittedScriptRecognizer.ts` recognizes an outcome block by
32470
- * this line, and a bare `printf` would be ambiguous with a workflow
32471
- * `script:` command that happens to start the same way.
32472
- */
32473
- const OUTCOME_MARKER = "# gtd: outcome (print-only)";
32474
- const FMT_ABANDON_NOOP = "no gtd process is underway (resting at \"%s\") — nothing to abandon\n";
32475
- 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";
32476
- 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";
32477
- /** `no gtd process is underway (resting at "<initial>") — nothing to abandon` — `gtd abandon`'s no-op plain-text line. */
32478
- const abandonNoopText = (initial) => renderFormat(FMT_ABANDON_NOOP, initial);
32479
- /** One already-rendered plain line. */
32480
- const noteOutcome = (text) => printfLine("%s\n", [shellQuote$1(text)]);
32481
- /**
32482
- * Resolves the post-hoc short hash/subject from `head` (a commitish) in-script
32483
- * — this line runs AFTER the reset that made `head` the new HEAD, so neither
32484
- * value is knowable when gtd generates it.
32485
- */
32486
- const abandonedOutcome = (from, head, state) => printfLine(FMT_ABANDONED, [
32487
- shellQuote$1(from),
32488
- `"$(git rev-parse --short ${shellQuote$1(head)})"`,
32489
- `"$(git log -1 --format=%s ${shellQuote$1(head)})"`,
32490
- shellQuote$1(state)
32491
- ]);
32492
- /** `gtd abandon`'s no-op outcome — the same wording `abandonNoopText` renders. */
32493
- const abandonNoopOutcome = (initial) => noteOutcome(abandonNoopText(initial));
32494
- /** Resolves the post-hoc short hash/subject from `to` (a commitish) in-script, for the same reason `abandonedOutcome` does. */
32495
- const restoredOutcome = (to, state) => printfLine(FMT_RESTORED, [
32496
- `"$(git rev-parse --short ${shellQuote$1(to)})"`,
32497
- `"$(git log -1 --format=%s ${shellQuote$1(to)})"`,
32498
- shellQuote$1(state)
32499
- ]);
32500
- //#endregion
32501
- //#region src/emit/ModeContradiction.ts
32354
+ const ROOT_MEMORY_SCOPE_NAME = "root";
32502
32355
  /**
32503
- * Written for two readers at once, since gtd validate's exit code carries no
32504
- * second meaning to distinguish them: the agent (this is a CONFIGURATION BUG,
32505
- * not the steering file — stop, don't edit it, or a fix-retry loop will
32506
- * thrash on the document) and the human (which mode, and the exact rendered
32507
- * `format:` command, to fix `gtd.config.ts` without reproducing the failure).
32356
+ * An agent rest's memory key, `<scope>#<hash7>`: the hash is the commit the
32357
+ * current unbroken run into the scope started FROM, so the same run always
32358
+ * re-derives the same key. `resumed` is whether an agent turn in that run
32359
+ * already landed.
32508
32360
  */
32509
- 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:`;
32361
+ const memoryOf = (trace, run) => {
32362
+ const rest = trace[trace.length - 1];
32363
+ if (rest === void 0 || rest.kind !== "agent") return {
32364
+ key: void 0,
32365
+ resumed: false
32366
+ };
32367
+ const start = scopeRunStart(trace, rest.memoryScope);
32368
+ const token = start <= 0 ? run.startParentHash : trace[start - 1].enteredAt;
32369
+ const resumed = trace.slice(Math.max(start, 0), trace.length - 1).some((s) => s.kind === "agent" && s.memoryScope === rest.memoryScope);
32370
+ return {
32371
+ key: `${rest.memoryScope || ROOT_MEMORY_SCOPE_NAME}#${token.slice(0, 7)}`,
32372
+ resumed
32373
+ };
32374
+ };
32375
+ const hintsOf = (def) => ({
32376
+ ...optional("model", def.model),
32377
+ ...optional("label", def.label),
32378
+ ...optional("file", def.file),
32379
+ ...optional("judge", def.judge),
32380
+ ...optional("system", def.system),
32381
+ ...optional("mode", def.mode),
32382
+ ...optional("skills", def.skills),
32383
+ ...optional("access", def.access)
32384
+ });
32385
+ const normalizeStatus = (raw) => raw === "A" ? "A" : raw === "D" ? "D" : "M";
32386
+ /** Per-model token totals, highest-cost first (ties broken by model name). */
32387
+ const costByModel = (entries) => {
32388
+ const byModel = /* @__PURE__ */ new Map();
32389
+ for (const entry of entries) byModel.set(entry.model, (byModel.get(entry.model) ?? 0) + entry.cost);
32390
+ return [...byModel.entries()].map(([model, cost]) => ({
32391
+ model,
32392
+ cost
32393
+ })).sort((a, b) => b.cost - a.cost || a.model.localeCompare(b.model));
32394
+ };
32395
+ const costTotals = (entries) => ({
32396
+ total: entries.reduce((sum, entry) => sum + entry.cost, 0),
32397
+ byModel: costByModel(entries)
32398
+ });
32399
+ const setupFor = (config, run, workspace, host, startOverrides = {}) => gen(function* () {
32400
+ const resolved = yield* workflowOf(config, run);
32401
+ const def = resolved.def;
32402
+ const startFlags = run.trace.length === 0 ? yield* StartFlags : {};
32403
+ const { vars, env } = settingsFor(resolved, run.pinnedVars, host.env, startOverrides, startFlags);
32404
+ const skills = yield* resolveScopeSkills(def, vars);
32405
+ const access = yield* resolveScopeAccess(def, skills, vars);
32406
+ const budget = yield* try_({
32407
+ try: () => judgeBudgetBytes(vars),
32408
+ catch: (e) => e instanceof Error ? e : new Error(String(e))
32409
+ });
32410
+ yield* workspace.episodeTrees(run.episode.base, run.episode.commits.map((c) => c.hash));
32411
+ const base = run.episode.base === void 0 ? {
32412
+ hash: EMPTY_TREE$1,
32413
+ tree: treeFromRecord({})
32414
+ } : {
32415
+ hash: run.episode.base,
32416
+ tree: commitTree(workspace, run.episode.base)
32417
+ };
32418
+ const git = yield* GitService;
32419
+ const episode = [];
32420
+ for (const c of run.episode.commits) {
32421
+ const tip = parseCommitMessage(c.message).kept;
32422
+ const exists = tip !== void 0 && isSome(yield* git.readRefOption(`${tip}^{commit}`));
32423
+ episode.push({
32424
+ hash: c.hash,
32425
+ message: c.message,
32426
+ tree: commitTree(workspace, c.hash),
32427
+ ...exists ? { kept: {
32428
+ hash: tip,
32429
+ tree: commitTree(workspace, tip)
32430
+ } } : {}
32431
+ });
32432
+ }
32433
+ let pendingMemo;
32434
+ return {
32435
+ resolved,
32436
+ def,
32437
+ vars,
32438
+ env,
32439
+ setup: {
32440
+ def,
32441
+ run,
32442
+ vars,
32443
+ env,
32444
+ budget,
32445
+ skills,
32446
+ access,
32447
+ workspace,
32448
+ base,
32449
+ episode,
32450
+ pendingWorktree: () => pendingMemo ??= workspace.worktreeSync()
32451
+ }
32452
+ };
32453
+ });
32454
+ const pendingChanges = (git, withIndex) => gen(function* () {
32455
+ const worktree = yield* git.changedPaths();
32456
+ if (!withIndex) return worktree;
32457
+ const seen = new Set(worktree.map((e) => e.path));
32458
+ return [...worktree, ...(yield* git.stagedPaths()).filter((e) => !seen.has(e.path))];
32459
+ });
32460
+ /** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
32461
+ const restAt = (ref, startOverrides = {}) => gen(function* () {
32462
+ const git = yield* GitService;
32463
+ const config = yield* (yield* ConfigService).load;
32464
+ const workspace = yield* Workspace;
32465
+ const host = yield* Host;
32466
+ const run = yield* computeProcessRun(git, config.workflow, ref);
32467
+ const { resolved, def, vars, env, setup } = yield* setupFor(config, run, workspace, host, startOverrides);
32468
+ const outcome = yield* promise(() => replayFor(setup));
32469
+ const pinned = headHasPin(workspace, run);
32470
+ const pin = pinBeatAt(outcome, run, def, pinned);
32471
+ const resting = restingOn(outcome, pin);
32472
+ if (resting === void 0) return yield* fail$8(replayError(outcome) ?? /* @__PURE__ */ new Error("gtd: replay found no rest"));
32473
+ const { reached, step } = resting;
32474
+ const { stepDef, state, memory } = restFacts(pin, step, reached.trace, run);
32475
+ yield* (yield* Narrator).narrate(`rest resolved: ${state} (awaits ${stepDef.actor})`);
32476
+ if (stepDef.mode !== void 0 && def.modes[stepDef.mode] === void 0) return yield* fail$8(/* @__PURE__ */ new Error(`gtd: config: step "${step.name}": mode "${stepDef.mode}" is not a mode this workflow knows (${knownModes(def).join(", ")})`));
32477
+ const changes = ref === void 0 ? (yield* pendingChanges(git, stepDef.approve === true)).map((e) => ({
32478
+ status: normalizeStatus(e.status),
32479
+ path: e.path
32480
+ })) : [];
32481
+ const configNow = config.fingerprint;
32482
+ if (ref === void 0) yield* warnConfigChanges(run, configNow, workspace);
32483
+ return {
32484
+ def,
32485
+ workflowName: resolved.name,
32486
+ state,
32487
+ stepDef,
32488
+ actor: stepDef.actor,
32489
+ pin,
32490
+ pinned,
32491
+ step,
32492
+ trace: reached.trace,
32493
+ run,
32494
+ vars,
32495
+ env,
32496
+ config: configNow,
32497
+ changes,
32498
+ memory: memory.key,
32499
+ memoryResumed: memory.resumed,
32500
+ hints: hintsOf(stepDef),
32501
+ cost: costTotals(run.costEntries),
32502
+ setup
32503
+ };
32504
+ });
32505
+ const currentRest = restAt(void 0);
32506
+ /** The step the process rests at and the trace that reached it — at an unpin, the flow's last step. */
32507
+ const restingOn = (outcome, pin) => {
32508
+ if (outcome.kind === "rest") return {
32509
+ reached: outcome,
32510
+ step: outcome.rest
32511
+ };
32512
+ if (outcome.kind !== "ended" || pin !== "remove") return void 0;
32513
+ const step = outcome.trace.at(-1);
32514
+ return step === void 0 ? void 0 : {
32515
+ reached: outcome,
32516
+ step
32517
+ };
32518
+ };
32519
+ /** A pin or unpin beat has its own script, no step's, and no agent memory. */
32520
+ const restFacts = (pin, step, trace, run) => pin === void 0 ? {
32521
+ stepDef: stepDefOf(step),
32522
+ state: step.name,
32523
+ memory: memoryOf(trace, run)
32524
+ } : {
32525
+ stepDef: pinStepDef(pin),
32526
+ state: PIN_STATES[pin],
32527
+ memory: {
32528
+ key: void 0,
32529
+ resumed: false
32530
+ }
32531
+ };
32532
+ const headHasPin = (workspace, run) => {
32533
+ const head = run.episode.commits.at(-1)?.hash ?? run.episode.base;
32534
+ return head !== void 0 && workspace.treeSync(head).has(".gtd/process.gtdpin");
32535
+ };
32536
+ const underwayAt = (run, step, def) => run.workflow !== void 0 || step.name !== def.initial || step.id.occurrence !== 1;
32510
32537
  /**
32511
- * Formats a copy of the mode's own canonical sample and re-validates it with
32512
- * `gtd check <mode>`, failing loudly when the round-trip breaks it — detects a
32513
- * mode whose `format:` contradicts its own `validate:` without ever touching
32514
- * the real steering file. `printf '%s'` over a `shellQuote`d sample, never a
32515
- * heredoc, since no delimiter chosen ahead of time can collide with the
32516
- * sample's own bytes. One bash fragment with no blank lines (so
32517
- * `src/emit/Emit.ts`'s `assembleScript` never splits it from a sibling step), meant
32518
- * to run BEFORE `[ -f <file> ] || exit 0` in the emitted validate script so it
32519
- * still fires at a first-write beat where the real steering file doesn't
32520
- * exist yet.
32538
+ * A started process pins first — again, should a turn have deleted the pin;
32539
+ * a pinned flow that ended unpins before the closing commit.
32521
32540
  */
32522
- const buildModeContradictionCheck = (inputs) => {
32523
- const { mode, samplePath, sample, formatCommand } = inputs;
32524
- const pathQ = shellQuote$1(samplePath);
32525
- const messageQ = shellQuote$1(contradictionMessage(mode, formatCommand));
32526
- return [
32527
- `printf '%s' ${shellQuote$1(sample)} > ${pathQ}`,
32528
- formatCommand,
32529
- `gtd check ${mode} ${pathQ} >/dev/null 2>&1 || {`,
32530
- ` printf '%s\\n' ${messageQ} >&2`,
32531
- ` cat ${pathQ} >&2`,
32532
- ` rm -f ${pathQ}`,
32533
- ` exit 1`,
32534
- `}`,
32535
- `rm -f ${pathQ}`
32536
- ].join("\n");
32541
+ const pinBeatAt = (outcome, run, def, pinned) => {
32542
+ if (flowEnded(outcome, run, def)) return pinned || run.pinCommitted ? "remove" : void 0;
32543
+ if (outcome.kind !== "rest" || pinned) return void 0;
32544
+ return underwayAt(run, outcome.rest, def) ? "add" : void 0;
32537
32545
  };
32546
+ 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);
32547
+ const pinStepDef = (pin) => ({
32548
+ actor: "check",
32549
+ kind: "script",
32550
+ content: pin === "add" ? pinScript(PIN_PATH) : unpinScript(PIN_PATH)
32551
+ });
32538
32552
  /**
32539
- * Skip notice for a mode whose validator is EXTERNAL (a user `validate:`
32540
- * command, not one of the two built-in modes) — there's no in-process parser
32541
- * to round-trip a sample through. Printed to stderr since silence would read
32542
- * as a clean bill of health.
32553
+ * Whether `gtd start <name>` can start a process here: the workflow must
32554
+ * reach a step from the tree the opening commit would capture. That step is
32555
+ * what the opening commit's subject names.
32543
32556
  */
32544
- const modeContradictionSkipNotice = (mode) => {
32545
- return `printf '%s\\n' ${shellQuote$1(`gtd: mode "${mode}" has an external validate: command — skipping the format/validate contradiction check`)} >&2`;
32546
- };
32547
- //#endregion
32548
- //#region src/emit/SteeringMode.ts
32549
- init_dist$1();
32550
- init_steering();
32557
+ const startRefusal = (rest, resolved, startVars) => gen(function* () {
32558
+ const host = yield* Host;
32559
+ const { vars, env } = settingsFor(resolved, {}, host.env, startVars, yield* StartFlags);
32560
+ const skills = yield* resolveScopeSkills(resolved.def, vars);
32561
+ const access = yield* resolveScopeAccess(resolved.def, skills, vars);
32562
+ const workspace = rest.setup.workspace;
32563
+ const outcome = yield* promise(() => replay({
32564
+ flow: resolved.def.flow,
32565
+ episode: {
32566
+ base: {
32567
+ hash: "",
32568
+ tree: pendingTree(workspace, rest.setup.pendingWorktree(), void 0)
32569
+ },
32570
+ commits: []
32571
+ },
32572
+ vars,
32573
+ env,
32574
+ start: "",
32575
+ budgetBytes: rest.setup.budget,
32576
+ skills,
32577
+ configuredSkills: resolved.def.configuredSkills,
32578
+ access,
32579
+ configuredAccess: resolved.def.configuredAccess
32580
+ }));
32581
+ if (outcome.kind === "rest") return { firstStep: outcome.rest.name };
32582
+ if (outcome.kind === "refused") return { refusal: outcome.message };
32583
+ const why = outcome.kind === "ended" ? "the flow reaches no step" : outcome.message;
32584
+ return { refusal: `workflow "${resolved.name}" cannot start — ${why}` };
32585
+ });
32586
+ /** The review window's diff base at the rest. */
32587
+ const reviewBaseFor = (rest) => rest.step.request.options.base ?? rest.run.diffBase;
32588
+ const renderRest = (rest) => succeed$1({
32589
+ state: rest.state,
32590
+ actor: rest.actor,
32591
+ kind: rest.stepDef.kind,
32592
+ content: rest.stepDef.content,
32593
+ ...rest.hints,
32594
+ ...rest.memory !== void 0 ? { memory: rest.memory } : {},
32595
+ memoryResumed: rest.memoryResumed,
32596
+ ...rest.pin !== void 0 ? { pin: rest.pin } : {}
32597
+ });
32551
32598
  /**
32552
- * `format` alone (no live/external verdict) when there's no validator at
32553
- * all; a live in-process validator for a `"builtin"` validate OR a command
32554
- * that is exactly the format's own seeded string; `externalValidate` for any
32555
- * other command. Each arm returns directly — a flat sequence, not nested
32556
- * ternaries — so the four outcomes stay each their own case to read, not a
32557
- * boolean expression to re-derive.
32599
+ * Derived, restart-proof stall detection: the tree is clean, HEAD is an empty
32600
+ * attempt at this very agent rest, and another dispatch would repeat it.
32558
32601
  */
32559
- const capabilitiesFor = (mode, format, validate) => {
32560
- if (format === void 0) return validate?.kind === "command" ? { externalValidate: true } : {};
32561
- if (validate?.kind === "builtin") return {
32562
- format,
32563
- liveValidate: format.validate
32602
+ 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;
32603
+ /** 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. */
32604
+ const noProcessUnderway = (rest) => rest.pin === void 0 && !underwayAt(rest.run, rest.step, rest.def);
32605
+ /** `idle` means exactly one thing: no process underway, clean tree. */
32606
+ const restIsIdle = (rest) => noProcessUnderway(rest) && rest.changes.length === 0;
32607
+ const isHumanReviewGate = (def) => def.actor === "human" && def.mode === "review";
32608
+ const approvalRefusal = (rest, approver) => {
32609
+ if (rest.stepDef.approve !== true) return void 0;
32610
+ const id = rest.step.id;
32611
+ if (rest.changes.length > 0) return {
32612
+ kind: "refusal",
32613
+ 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\``
32564
32614
  };
32565
- if (validate === void 0) return { format };
32566
- if (isSeededValidateCommand(mode, validate.command)) return {
32567
- format,
32568
- liveValidate: format.validate
32615
+ if (isNone(approver)) return {
32616
+ kind: "refusal",
32617
+ message: `gtd land: approval-gate: ${formatStepId(id)} records who approved it, but git has no author identity — set user.name and user.email`
32618
+ };
32619
+ if (!isRecordableApproval({
32620
+ step: id,
32621
+ ...approver.value
32622
+ })) return {
32623
+ kind: "refusal",
32624
+ 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)`
32569
32625
  };
32626
+ };
32627
+ /** The landing a clean tree decides before any replay, if it decides one. */
32628
+ const cleanLanding = (rest) => {
32629
+ if (rest.changes.length > 0) return void 0;
32630
+ const def = rest.stepDef;
32631
+ if (def.kind === "prompt" && def.allowEmpty !== true) return {
32632
+ kind: "attempt",
32633
+ subject: formatSubject(rest.actor, rest.state)
32634
+ };
32635
+ if (rest.actor === "human" && def.acceptClean !== true) return {
32636
+ kind: "noop",
32637
+ settled: false
32638
+ };
32639
+ };
32640
+ /** At the human review gate the pending tree lands with every tick cleared, as the landing script will. */
32641
+ const reviewGateRewrite = (def) => {
32642
+ const reviewFormat = steeringFormatFor("review");
32643
+ if (!isHumanReviewGate(def) || def.file === void 0 || reviewFormat === void 0) return;
32570
32644
  return {
32571
- format,
32572
- externalValidate: true
32645
+ path: def.file,
32646
+ apply: (content) => reviewFormat.clearTicks(content)
32647
+ };
32648
+ };
32649
+ const multilineRefusal = (vars) => {
32650
+ const multiline = multilineSetting(vars);
32651
+ return multiline === void 0 ? void 0 : {
32652
+ kind: "refusal",
32653
+ message: `gtd: process setting "${multiline}" spans several lines — a process setting is pinned in a commit trailer and must be a single line`
32573
32654
  };
32574
32655
  };
32656
+ const landingTarget = (outcome, rest) => {
32657
+ if (outcome.kind === "refused") return succeed$1({
32658
+ kind: "refusal",
32659
+ message: outcome.message
32660
+ });
32661
+ if (outcome.kind === "divergence" || outcome.kind === "failed") return fail$8(new Error(outcome.message));
32662
+ if (outcome.kind !== "rest") return succeed$1(rest.def.initial);
32663
+ const reserved = initialStepRefusal(rest.run.workflow, outcome.rest.name, rest.def.initial);
32664
+ return succeed$1(reserved === void 0 ? outcome.rest.name : reserved);
32665
+ };
32575
32666
  /**
32576
- * Resolve a `mode:` name against `def.modes` plus the built-in registry
32577
- * (`src/workflow/SteeringFormats.ts`) — half by half: a declared `format:`/`validate:`
32578
- * wins, and an undeclared `validate:` falls back to the built-in format's own
32579
- * parser when the name is registered. `format` is set from the registry
32580
- * ALONE, independent of which half of `validate` wins — a declared validator
32581
- * overrides validation, not the format identity.
32582
- *
32583
- * `def: undefined` is the LSP's built-in-only fallback — resolving `mode`
32584
- * against the registry alone, with no workflow definition in hand (e.g. the
32585
- * basename dispatch for a `REVIEW.md` no config maps).
32586
- *
32587
- * The `"unknown"` arm carries the whole refusal message, naming what IS known
32588
- * when `def` is given — this is the one place that message is built, so it
32589
- * can never drift from what actually resolved.
32667
+ * A step commit entering the default's first step is what ends an episode, so
32668
+ * a named workflow landing there would read back as finished. Its opening
32669
+ * commit may name that step: a bare opening is never read as a boundary.
32590
32670
  */
32591
- const resolveMode = (def, state, mode) => {
32592
- const declared = def?.modes?.[mode];
32593
- const format = steeringFormatFor(mode);
32594
- if (declared === void 0 && format === void 0) return {
32595
- kind: "unknown",
32596
- 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`
32671
+ const initialStepRefusal = (workflow, step, initial) => workflow !== void 0 && step === initial ? {
32672
+ kind: "refusal",
32673
+ 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`
32674
+ } : void 0;
32675
+ const closingTrailers = (branch) => ({
32676
+ version: GTD_VERSION,
32677
+ ...isSome(branch) ? { branch: branch.value } : {}
32678
+ });
32679
+ const isSettledNoop = (rest, to) => rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state;
32680
+ const keptTurn = (rest) => rest.stepDef.keep === true && rest.changes.length > 0 ? { keepTurn: keptRef(rest.run.startHash, rest.state) } : {};
32681
+ const approvedTrailer = (rest, approver) => isSome(approver) ? { approved: {
32682
+ step: rest.step.id,
32683
+ ...approver.value
32684
+ } } : {};
32685
+ const landingTo = (rest, to, branch, approver, marks) => {
32686
+ if (isSettledNoop(rest, to)) return {
32687
+ kind: "noop",
32688
+ settled: true
32689
+ };
32690
+ const underway = !noProcessUnderway(rest);
32691
+ const starts = !underway && to !== rest.def.initial;
32692
+ const refused = starts ? multilineRefusal(rest.vars) : void 0;
32693
+ if (refused !== void 0) return refused;
32694
+ const { finishes, landsAt } = endingOf(rest, to, underway);
32695
+ return {
32696
+ kind: "commit",
32697
+ to: landsAt,
32698
+ spec: {
32699
+ actor: rest.actor,
32700
+ from: rest.state,
32701
+ to: landsAt,
32702
+ step: rest.step.id,
32703
+ ...marks.length > 0 && { marks },
32704
+ ...approvedTrailer(rest, approver),
32705
+ ...startTrailers(rest, starts),
32706
+ ...finishes ? closingTrailers(branch) : {}
32707
+ },
32708
+ ...finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {},
32709
+ ...keptTurn(rest)
32597
32710
  };
32598
- const validate = declared?.validate !== void 0 ? {
32599
- kind: "command",
32600
- command: declared.validate
32601
- } : format !== void 0 ? {
32602
- kind: "builtin",
32603
- format
32604
- } : void 0;
32711
+ };
32712
+ const endingOf = (rest, to, underway) => {
32713
+ const ends = underway && to === rest.def.initial;
32605
32714
  return {
32606
- kind: "resolved",
32607
- mode,
32608
- ...format !== void 0 ? { format } : {},
32609
- ...declared?.format !== void 0 ? { formatCommand: declared.format } : {},
32610
- ...validate !== void 0 ? { validate } : {},
32611
- capabilities: capabilitiesFor(mode, format, validate)
32715
+ finishes: ends && !rest.pinned,
32716
+ landsAt: ends && rest.pinned ? PIN_STATES.remove : to
32612
32717
  };
32613
32718
  };
32614
- /** `gtd next`'s fix-retry instruction, printed ahead of the failing command's captured output. */
32615
- const fixPromptInstruction = (file) => `Your last turn does not pass its own validation script. Fix these format violations in ${file}, then finish:`;
32616
- /**
32617
- * Pushes a `binaryGuard` step ahead of `command` when its own leading word
32618
- * is unambiguous — BEFORE, not wrapped around: a guard that fails must stop
32619
- * the script on its own `exit 127`, without touching `failurePromptWrapper`'s
32620
- * captured-output path. Each command guards on its OWN leading word — a
32621
- * guard naming the wrong binary is worse than none.
32622
- */
32623
- const pushGuardedCommand = (steps, mode, key, command, file, onFailure) => {
32624
- const binary = extractLeadingBinary(command);
32625
- if (binary !== void 0) steps.push({
32626
- kind: "command",
32627
- command: binaryGuard(binary, mode, key)
32628
- });
32629
- steps.push({
32630
- kind: "command",
32631
- command: withFileVar$1(command, file),
32632
- ...onFailure !== void 0 ? { onFailure } : {}
32633
- });
32719
+ /** A pin or unpin commit carries the pin alone; the unpin commit closes the process. */
32720
+ const pinLanding = (rest, pin, branch) => {
32721
+ const change = rest.changes.find((c) => c.path === PIN_PATH)?.status;
32722
+ return pin === "add" ? pinCommit(rest, change) : unpinCommit(rest, change, branch);
32634
32723
  };
32635
- /**
32636
- * `resolved`'s `format:`/`validate:` as `EmitStep[]` — format first,
32637
- * validate last, the last one wrapped with `onFailure` when the validator is
32638
- * a command (a built-in's own in-process parser has no shell step to wrap:
32639
- * `gtd check`'s seeded command carries that instead). Each command gets its
32640
- * own `binaryGuard` immediately ahead of it.
32641
- */
32642
- const modeCommandSteps = (resolved, file) => {
32643
- const steps = [];
32644
- if (resolved.formatCommand !== void 0) pushGuardedCommand(steps, resolved.mode, "format", resolved.formatCommand, file, void 0);
32645
- if (resolved.validate?.kind === "command") pushGuardedCommand(steps, resolved.mode, "validate", resolved.validate.command, file, fixPromptInstruction(file));
32646
- return steps;
32724
+ const pinCommit = (rest, change) => {
32725
+ if (change === void 0 || change === "D") return pinRefusal("add");
32726
+ const to = rest.step.name;
32727
+ return {
32728
+ kind: "commit",
32729
+ to,
32730
+ spec: {
32731
+ actor: rest.actor,
32732
+ from: rest.state,
32733
+ to,
32734
+ pin: "add"
32735
+ },
32736
+ only: PIN_PATH
32737
+ };
32647
32738
  };
32648
- /** `<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). */
32649
- const scratchSamplePath = (scratchDir, mode) => join(scratchDir, `gtd-mode-sample-${mode}-${process.pid}.md`);
32739
+ const unpinCommit = (rest, change, branch) => {
32740
+ if (change !== "D" && rest.pinned) return pinRefusal("remove");
32741
+ const to = rest.def.initial;
32742
+ return {
32743
+ kind: "commit",
32744
+ to,
32745
+ spec: {
32746
+ actor: rest.actor,
32747
+ from: rest.state,
32748
+ to,
32749
+ pin: "remove",
32750
+ ...closingTrailers(branch)
32751
+ },
32752
+ keepHistory: processHistoryRef(rest.run.startHash),
32753
+ ...rest.pinned ? { only: PIN_PATH } : {}
32754
+ };
32755
+ };
32756
+ const pinRefusal = (pin) => ({
32757
+ kind: "refusal",
32758
+ 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`
32759
+ });
32760
+ const startTrailers = (rest, starts) => starts ? {
32761
+ vars: rest.vars,
32762
+ config: rest.config
32763
+ } : {};
32764
+ const outcomeMarks = (outcome) => outcome.kind === "rest" || outcome.kind === "ended" ? outcome.marks : [];
32650
32765
  /**
32651
- * The contradiction round-trip/skip-notice steps for `resolved`'s `mode:`.
32652
- * No `formatCommand` means nothing to round-trip: empty. Otherwise: a live
32653
- * built-in validator runs the round-trip against that format's own canonical
32654
- * sample; an external validator prints a one-line skip notice instead, since
32655
- * silence there would read as a clean bill of health; a format-only mode with
32656
- * neither has nothing to round-trip either.
32766
+ * What landing the pending turn at `rest` does — a pure decision from a
32767
+ * replay with the working tree as the pending turn. The target step is
32768
+ * computed by replaying, never matched.
32657
32769
  */
32658
- const modeContradictionSteps = (resolved) => gen(function* () {
32659
- if (resolved.formatCommand === void 0) return [];
32660
- const { capabilities } = resolved;
32661
- if (capabilities.format !== void 0 && capabilities.liveValidate !== void 0) {
32662
- const { scratchDir } = yield* Host;
32663
- const samplePath = scratchSamplePath(scratchDir, resolved.mode);
32664
- const formatCommand = withFileVar$1(resolved.formatCommand, samplePath);
32665
- return [{
32666
- kind: "command",
32667
- command: buildModeContradictionCheck({
32668
- mode: resolved.mode,
32669
- samplePath,
32670
- sample: capabilities.format.sample,
32671
- formatCommand
32672
- })
32673
- }];
32770
+ const decideLanding = (rest, verdicts, branch, approver) => gen(function* () {
32771
+ if (rest.pin !== void 0) return pinLanding(rest, rest.pin, branch);
32772
+ const unapproved = approvalRefusal(rest, approver);
32773
+ if (unapproved !== void 0) return unapproved;
32774
+ const early = cleanLanding(rest);
32775
+ if (early !== void 0) return early;
32776
+ if (rest.stepDef.kind === "prompt" && rest.stepDef.access?.write != null) {
32777
+ const message = accessRefusal(rest.changes, rest.stepDef.access.write);
32778
+ if (message !== void 0) return {
32779
+ kind: "refusal",
32780
+ message
32781
+ };
32674
32782
  }
32675
- if (capabilities.externalValidate === true) return [{
32676
- kind: "command",
32677
- command: modeContradictionSkipNotice(resolved.mode)
32678
- }];
32679
- return [];
32783
+ const outcome = yield* promise(() => replayFor(rest.setup, {
32784
+ tree: pendingTree(rest.setup.workspace, rest.setup.pendingWorktree(), reviewGateRewrite(rest.stepDef)),
32785
+ ...verdicts !== void 0 ? { verdicts } : {}
32786
+ }));
32787
+ const to = yield* landingTarget(outcome, rest);
32788
+ if (typeof to !== "string") return to;
32789
+ return landingTo(rest, to, branch, approver, outcomeMarks(outcome));
32790
+ });
32791
+ /** Where landing the pending turn would leave the process, or `undefined` when it would land nothing. */
32792
+ const previewLanding = (rest) => decideLanding(rest, void 0, none(), none()).pipe(map((landing) => landing.kind === "commit" ? landing.to : void 0), catch_(() => succeed$1(void 0)));
32793
+ /** Every fact the pure landing planner needs, read once: the rest, its steering file, and the landing decision. */
32794
+ const snapshotFromRest = (rest, verdicts) => gen(function* () {
32795
+ const git = yield* GitService;
32796
+ const branch = yield* git.currentBranch();
32797
+ const approver = rest.stepDef.approve === true ? yield* git.authorIdent() : none();
32798
+ const landing = yield* decideLanding(rest, verdicts, branch, approver);
32799
+ return {
32800
+ stepDef: rest.stepDef,
32801
+ state: rest.state,
32802
+ actor: rest.actor,
32803
+ changes: rest.changes,
32804
+ file: rest.hints.file,
32805
+ landing
32806
+ };
32807
+ });
32808
+ const humanCommitsOf = (trace, entryCommit) => trace.filter((entry) => entry.actor === "human" && entry.hash !== entryCommit).map((entry) => ({
32809
+ hash: entry.hash,
32810
+ state: entry.state
32811
+ }));
32812
+ const summaryInput = (run, settings) => {
32813
+ const entryCommit = run.trace[0].hash;
32814
+ const { total, byModel } = costTotals(run.costEntries);
32815
+ return {
32816
+ entryCommit,
32817
+ processBase: run.startParentHash,
32818
+ processTip: run.trace[run.trace.length - 1].hash,
32819
+ humanCommits: humanCommitsOf(run.trace, entryCommit),
32820
+ processCost: total,
32821
+ processCostByModel: byModel,
32822
+ marks: run.marks,
32823
+ ...settings
32824
+ };
32825
+ };
32826
+ const summaryFor = (run) => gen(function* () {
32827
+ const config = yield* (yield* ConfigService).load;
32828
+ const host = yield* Host;
32829
+ const resolved = yield* workflowOf(config, run);
32830
+ const summary = resolved.def.summary;
32831
+ if (summary === void 0 || run.trace.length === 0) return { prompt: void 0 };
32832
+ return { prompt: summary(summaryInput(run, settingsFor(resolved, run.pinnedVars, host.env, {}, {}))) };
32833
+ });
32834
+ /** 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. */
32835
+ const inRepoConfigPaths = (root, files, recorded) => {
32836
+ const paths = new Set(Object.keys(recorded));
32837
+ for (const file of files) paths.add(relative(root, resolve(root, file)).split(sep).join("/"));
32838
+ return [...paths].filter((p) => p !== "" && !p.startsWith("..") && !isAbsolute(p)).sort();
32839
+ };
32840
+ 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}`);
32841
+ const differingConfigPaths = (git, workspace, root, config, run, hash) => gen(function* () {
32842
+ const differing = [];
32843
+ 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);
32844
+ return differing;
32680
32845
  });
32846
+ 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");
32847
+ const historySteps = (run) => {
32848
+ const messages = new Map(run.episode.commits.map((c) => [c.hash, c.message]));
32849
+ return run.trace.map((entry) => {
32850
+ const message = parseCommitMessage(messages.get(entry.hash) ?? "");
32851
+ return {
32852
+ hash: entry.hash,
32853
+ subject: (messages.get(entry.hash) ?? "").split("\n")[0] ?? "",
32854
+ actor: entry.actor,
32855
+ from: message.parsed?.from,
32856
+ to: entry.state,
32857
+ verdicts: message.judge,
32858
+ ...optional("approval", approvalEntryOf(entry.hash, message)[0])
32859
+ };
32860
+ });
32861
+ };
32862
+ 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`;
32681
32863
  /**
32682
- * Renders `resolved`'s complete validate script for `file`: the mode's
32683
- * format/validate contradiction check, a guard for a not-yet-written file,
32684
- * then the format/validate commands themselves (format first). gtd never
32685
- * runs this script itself — `resolveMode`+`validateScriptFor` only render
32686
- * text for a driver to execute (`gtd renders; the driver executes`).
32864
+ * Replays the closed run at `tip` under today's config. The workflow is not
32865
+ * fingerprinted for a version change: the engine under it moves with every
32866
+ * upgrade, and the version warning is the only signal.
32687
32867
  */
32688
- const validateScriptFor = (resolved, file) => gen(function* () {
32689
- return { script: emitScripts([
32690
- ...yield* modeContradictionSteps(resolved),
32691
- {
32692
- kind: "command",
32693
- command: fileExistsGuard(file)
32868
+ const historyTrail = (tip) => gen(function* () {
32869
+ const git = yield* GitService;
32870
+ const config = yield* (yield* ConfigService).load;
32871
+ const hash = yield* git.resolveRef(tip);
32872
+ const subject = yield* git.lastCommitSubject(hash);
32873
+ const run = yield* runAtTip(git, config.workflow, {
32874
+ hash,
32875
+ subject
32876
+ });
32877
+ const { outcome, recorded, versionWarning } = yield* replayFinishedProcess(config, run, hash, {
32878
+ command: "gtd history",
32879
+ notEnded: (o) => notClosedError(hash, o)
32880
+ });
32881
+ return {
32882
+ tip: hash,
32883
+ steps: historySteps(run),
32884
+ ended: outcome.via,
32885
+ version: {
32886
+ recorded,
32887
+ running: GTD_VERSION
32694
32888
  },
32695
- ...modeCommandSteps(resolved, file)
32696
- ]).required };
32889
+ ...versionWarning !== void 0 ? { versionWarning } : {}
32890
+ };
32891
+ });
32892
+ /** Refuses on config drift before replaying, so a stale config never runs the flow. */
32893
+ const replayFinishedProcess = (config, run, hash, how) => gen(function* () {
32894
+ const git = yield* GitService;
32895
+ const workspace = yield* Workspace;
32896
+ const host = yield* Host;
32897
+ const recorded = parseCommitMessage(run.episode.commits.find((c) => c.hash === hash)?.message ?? "").version;
32898
+ const versionWarning = versionWarningFor(recorded);
32899
+ const withWarning = (err) => versionWarning === void 0 ? err : /* @__PURE__ */ new Error(`${err.message}\n${versionWarning}`);
32900
+ const differing = yield* differingConfigPaths(git, workspace, host.root, config, run, hash);
32901
+ if (differing.length > 0) return yield* fail$8(withWarning(configRefusal(how.command, hash, differing, how.configReason)));
32902
+ const { setup } = yield* setupFor(config, run, workspace, host);
32903
+ const outcome = yield* promise(() => replayFor(setup));
32904
+ if (outcome.kind !== "ended") return yield* fail$8(withWarning(how.notEnded(outcome)));
32905
+ return {
32906
+ outcome,
32907
+ recorded,
32908
+ ...versionWarning !== void 0 ? { versionWarning } : {}
32909
+ };
32697
32910
  });
32698
32911
  //#endregion
32699
32912
  //#region src/cli/ToolCommands.ts
@@ -32753,7 +32966,7 @@ const runStraysCommand = (command, json, out) => gen(function* () {
32753
32966
  const narrator = yield* Narrator;
32754
32967
  const base = yield* resolveSide(git, command.base);
32755
32968
  const tip = yield* resolveSide(git, command.tip);
32756
- const touched = yield* git.diffPaths(base, tip);
32969
+ const touched = (yield* git.diffPaths(base, tip)).filter((path) => path !== PIN_PATH);
32757
32970
  const strays = strayPaths(touched, command.globs);
32758
32971
  const total = touched.length;
32759
32972
  if (json.kind === "select") yield* writeSelection(out, strays, json.path).pipe(catchIf((error) => error instanceof SelectorUsageError && /^\d+$/.test(json.path), () => void_));
@@ -33768,6 +33981,10 @@ const runAbandonCommand = (json, out) => gen(function* () {
33768
33981
  kind: "gitWrite",
33769
33982
  command: mixedResetTo(run.startParentHash)
33770
33983
  },
33984
+ {
33985
+ kind: "gitWrite",
33986
+ command: unpinScript(PIN_PATH)
33987
+ },
33771
33988
  {
33772
33989
  kind: "outcome",
33773
33990
  command: abandonedOutcome(restState, run.startParentHash, initial)
@@ -33925,7 +34142,7 @@ const runCodeThreadsCheckCommand = () => gen(function* () {
33925
34142
  if (rest.run.trace.length === 0) return;
33926
34143
  const git = yield* GitService;
33927
34144
  const workspace = yield* Workspace;
33928
- const lines = (yield* git.changedPaths(rest.run.diffBase)).filter((c) => c.status !== "D").flatMap(({ path }) => {
34145
+ const lines = (yield* git.changedPaths(rest.run.diffBase)).filter((c) => c.status !== "D" && c.path !== ".gtd/process.gtdpin").flatMap(({ path }) => {
33929
34146
  const { threads, findings } = parseCodeThreads(path, workspace.atPath(path) ?? "");
33930
34147
  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}`)];
33931
34148
  });