@pmelab/gtd 26.0.0 → 26.1.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 (43) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +5 -0
  3. package/dist/flows.mjs +15 -1
  4. package/dist/gtd.bundle.mjs +134 -18
  5. package/dist/testkit.mjs +284 -55
  6. package/package.json +1 -1
  7. package/src/cli/Cli.test.ts +3 -3
  8. package/src/cli/History.test.ts +2 -2
  9. package/src/cli/Program.test.ts +10 -12
  10. package/src/edge/Edge.test.ts +113 -16
  11. package/src/edge/Edge.ts +22 -6
  12. package/src/emit/GitScript.test.ts +65 -2
  13. package/src/emit/GitScript.ts +34 -1
  14. package/src/emit/index.ts +1 -0
  15. package/src/flags/index.ts +0 -1
  16. package/src/flows/runtime.test.ts +33 -0
  17. package/src/flows/runtime.ts +31 -0
  18. package/src/platform/RetainedHistory.test.ts +9 -0
  19. package/src/platform/RetainedHistory.ts +3 -0
  20. package/src/platform/Workspace.test.ts +1 -1
  21. package/src/platform/index.ts +1 -0
  22. package/src/replay/RefName.test.ts +40 -0
  23. package/src/replay/RefName.ts +19 -0
  24. package/src/replay/Replay.test.ts +193 -2
  25. package/src/replay/Replay.ts +51 -6
  26. package/src/replay/Trailers.test.ts +26 -0
  27. package/src/replay/Trailers.ts +8 -0
  28. package/src/replay/index.ts +1 -0
  29. package/src/step/LandStep.ts +1 -0
  30. package/src/step/RepoSnapshot.ts +2 -0
  31. package/src/step/planStart.test.ts +5 -5
  32. package/src/step/planStep.test.ts +53 -0
  33. package/src/step/planStep.ts +7 -1
  34. package/src/testkit/EmittedScriptRecognizer.test.ts +57 -29
  35. package/src/testkit/EmittedScriptRecognizer.ts +26 -4
  36. package/src/testkit/EmittedScripts.test.ts +182 -0
  37. package/src/testkit/FakeGitOperations.ts +8 -2
  38. package/src/testkit/GitTiers.ts +103 -24
  39. package/src/testkit/InMemRepo.ts +57 -12
  40. package/src/testkit/TestRepo.ts +2 -2
  41. package/src/workflow/Workflow.ts +1 -0
  42. package/src/workflow/discovery.test.ts +1 -1
  43. package/src/workflows/shared/text.fixture.ts +1 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtd",
3
- "version": "26.0.0",
3
+ "version": "26.1.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
@@ -576,6 +576,11 @@ git config --add remote.origin.fetch 'refs/gtd/history/*:refs/gtd/history/*'
576
576
  The second line makes every plain `git fetch` include them. It has no leading
577
577
  `+`, so a conflicting local ref fails instead of being overwritten.
578
578
 
