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