@pmelab/gtd 62.1.0 → 63.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtd",
3
- "version": "62.1.0",
3
+ "version": "63.0.0",
4
4
  "description": "Drive the gtd loop inside Claude Code: agent turns run as subagents, gtd decides every beat",
5
5
  "author": {
6
6
  "name": "Philipp Melab"
package/README.md CHANGED
@@ -641,7 +641,8 @@ writing `gtd bundle`'s output to `.gtd/process.gtdpin`, and its last, once the
641
641
  flow has ended, an unpin beat removing it — the commit that closes the process.
642
642
  Both are ordinary `script` beats a driver runs unattended; see
643
643
  [Pin and unpin beats](https://github.com/pmelab/gtd/blob/main/docs/driver.md#pin-and-unpin-beats).
644
- Histories with pin commits are history format 2, which an older gtd refuses.
644
+ Histories with pin commits are history format 2 or later, which an older gtd
645
+ refuses.
645
646
 
646
647
  While a process is pinned, the `gtd` on your `PATH` hands every command over to
647
648
  the pin, so editing the workflow or upgrading gtd mid-process changes nothing;
@@ -650,8 +651,11 @@ gtd notes on stderr when the live config differs from the pinned one. `ui`,
650
651
  always run live, and `gtd --live <command>` runs the live engine for any other,
651
652
  at the risk of misreading the process. To fix a workflow mid-process, edit it
652
653
  and run `gtd repin`: its script commits a fresh pin from the live engine and
653
- config, and the process continues on it. A pin in a format your gtd does not
654
- know is refused: upgrade gtd. See
654
+ config, and the process continues on it. Where the edited workflow no longer
655
+ replays the process, the repin continues from the divergence: the commits it
656
+ cannot replay stay as files, and the process rests at the step the edited
657
+ workflow reached (history format 3). A pin in a format your gtd does not know is
658
+ refused: upgrade gtd. See
655
659
  [Pinned processes](https://github.com/pmelab/gtd/blob/main/docs/cli.md#pinned-processes).
656
660
 
657
661
  ## License
@@ -22810,9 +22810,9 @@ const withoutPath = (tree, hidden) => {
22810
22810
  const OLDEST_READABLE_FORMAT = 1;
22811
22811
  const short$1 = (hash) => hash.slice(0, 7);
22812
22812
  const formatFault = (commits) => {
22813
- const foreign = commits.find((c) => c.format < OLDEST_READABLE_FORMAT || c.format > 2);
22813
+ const foreign = commits.find((c) => c.format < OLDEST_READABLE_FORMAT || c.format > 3);
22814
22814
  if (foreign === void 0) return void 0;
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`;
22815
+ return `gtd: commit ${short$1(foreign.hash)} is written in history format ${foreign.format}, but this gtd reads formats ${OLDEST_READABLE_FORMAT} to 3 — run a gtd release that reads format ${foreign.format} to continue this process`;
22816
22816
  };
22817
22817
  //#endregion
22818
22818
  //#region src/replay/Trailers.ts
@@ -22943,6 +22943,9 @@ const TRAILER_READERS = {
22943
22943
  "Gtd-Pin": (value, into) => {
22944
22944
  if (value === "add" || value === "remove") into.pin ??= value;
22945
22945
  },
22946
+ "Gtd-Anchor": (value, into) => {
22947
+ if (HASH_RE.test(value)) into.anchor ??= value;
22948
+ },
22946
22949
  "Gtd-Format": (value, into) => {
22947
22950
  if (FORMAT_RE.test(value)) into.format ??= Number(value);
22948
22951
  },
@@ -22966,6 +22969,7 @@ const parseCommitMessage = (message) => {
22966
22969
  kept: void 0,
22967
22970
  approved: void 0,
22968
22971
  pin: void 0,
22972
+ anchor: void 0,
22969
22973
  judge: [],
22970
22974
  marks: [],
22971
22975
  vars: {},
@@ -22995,9 +22999,10 @@ const formatCommitMessage = (spec) => {
22995
22999
  ...(spec.marks ?? []).map((m) => `Gtd-Mark: ${m.name}=${m.value}`),
22996
23000
  ...trailer("Approved", spec.approved && encodeApproval(spec.approved)),
22997
23001
  ...trailer("Pin", spec.pin),
23002
+ ...trailer("Anchor", spec.anchor),
22998
23003
  ...trailer("Branch", spec.branch),
22999
23004
  ...trailer("Version", spec.version),
23000
- `Gtd-Format: 2`
23005
+ `Gtd-Format: 3`
23001
23006
  ];
23002
23007
  return `${formatSubject(spec.actor, spec.to, spec.from)}\n\n${lines.join("\n")}`;
23003
23008
  };
@@ -23091,6 +23096,27 @@ const budgeted = (evidence, budgetBytes) => {
23091
23096
  };
23092
23097
  const sameSkills = (a, b) => (a?.length ?? 0) === (b?.length ?? 0) && (a ?? []).every((skill, i) => skill === b?.[i]);
23093
23098
  const samePersona = (a, b) => a.model === b.model && a.system === b.system && sameSkills(a.skills, b.skills) && sameAccess(a.access, b.access);
23099
+ const anchorOf = (parsed) => parsed.pin === "add" ? parsed.anchor : void 0;
23100
+ const isAnchor = (commit) => anchorOf(commit.parsed) !== void 0;
23101
+ /**
23102
+ * The commits every anchor sets aside — those between the commit it names and
23103
+ * itself — or a fault for an anchor naming no commit still read.
23104
+ */
23105
+ const setAsideBy = (base, all) => {
23106
+ const setAside = /* @__PURE__ */ new Set();
23107
+ if (all.every((c) => anchorOf(c.parsed) === void 0)) return { setAside };
23108
+ let kept = [];
23109
+ for (const commit of all) {
23110
+ const anchor = anchorOf(commit.parsed);
23111
+ if (anchor !== void 0) {
23112
+ const at = anchor === base ? -1 : kept.indexOf(anchor);
23113
+ if (anchor !== base && at === -1) return { fault: `gtd: commit ${short(commit.hash)} anchors the process at ${short(anchor)}, which is no commit replay reads in this episode` };
23114
+ for (const hash of kept.splice(at + 1)) setAside.add(hash);
23115
+ }
23116
+ kept.push(commit.hash);
23117
+ }
23118
+ return { setAside };
23119
+ };
23094
23120
  /**
23095
23121
  * Replay one episode: run its entry flow, answering each step from the
23096
23122
  * episode's commits in order, until a step has no commit left — the rest.
@@ -23164,19 +23190,39 @@ const withoutPin = (input) => {
23164
23190
  };
23165
23191
  const replay = async (raw) => {
23166
23192
  const input = withoutPin(raw);
23167
- const commits = input.episode.commits.map((c) => ({
23193
+ const all = input.episode.commits.map((c) => ({
23168
23194
  ...c,
23169
23195
  parsed: parseCommitMessage(c.message)
23170
23196
  }));
23197
+ const resolved = setAsideBy(input.episode.base.hash, all);
23198
+ if ("fault" in resolved) return {
23199
+ kind: "failed",
23200
+ message: resolved.fault
23201
+ };
23202
+ const setAside = resolved.setAside;
23203
+ const commits = all.filter((c) => !setAside.has(c.hash));
23204
+ const pinned = (raw.episode.commits.at(-1)?.tree ?? raw.episode.base.tree).paths().includes(PIN_PATH);
23171
23205
  const treeAt = (hash) => hash === input.episode.base.hash ? input.episode.base.tree : commits.find((c) => c.hash === hash)?.tree ?? (hash === input.start ? input.startTree : void 0);
23172
23206
  let cursor = 0;
23173
23207
  let pendingUsed = false;
23174
23208
  let position = input.episode.base;
23175
23209
  const skipPins = () => {
23176
- while (cursor < commits.length && commits[cursor].parsed.pin !== void 0) cursor++;
23210
+ while (cursor < commits.length && commits[cursor].parsed.pin !== void 0) {
23211
+ const commit = commits[cursor];
23212
+ cursor++;
23213
+ if (!isAnchor(commit)) continue;
23214
+ position = commit;
23215
+ const to = commit.parsed.parsed?.to;
23216
+ expectedNext = to === void 0 || to === UNPIN_STATE ? void 0 : {
23217
+ name: to,
23218
+ hash: commit.hash
23219
+ };
23220
+ anchorPending = expectedNext !== void 0;
23221
+ }
23177
23222
  };
23178
23223
  let previousPosition = input.episode.base;
23179
23224
  let expectedNext;
23225
+ let anchorPending = false;
23180
23226
  const occurrences = /* @__PURE__ */ new Map();
23181
23227
  const personas = /* @__PURE__ */ new Map();
23182
23228
  const scopes = [];
@@ -23319,14 +23365,18 @@ const replay = async (raw) => {
23319
23365
  }
23320
23366
  return samePersona(seen, identity) ? void 0 : `gtd: "${step.name}" runs with a different model, system prompt, skills or file access than an earlier agent step in its conversation in memory scope "${step.memoryScope || "root"}" — a conversation has one identity`;
23321
23367
  };
23322
- const DIVERGED = "the workflow changed under this process; run `gtd abandon` to start over";
23368
+ const DIVERGED = pinned ? "the workflow changed under this process; run `gtd repin` to continue under it from there, or `gtd abandon` to start over" : "the workflow changed under this process; run `gtd abandon` to start over";
23369
+ const diverged = (message, reached) => ({
23370
+ kind: "divergence",
23371
+ message: `${message} — ${DIVERGED}`,
23372
+ at: position.hash,
23373
+ reached: reached?.name
23374
+ });
23323
23375
  const blockerAt = (step) => {
23324
23376
  const expected = expectedNext;
23325
23377
  expectedNext = void 0;
23326
- if (expected !== void 0 && expected.name !== step.name) return {
23327
- kind: "divergence",
23328
- message: `gtd: commit ${short(expected.hash)} names "${expected.name}" as the next step, but replay reached "${step.name}" — ${DIVERGED}`
23329
- };
23378
+ anchorPending = false;
23379
+ if (expected !== void 0 && expected.name !== step.name) return diverged(`gtd: commit ${short(expected.hash)} names "${expected.name}" as the next step, but replay reached "${step.name}"`, step);
23330
23380
  const error = unknownOptions(step) ?? approveConflict(step) ?? modeWithoutFile(step) ?? checkIdentity(step);
23331
23381
  return error === void 0 ? void 0 : {
23332
23382
  kind: "failed",
@@ -23337,33 +23387,30 @@ const replay = async (raw) => {
23337
23387
  if (step.request.kind !== "human" || step.request.options.approve !== true) return void 0;
23338
23388
  const approved = commit.parsed.approved;
23339
23389
  if (approved !== void 0 && formatStepId(approved.step) === expected) return void 0;
23340
- return {
23341
- kind: "divergence",
23342
- message: `gtd: commit ${short(commit.hash)} completes approval gate "${expected}" but records no Gtd-Approved for it — ${DIVERGED}`
23343
- };
23390
+ return diverged(`gtd: commit ${short(commit.hash)} completes approval gate "${expected}" but records no Gtd-Approved for it`, step);
23344
23391
  };
23345
23392
  const consumeCommit = (step, commit) => {
23346
23393
  const recorded = commit.parsed.step;
23347
23394
  const expected = formatStepId(step.id);
23348
23395
  if (recorded === void 0 || formatStepId(recorded) !== expected) {
23349
23396
  const what = recorded === void 0 ? "records no step" : `records step "${formatStepId(recorded)}"`;
23350
- return finish({
23351
- kind: "divergence",
23352
- message: `gtd: commit ${short(commit.hash)} ${what}, but replay expected "${expected}" — ${DIVERGED}`
23353
- });
23397
+ return finish(diverged(`gtd: commit ${short(commit.hash)} ${what}, but replay expected "${expected}"`, step));
23354
23398
  }
23355
23399
  const unapproved = missingApproval(step, commit, expected);
23356
23400
  if (unapproved !== void 0) return finish(unapproved);
23357
23401
  cursor++;
23358
23402
  advance(commit);
23403
+ expectNextOf(commit);
23359
23404
  skipPins();
23360
23405
  noteKept(step, commit);
23406
+ return replyFor(step.request, commit.parsed.judge);
23407
+ };
23408
+ const expectNextOf = (commit) => {
23361
23409
  const to = commit.parsed.parsed?.to;
23362
23410
  if (to !== void 0 && to !== UNPIN_STATE) expectedNext = {
23363
23411
  name: to,
23364
23412
  hash: commit.hash
23365
23413
  };
23366
- return replyFor(step.request, commit.parsed.judge);
23367
23414
  };
23368
23415
  const noteKept = (step, commit) => {
23369
23416
  if (commit.parsed.kept === void 0) return;
@@ -23435,10 +23482,8 @@ const replay = async (raw) => {
23435
23482
  };
23436
23483
  const endOfFlow = (via) => {
23437
23484
  skipPins();
23438
- return cursor < commits.length ? {
23439
- kind: "divergence",
23440
- message: `gtd: the flow ended, but commit ${short(commits[cursor].hash)} continues the episode — ${DIVERGED}`
23441
- } : {
23485
+ if (anchorPending && cursor === commits.length) return diverged(`gtd: the flow ended, but commit ${short(expectedNext.hash)} continues the process at "${expectedNext.name}"`, void 0);
23486
+ return cursor < commits.length ? diverged(`gtd: the flow ended, but commit ${short(commits[cursor].hash)} continues the episode`, void 0) : {
23442
23487
  kind: "ended",
23443
23488
  via,
23444
23489
  trace,
@@ -23448,6 +23493,7 @@ const replay = async (raw) => {
23448
23493
  };
23449
23494
  const changesSince = (hash) => {
23450
23495
  const tree = treeAt(hash);
23496
+ if (setAside.has(hash)) throw new Error(`gtd: changesSince(${hash}): a repin set ${short(hash)} aside — pass a hash this run read from head() or start()`);
23451
23497
  if (tree === void 0) throw new Error(`gtd: changesSince(${hash}): ${hash} is not the episode base or one of its commits — pass a hash this run read from head() or start(), not one captured earlier, read from state, or from another branch`);
23452
23498
  return changesBetween(tree, position.tree);
23453
23499
  };
@@ -25378,11 +25424,21 @@ const approvalEntryOf = (hash, message) => message.approved === void 0 ? [] : [{
25378
25424
  email: message.approved.email,
25379
25425
  commit: hash
25380
25426
  }];
25427
+ const setAsideIn = (history, location) => {
25428
+ const anchored = setAsideBy(hashAt(history, location.baseIndex) ?? EMPTY_TREE$1, history.slice(location.baseIndex + 1).map((c) => ({
25429
+ hash: c.hash,
25430
+ parsed: parseCommitMessage(c.message)
25431
+ })));
25432
+ return "setAside" in anchored ? anchored.setAside : /* @__PURE__ */ new Set();
25433
+ };
25381
25434
  const buildRun = (history, location, closingHash) => {
25382
25435
  const allProcessCommits = history.slice(location.processStart);
25383
25436
  const allParsed = allProcessCommits.map((c) => parseCommitMessage(c.message));
25384
25437
  const processCommits = allProcessCommits.filter((_, i) => allParsed[i].pin !== "add");
25385
25438
  const parsed = allParsed.filter((m) => m.pin !== "add");
25439
+ const setAside = setAsideIn(history, location);
25440
+ const answering = parsed.filter((_, i) => !setAside.has(processCommits[i].hash));
25441
+ const answeringCommits = processCommits.filter((c) => !setAside.has(c.hash));
25386
25442
  const first = parsed[0];
25387
25443
  const startParentHash = hashAt(history, location.processStart - 1) ?? EMPTY_TREE$1;
25388
25444
  const head = history[history.length - 1];
@@ -25394,10 +25450,10 @@ const buildRun = (history, location, closingHash) => {
25394
25450
  diffBase: first?.reviewBase ?? startParentHash,
25395
25451
  trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i])),
25396
25452
  costEntries: parsed.flatMap((m, i) => costEntriesOf(processCommits[i].hash, m)),
25397
- judgeVerdicts: parsed.flatMap((m) => m.judge),
25453
+ judgeVerdicts: answering.flatMap((m) => m.judge),
25398
25454
  pinCommitted: allParsed.some((m) => m.pin === "add"),
25399
- marks: parsed.flatMap((m) => m.marks),
25400
- approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i])),
25455
+ marks: answering.flatMap((m) => m.marks),
25456
+ approvals: answeringCommits.flatMap((c, i) => approvalEntryOf(c.hash, answering[i])),
25401
25457
  pinnedVars: { ...first?.vars },
25402
25458
  headTurn: headTurnOf(head),
25403
25459
  closingHash,
@@ -25826,6 +25882,43 @@ const restAt = (ref, startOverrides = {}) => gen(function* () {
25826
25882
  };
25827
25883
  });
25828
25884
  const currentRest = restAt(void 0);
25885
+ /**
25886
+ * Where `gtd repin` anchors a pinned process the live workflow no longer
25887
+ * replays: the commit replay last stood on, and the step the live workflow
25888
+ * reaches from there on HEAD's tree — the unpin when the flow ends instead.
25889
+ * `undefined` when there is nothing to anchor: no divergence, or no pin.
25890
+ */
25891
+ const repinAnchor = gen(function* () {
25892
+ const git = yield* GitService;
25893
+ const config = yield* (yield* ConfigService).load;
25894
+ const workspace = yield* Workspace;
25895
+ const run = yield* computeProcessRun(git, config.workflow);
25896
+ const { def, setup } = yield* setupFor(config, run, workspace, yield* Host);
25897
+ const diverged = yield* promise(() => replayFor(setup));
25898
+ if (diverged.kind !== "divergence" || !headHasPin(workspace, run)) return void 0;
25899
+ const trial = {
25900
+ hash: "",
25901
+ message: formatCommitMessage({
25902
+ actor: "check",
25903
+ from: PIN_STATES.add,
25904
+ to: PIN_STATES.add,
25905
+ pin: "add",
25906
+ anchor: diverged.at
25907
+ }),
25908
+ tree: setup.episode.at(-1)?.tree ?? setup.base.tree
25909
+ };
25910
+ const outcome = yield* promise(() => replayFor({
25911
+ ...setup,
25912
+ episode: [...setup.episode, trial]
25913
+ }));
25914
+ if (outcome.kind !== "divergence") return yield* fail$4(replayError(outcome) ?? /* @__PURE__ */ new Error("gtd repin: replay found no step"));
25915
+ const to = outcome.reached ?? PIN_STATES.remove;
25916
+ if (run.workflow === void 0 && to === def.initial) return yield* fail$4(/* @__PURE__ */ new Error(`gtd repin: the live workflow continues this process at "${to}", the default workflow's first step, where a finished process waits — run \`gtd abandon\` to start over`));
25917
+ return {
25918
+ at: diverged.at,
25919
+ to
25920
+ };
25921
+ });
25829
25922
  /** The step the process rests at and the trace that reached it — at an unpin, the flow's last step. */
25830
25923
  const restingOn = (outcome, pin) => {
25831
25924
  if (outcome.kind === "rest") return {
@@ -27339,20 +27432,28 @@ const runRestoreCommand = (json, out) => gen(function* () {
27339
27432
  ]).required;
27340
27433
  yield* writeScript(json, out, combinedScript(required, ""));
27341
27434
  });
27342
- /**
27343
- * `gtd repin`: the pin beat's script and landing, on demand. It runs live,
27344
- * so reading the rest first refuses a live workflow that cannot replay the
27345
- * process before any pin is written.
27346
- */
27347
- const runRepinCommand = (json, out) => gen(function* () {
27435
+ /** The step an ordinary repin rests at — the one the process rests at now. */
27436
+ const repinTarget = gen(function* () {
27348
27437
  const rest = yield* currentRest;
27349
27438
  const refusal = rest.pin === "add" ? "the next beat pins the process already — run it (`gtd next --json=content`), then land" : rest.pin === "remove" ? "the flow has ended — its unpin beat is next, so there is nothing to repin" : noProcessUnderway(rest) ? "no gtd process is underway — nothing to repin" : void 0;
27350
27439
  if (refusal !== void 0) return yield* fail$4(/* @__PURE__ */ new Error(`gtd repin: ${refusal}`));
27440
+ return rest.step.name;
27441
+ });
27442
+ /**
27443
+ * `gtd repin`: the pin beat's script and landing, on demand. It runs live: a
27444
+ * pinned process the live workflow no longer replays is anchored where it
27445
+ * diverged, and any other failure to read the rest refuses before any pin is
27446
+ * written.
27447
+ */
27448
+ const runRepinCommand = (json, out) => gen(function* () {
27449
+ const anchor = yield* repinAnchor;
27450
+ const to = anchor?.to ?? (yield* repinTarget);
27351
27451
  const message = formatCommitMessage({
27352
27452
  actor: "check",
27353
27453
  from: PIN_STATES.add,
27354
- to: rest.step.name,
27355
- pin: "add"
27454
+ to,
27455
+ pin: "add",
27456
+ ...anchor !== void 0 ? { anchor: anchor.at } : {}
27356
27457
  });
27357
27458
  yield* writeScript(json, out, combinedScript(emitScripts([
27358
27459
  {
@@ -27365,7 +27466,7 @@ const runRepinCommand = (json, out) => gen(function* () {
27365
27466
  },
27366
27467
  {
27367
27468
  kind: "outcome",
27368
- command: transitionOutcome(PIN_STATES.add, rest.step.name)
27469
+ command: transitionOutcome(PIN_STATES.add, to)
27369
27470
  }
27370
27471
  ]).required, ""));
27371
27472
  });
@@ -28039,7 +28140,7 @@ With no <name>, list every workflow and door it can start. An unknown <name>, th
28039
28140
  arity: "none",
28040
28141
  group: "Start and stop",
28041
28142
  summary: "Pin the process to the live gtd and workflow",
28042
- details: `Switch the process underway to the live engine and workflow, the way out when the workflow needs a fix mid-process: the printed script runs the pin beat again, writing \`gtd bundle\`'s output to .gtd/process.gtdpin and committing that file alone. Always runs on the gtd on PATH, never on the pin. Refuses when no process is underway, when the next beat is a pin or unpin beat already, or when the live workflow does not replay the process.
28143
+ details: `Switch the process underway to the live engine and workflow, the way out when the workflow needs a fix mid-process: the printed script runs the pin beat again, writing \`gtd bundle\`'s output to .gtd/process.gtdpin and committing that file alone. Always runs on the gtd on PATH, never on the pin. When the live workflow no longer replays the process, the commit continues from the divergence: a Gtd-Anchor trailer names the last commit it replays, the later commits stay as files only, and the process rests at the step the live workflow reached there. Refuses when no process is underway, when the next beat is a pin or unpin beat already, when an unpinned process diverges, or when the live workflow would continue at the default workflow's first step.
28043
28144
 
28044
28145
  s=$(gtd repin --json=script) && sh -c "$s"`
28045
28146
  },