579
+ A step declared with `keep: true` files its turn under `refs/gtd/kept/` instead
580
+ of on the branch (see [Kept tips](docs/configuration.md#kept-tips)). Those refs
581
+ are not process history: `gtd history` does not list them, and the
582
+ `refs/gtd/history/*` refspecs above neither push nor fetch them.
583
+
579
584
  ## Entrypoints
580
585
 
581
586
  gtd is one package, `@pmelab/gtd`, and every entrypoint shares its version and
package/dist/flows.mjs CHANGED
@@ -109,6 +109,19 @@ const changesSince = (hash, pattern) => {
109
109
  const all = context.changesSince(hash);
110
110
  return toChanges(pattern === void 0 ? all : all.filter((c) => context.matches(c.path, pattern)));
111
111
  };
112
+ /**
113
+ * The latest kept tip of step `name` (scoped from here, same as `agent()`) in
114
+ * this episode, or `undefined` when none.
115
+ */
116
+ const kept = (name) => {
117
+ const context = ctx();
118
+ if (typeof context.kept !== "function") throw new Error("gtd: kept() needs a newer gtd — upgrade the gtd that runs it");
119
+ const tip = context.kept(name);
120
+ return tip === void 0 ? void 0 : {
121
+ hash: tip.hash,
122
+ changes: toChanges(tip.changes)
123
+ };
124
+ };
112
125
  /** The commit the process stands on at this point of the flow. */
113
126
  const head = () => ctx().head();
114
127
  /** The process's diff base: the commit before it began, or the base `gtd --workflow` fixed. */
@@ -502,6 +515,7 @@ var flows_exports = /* @__PURE__ */ __exportAll({
502
515
  installContext: () => installContext,
503
516
  isFactoryWorkflow: () => isFactoryWorkflow,
504
517
  judge: () => judge,
518
+ kept: () => kept,
505
519
  moveScript: () => moveScript,
506
520
  numeric: () => numeric,
507
521
  openQuestions: () => openQuestions,
@@ -530,4 +544,4 @@ var flows_exports = /* @__PURE__ */ __exportAll({
530
544
  wrote: () => wrote
531
545
  });
532
546
  //#endregion
533
- export { FLOWS_PROTOCOL, FlowsProtocolError, accessFor, agent, answered, changes, changesSince, check, checkScript, codeChanges, codeThreads, configProblems, configure, defineConfig, defineWorkflow, env, glob, hasThreadFor, head, human, installContext, isFactoryWorkflow, judge, moveScript, numeric, openQuestions, protocolMismatch, quote, read, refuse, removeScript, requireAnswers, requireProgress, requireReplies, requireRevert, requireThreadsClosed, restart, restoreScript, revertScript, run, scope, sectionBodies, sections, showScript, skillsFor, start, flows_exports as t, threads, vars, wrote };
547
+ export { FLOWS_PROTOCOL, FlowsProtocolError, accessFor, agent, answered, changes, changesSince, check, checkScript, codeChanges, codeThreads, configProblems, configure, defineConfig, defineWorkflow, env, glob, hasThreadFor, head, human, installContext, isFactoryWorkflow, judge, kept, moveScript, numeric, openQuestions, protocolMismatch, quote, read, refuse, removeScript, requireAnswers, requireProgress, requireReplies, requireRevert, requireThreadsClosed, restart, restoreScript, revertScript, run, scope, sectionBodies, sections, showScript, skillsFor, start, flows_exports as t, threads, vars, wrote };
@@ -20943,6 +20943,8 @@ const HISTORY_REF = "refs/worktree/gtd/history";
20943
20943
  /** One ref per finished process, kept past the branch's deletion; distinct from the per-worktree `HISTORY_REF`. */
20944
20944
  const PROCESS_HISTORY_PREFIX = "refs/gtd/history/";
20945
20945
  const processHistoryRef = (hash) => `${PROCESS_HISTORY_PREFIX}${hash}`;
20946
+ /** Directory of a step's turns set aside by `keep: true`, each at `<this>/<kept hash>`; own namespace, so history list/show/fetch never see it. */
20947
+ const keptRef = (entry, step) => `refs/gtd/kept/${entry}/${step}`;
20946
20948
  const readRetainedHistory = (git) => git.readRefOption(HISTORY_REF);
20947
20949
  /**
20948
20950
  * Whether resetting to `tipHash` is safe: HEAD must be an ancestor of
@@ -21715,6 +21717,19 @@ const changesSince = (hash, pattern) => {
21715
21717
  const all = context.changesSince(hash);
21716
21718
  return toChanges(pattern === void 0 ? all : all.filter((c) => context.matches(c.path, pattern)));
21717
21719
  };
21720
+ /**
21721
+ * The latest kept tip of step `name` (scoped from here, same as `agent()`) in
21722
+ * this episode, or `undefined` when none.
21723
+ */
21724
+ const kept = (name) => {
21725
+ const context = ctx();
21726
+ if (typeof context.kept !== "function") throw new Error("gtd: kept() needs a newer gtd — upgrade the gtd that runs it");
21727
+ const tip = context.kept(name);
21728
+ return tip === void 0 ? void 0 : {
21729
+ hash: tip.hash,
21730
+ changes: toChanges(tip.changes)
21731
+ };
21732
+ };
21718
21733
  /** The commit the process stands on at this point of the flow. */
21719
21734
  const head = () => ctx().head();
21720
21735
  /** The process's diff base: the commit before it began, or the base `gtd --workflow` fixed. */
@@ -22108,6 +22123,7 @@ var flows_exports = /* @__PURE__ */ __exportAll({
22108
22123
  installContext: () => installContext,
22109
22124
  isFactoryWorkflow: () => isFactoryWorkflow,
22110
22125
  judge: () => judge,
22126
+ kept: () => kept,
22111
22127
  moveScript: () => moveScript,
22112
22128
  numeric: () => numeric,
22113
22129
  openQuestions: () => openQuestions,
@@ -37491,8 +37507,24 @@ var init_wire = __esmMin((() => {
37491
37507
  init_Glob();
37492
37508
  }));
37493
37509
  //#endregion
37494
- //#region src/replay/Tree.ts
37510
+ //#region src/replay/RefName.ts
37495
37511
  init_wire();
37512
+ const FORBIDDEN = /[\u0000- \u007f~^:?*[\\]/;
37513
+ /** Why `name` cannot be (part of) a git ref name, or `undefined` when it can. */
37514
+ const refNameFault = (name) => {
37515
+ if (name === "") return "it is empty";
37516
+ if (FORBIDDEN.test(name)) return "it contains a space, control character or one of ~^:?*[\\";
37517
+ if (name.includes("..")) return "it contains \"..\"";
37518
+ if (name.includes("@{")) return "it contains \"@{\"";
37519
+ if (name.endsWith(".")) return "it ends with \".\"";
37520
+ for (const part of name.split("/")) {
37521
+ if (part === "") return "it has an empty \"/\" component";
37522
+ if (part.startsWith(".")) return "a component starts with \".\"";
37523
+ if (part.endsWith(".lock")) return "a component ends with \".lock\"";
37524
+ }
37525
+ };
37526
+ //#endregion
37527
+ //#region src/replay/Tree.ts
37496
37528
  const treeFromRecord = (files) => ({
37497
37529
  paths: () => Object.keys(files).sort(),
37498
37530
  read: (path) => Object.hasOwn(files, path) ? files[path] : void 0
@@ -37586,6 +37618,7 @@ const decodePath = (raw) => {
37586
37618
  return typeof path === "string" && path !== "" && !CONTROL_CHARACTER$1.test(path) ? path : void 0;
37587
37619
  };
37588
37620
  const FORMAT_RE = /^[1-9][0-9]*$/;
37621
+ const HASH_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
37589
37622
  const TRAILER_READERS = {
37590
37623
  "Gtd-Step": (value, into) => {
37591
37624
  into.step ??= parseStepId(value);
@@ -37615,6 +37648,9 @@ const TRAILER_READERS = {
37615
37648
  "Gtd-Version": (value, into) => {
37616
37649
  if (value !== "") into.version ??= value;
37617
37650
  },
37651
+ "Gtd-Kept": (value, into) => {
37652
+ if (HASH_RE.test(value)) into.kept ??= value;
37653
+ },
37618
37654
  "Gtd-Format": (value, into) => {
37619
37655
  if (FORMAT_RE.test(value)) into.format ??= Number(value);
37620
37656
  },
@@ -37635,6 +37671,7 @@ const parseCommitMessage = (message) => {
37635
37671
  reviewBase: void 0,
37636
37672
  branch: void 0,
37637
37673
  version: void 0,
37674
+ kept: void 0,
37638
37675
  judge: [],
37639
37676
  vars: {},
37640
37677
  config: {},
@@ -37749,7 +37786,8 @@ const KNOWN_OPTIONS = {
37749
37786
  ...STEERING_OPTIONS,
37750
37787
  "model",
37751
37788
  "system",
37752
- "allowEmpty"
37789
+ "allowEmpty",
37790
+ "keep"
37753
37791
  ]),
37754
37792
  human: /* @__PURE__ */ new Set([
37755
37793
  ...STEERING_OPTIONS,
@@ -37781,6 +37819,7 @@ const replay = async (input) => {
37781
37819
  const scopeCallIds = [];
37782
37820
  let scopeCallCount = 0;
37783
37821
  const trace = [];
37822
+ const keptTips = /* @__PURE__ */ new Map();
37784
37823
  let landed;
37785
37824
  let outcome;
37786
37825
  let signal;
@@ -37940,6 +37979,12 @@ const replay = async (input) => {
37940
37979
  }
37941
37980
  cursor++;
37942
37981
  advance(commit);
37982
+ if (commit.parsed.kept !== void 0) if (commit.kept === void 0) keptTips.delete(step.name);
37983
+ else keptTips.set(step.name, {
37984
+ hash: commit.kept.hash,
37985
+ before: commit.tree,
37986
+ after: commit.kept.tree
37987
+ });
37943
37988
  const to = commit.parsed.parsed?.to;
37944
37989
  if (to !== void 0) expectedNext = {
37945
37990
  name: to,
@@ -37947,6 +37992,21 @@ const replay = async (input) => {
37947
37992
  };
37948
37993
  return replyFor(step.request, commit.parsed.judge);
37949
37994
  };
37995
+ const landPending = (step, pending) => {
37996
+ pendingUsed = true;
37997
+ landed = step;
37998
+ const keeping = step.request.kind === "agent" && step.request.options.keep === true;
37999
+ if (keeping) keptTips.set(step.name, {
38000
+ hash: "",
38001
+ before: position.tree,
38002
+ after: pending.tree
38003
+ });
38004
+ advance({
38005
+ hash: "",
38006
+ tree: keeping ? position.tree : pending.tree
38007
+ });
38008
+ return replyFor(step.request, pending.verdicts ?? []);
38009
+ };
37950
38010
  const answer = (step) => {
37951
38011
  while (cursor < commits.length && isAttemptAt(commits[cursor], step.name)) cursor++;
37952
38012
  const commit = commits[cursor];
@@ -37957,17 +38017,18 @@ const replay = async (input) => {
37957
38017
  trace,
37958
38018
  landed
37959
38019
  });
37960
- pendingUsed = true;
37961
- landed = step;
37962
- advance({
37963
- hash: "",
37964
- tree: input.pending.tree
37965
- });
37966
- return replyFor(step.request, input.pending.verdicts ?? []);
38020
+ return landPending(step, input.pending);
37967
38021
  };
37968
38022
  const requestFault = (request) => {
37969
38023
  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)`;
37970
- return request.kind === "agent" ? scopeAccessFault() : void 0;
38024
+ if (request.kind !== "agent") return void 0;
38025
+ const name = scoped(request.name);
38026
+ if (request.options.keep === true) {
38027
+ if (request.options.allowEmpty === true) return `gtd: step "${name}": keep: true cannot be combined with allowEmpty — a turn that changes nothing has nothing to keep`;
38028
+ const refFault = name.includes("/") ? "it contains \"/\"" : refNameFault(name);
38029
+ if (refFault !== void 0) return `gtd: step "${name}": keep: true files the turn under a git ref named after the step, but ${refFault}`;
38030
+ }
38031
+ return scopeAccessFault();
37971
38032
  };
37972
38033
  const handleStep = async (request) => {
37973
38034
  if (outcome !== void 0) throw new Stop();
@@ -38050,6 +38111,13 @@ const replay = async (input) => {
38050
38111
  faults: findings.filter((f) => f.message.startsWith(`Code thread at ${c.path}:${t.line + 1}: `)).map((f) => f.message)
38051
38112
  }));
38052
38113
  }),
38114
+ kept: (name) => {
38115
+ const tip = keptTips.get(scoped(name));
38116
+ return tip === void 0 ? void 0 : {
38117
+ hash: tip.hash,
38118
+ changes: changesBetween(tip.before, tip.after)
38119
+ };
38120
+ },
38053
38121
  vars: input.vars,
38054
38122
  env: input.env,
38055
38123
  start: () => input.start,
@@ -39019,7 +39087,11 @@ const planStep = (snapshot, opts = {}) => {
39019
39087
  ...uncheckStep(snapshot),
39020
39088
  {
39021
39089
  kind: "gitWrite",
39022
- write: {
39090
+ write: landing.keepTurn !== void 0 ? {
39091
+ kind: "keepTurn",
39092
+ ref: landing.keepTurn,
39093
+ message
39094
+ } : {
39023
39095
  kind: "commitAll",
39024
39096
  message
39025
39097
  }
@@ -39499,6 +39571,7 @@ const promptDef = (common, request, skills, access) => ({
39499
39571
  ...optional$1("model", request.options.model),
39500
39572
  ...optional$1("system", request.options.system),
39501
39573
  ...optional$1("allowEmpty", request.options.allowEmpty),
39574
+ ...optional$1("keep", request.options.keep),
39502
39575
  ...optional$1("skills", skills),
39503
39576
  ...optional$1("access", access)
39504
39577
  });
@@ -39613,11 +39686,21 @@ const setupFor = (config, run, workspace, host, startOverrides = {}) => gen(func
39613
39686
  hash: run.episode.base,
39614
39687
  tree: commitTree(workspace, run.episode.base)
39615
39688
  };
39616
- const episode = run.episode.commits.map((c) => ({
39617
- hash: c.hash,
39618
- message: c.message,
39619
- tree: commitTree(workspace, c.hash)
39620
- }));
39689
+ const git = yield* GitService;
39690
+ const episode = [];
39691
+ for (const c of run.episode.commits) {
39692
+ const tip = parseCommitMessage(c.message).kept;
39693
+ const exists = tip !== void 0 && isSome(yield* git.readRefOption(`${tip}^{commit}`));
39694
+ episode.push({
39695
+ hash: c.hash,
39696
+ message: c.message,
39697
+ tree: commitTree(workspace, c.hash),
39698
+ ...exists ? { kept: {
39699
+ hash: tip,
39700
+ tree: commitTree(workspace, tip)
39701
+ } } : {}
39702
+ });
39703
+ }
39621
39704
  let pendingMemo;
39622
39705
  return {
39623
39706
  resolved,
@@ -39793,6 +39876,7 @@ const closingTrailers = (branch) => ({
39793
39876
  ...isSome(branch) ? { branch: branch.value } : {}
39794
39877
  });
39795
39878
  const isSettledNoop = (rest, to) => rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state;
39879
+ const keptTurn = (rest) => rest.stepDef.keep === true && rest.changes.length > 0 ? { keepTurn: keptRef(rest.run.startHash, rest.state) } : {};
39796
39880
  const landingTo = (rest, to, branch) => {
39797
39881
  if (isSettledNoop(rest, to)) return {
39798
39882
  kind: "noop",
@@ -39817,7 +39901,8 @@ const landingTo = (rest, to, branch) => {
39817
39901
  } : {},
39818
39902
  ...finishes ? closingTrailers(branch) : {}
39819
39903
  },
39820
- ...finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {}
39904
+ ...finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {},
39905
+ ...keptTurn(rest)
39821
39906
  };
39822
39907
  };
39823
39908
  /**
@@ -39954,7 +40039,7 @@ const commitAllowEmpty = (message) => {
39954
40039
  return [
39955
40040
  `if ! out=$(git commit --allow-empty -m ${m} 2>&1); then`,
39956
40041
  ` case "$out" in`,
39957
- ` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m} ;;`,
40042
+ ` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m} || exit 1 ;;`,
39958
40043
  ` *) printf '%s\\n' "$out" >&2; exit 1 ;;`,
39959
40044
  ` esac`,
39960
40045
  `fi`
@@ -39964,6 +40049,36 @@ const commitAllowEmpty = (message) => {
39964
40049
  const commitAll = (message) => `git add -A || exit 1\n${commitAllowEmpty(message)}`;
39965
40050
  const mixedResetTo = (ref) => `git reset --mixed ${shellQuote$1(ref)}`;
39966
40051
  const hardResetTo = (ref) => `git reset --hard ${shellQuote$1(ref)}`;
40052
+ /**
40053
+ * POSIX sh (runs under dash). The marker is a `commit-tree` over HEAD's own
40054
+ * tree, so no hook runs on it and its tree is the pre-turn tree by
40055
+ * construction; `commit-tree` ignores `commit.gpgSign`, hence the explicit
40056
+ * `-S`, read (and failing closed) before any object or ref is written; the
40057
+ * index is already staged by then, so an early exit leaves the turn staged.
40058
+ * The kept ref is deleted only after `reset -q` succeeds: it is then the
40059
+ * turn's only handle.
40060
+ * The branch moves before the final hard reset, so if that reset fails the
40061
+ * marker has landed and the turn is still in the working tree (kept ref
40062
+ * present); every other failure leaves the branch untouched.
40063
+ */
40064
+ const keepTurn = (refPrefix, message) => {
40065
+ const keptRef = `${shellQuote$1(`${refPrefix}/`)}"$gtd_kept"`;
40066
+ return [
40067
+ `git add -A || exit 1`,
40068
+ `gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
40069
+ `if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
40070
+ `gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
40071
+ `gtd_tree=$(git write-tree) || exit 1`,
40072
+ `gtd_kept=$(git commit-tree "$gtd_tree" -p "$gtd_head" -m ${shellQuote$1(message)}) || exit 1`,
40073
+ `git update-ref ${keptRef} "$gtd_kept" || exit 1`,
40074
+ `gtd_marker=$(printf '%s\\nGtd-Kept: %s' ${shellQuote$1(message)} "$gtd_kept")`,
40075
+ `if ! gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m "$gtd_marker") || ! git update-ref HEAD "$gtd_tip" "$gtd_head"; then`,
40076
+ ` git reset -q && git update-ref -d ${keptRef}`,
40077
+ ` exit 1`,
40078
+ `fi`,
40079
+ `git reset --hard HEAD`
40080
+ ].join("\n");
40081
+ };
39967
40082
  const updateRef = (ref, hash) => `git update-ref ${shellQuote$1(ref)} ${shellQuote$1(hash)}`;
39968
40083
  /** `git update-ref -d <ref>` — idempotent: deleting a missing ref is already a no-op in real git. */
39969
40084
  const deleteRef = (ref) => `git update-ref -d ${shellQuote$1(ref)}`;
@@ -39996,6 +40111,7 @@ const renderGitWrite = (write) => {
39996
40111
  switch (write.kind) {
39997
40112
  case "commitAll": return commitAll(write.message);
39998
40113
  case "updateRef": return updateRef(write.ref, write.target);
40114
+ case "keepTurn": return keepTurn(write.ref, write.message);
39999
40115
  }
40000
40116
  };
40001
40117
  /**