@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
package/dist/testkit.mjs CHANGED
@@ -6071,6 +6071,8 @@ const HISTORY_REF = "refs/worktree/gtd/history";
6071
6071
  /** One ref per finished process, kept past the branch's deletion; distinct from the per-worktree `HISTORY_REF`. */
6072
6072
  const PROCESS_HISTORY_PREFIX = "refs/gtd/history/";
6073
6073
  const processHistoryRef = (hash) => `${PROCESS_HISTORY_PREFIX}${hash}`;
6074
+ /** 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. */
6075
+ const keptRef = (entry, step) => `refs/gtd/kept/${entry}/${step}`;
6074
6076
  const readRetainedHistory = (git) => git.readRefOption(HISTORY_REF);
6075
6077
  /**
6076
6078
  * Whether resetting to `tipHash` is safe: HEAD must be an ancestor of
@@ -6770,6 +6772,22 @@ const accessShapeFault = (value) => {
6770
6772
  const sameGlobs = (a, b) => a === void 0 || b === void 0 ? a === b : a.length === b.length && a.every((g, i) => g === b[i]);
6771
6773
  const sameAccess = (a, b) => sameGlobs(a?.read, b?.read) && sameGlobs(a?.write, b?.write);
6772
6774
  //#endregion
6775
+ //#region src/replay/RefName.ts
6776
+ const FORBIDDEN = /[\u0000- \u007f~^:?*[\\]/;
6777
+ /** Why `name` cannot be (part of) a git ref name, or `undefined` when it can. */
6778
+ const refNameFault = (name) => {
6779
+ if (name === "") return "it is empty";
6780
+ if (FORBIDDEN.test(name)) return "it contains a space, control character or one of ~^:?*[\\";
6781
+ if (name.includes("..")) return "it contains \"..\"";
6782
+ if (name.includes("@{")) return "it contains \"@{\"";
6783
+ if (name.endsWith(".")) return "it ends with \".\"";
6784
+ for (const part of name.split("/")) {
6785
+ if (part === "") return "it has an empty \"/\" component";
6786
+ if (part.startsWith(".")) return "a component starts with \".\"";
6787
+ if (part.endsWith(".lock")) return "a component ends with \".lock\"";
6788
+ }
6789
+ };
6790
+ //#endregion
6773
6791
  //#region src/replay/Tree.ts
6774
6792
  const treeFromRecord = (files) => ({
6775
6793
  paths: () => Object.keys(files).sort(),
@@ -6864,6 +6882,7 @@ const decodePath = (raw) => {
6864
6882
  return typeof path === "string" && path !== "" && !CONTROL_CHARACTER$1.test(path) ? path : void 0;
6865
6883
  };
6866
6884
  const FORMAT_RE = /^[1-9][0-9]*$/;
6885
+ const HASH_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
6867
6886
  const TRAILER_READERS = {
6868
6887
  "Gtd-Step": (value, into) => {
6869
6888
  into.step ??= parseStepId(value);
@@ -6893,6 +6912,9 @@ const TRAILER_READERS = {
6893
6912
  "Gtd-Version": (value, into) => {
6894
6913
  if (value !== "") into.version ??= value;
6895
6914
  },
6915
+ "Gtd-Kept": (value, into) => {
6916
+ if (HASH_RE.test(value)) into.kept ??= value;
6917
+ },
6896
6918
  "Gtd-Format": (value, into) => {
6897
6919
  if (FORMAT_RE.test(value)) into.format ??= Number(value);
6898
6920
  },
@@ -6913,6 +6935,7 @@ const parseCommitMessage = (message) => {
6913
6935
  reviewBase: void 0,
6914
6936
  branch: void 0,
6915
6937
  version: void 0,
6938
+ kept: void 0,
6916
6939
  judge: [],
6917
6940
  vars: {},
6918
6941
  config: {},
@@ -7027,7 +7050,8 @@ const KNOWN_OPTIONS = {
7027
7050
  ...STEERING_OPTIONS,
7028
7051
  "model",
7029
7052
  "system",
7030
- "allowEmpty"
7053
+ "allowEmpty",
7054
+ "keep"
7031
7055
  ]),
7032
7056
  human: /* @__PURE__ */ new Set([
7033
7057
  ...STEERING_OPTIONS,
@@ -7059,6 +7083,7 @@ const replay = async (input) => {
7059
7083
  const scopeCallIds = [];
7060
7084
  let scopeCallCount = 0;
7061
7085
  const trace = [];
7086
+ const keptTips = /* @__PURE__ */ new Map();
7062
7087
  let landed;
7063
7088
  let outcome;
7064
7089
  let signal;
@@ -7218,6 +7243,12 @@ const replay = async (input) => {
7218
7243
  }
7219
7244
  cursor++;
7220
7245
  advance(commit);
7246
+ if (commit.parsed.kept !== void 0) if (commit.kept === void 0) keptTips.delete(step.name);
7247
+ else keptTips.set(step.name, {
7248
+ hash: commit.kept.hash,
7249
+ before: commit.tree,
7250
+ after: commit.kept.tree
7251
+ });
7221
7252
  const to = commit.parsed.parsed?.to;
7222
7253
  if (to !== void 0) expectedNext = {
7223
7254
  name: to,
@@ -7225,6 +7256,21 @@ const replay = async (input) => {
7225
7256
  };
7226
7257
  return replyFor(step.request, commit.parsed.judge);
7227
7258
  };
7259
+ const landPending = (step, pending) => {
7260
+ pendingUsed = true;
7261
+ landed = step;
7262
+ const keeping = step.request.kind === "agent" && step.request.options.keep === true;
7263
+ if (keeping) keptTips.set(step.name, {
7264
+ hash: "",
7265
+ before: position.tree,
7266
+ after: pending.tree
7267
+ });
7268
+ advance({
7269
+ hash: "",
7270
+ tree: keeping ? position.tree : pending.tree
7271
+ });
7272
+ return replyFor(step.request, pending.verdicts ?? []);
7273
+ };
7228
7274
  const answer = (step) => {
7229
7275
  while (cursor < commits.length && isAttemptAt(commits[cursor], step.name)) cursor++;
7230
7276
  const commit = commits[cursor];
@@ -7235,17 +7281,18 @@ const replay = async (input) => {
7235
7281
  trace,
7236
7282
  landed
7237
7283
  });
7238
- pendingUsed = true;
7239
- landed = step;
7240
- advance({
7241
- hash: "",
7242
- tree: input.pending.tree
7243
- });
7244
- return replyFor(step.request, input.pending.verdicts ?? []);
7284
+ return landPending(step, input.pending);
7245
7285
  };
7246
7286
  const requestFault = (request) => {
7247
7287
  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)`;
7248
- return request.kind === "agent" ? scopeAccessFault() : void 0;
7288
+ if (request.kind !== "agent") return void 0;
7289
+ const name = scoped(request.name);
7290
+ if (request.options.keep === true) {
7291
+ 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`;
7292
+ const refFault = name.includes("/") ? "it contains \"/\"" : refNameFault(name);
7293
+ if (refFault !== void 0) return `gtd: step "${name}": keep: true files the turn under a git ref named after the step, but ${refFault}`;
7294
+ }
7295
+ return scopeAccessFault();
7249
7296
  };
7250
7297
  const handleStep = async (request) => {
7251
7298
  if (outcome !== void 0) throw new Stop();
@@ -7328,6 +7375,13 @@ const replay = async (input) => {
7328
7375
  faults: findings.filter((f) => f.message.startsWith(`Code thread at ${c.path}:${t.line + 1}: `)).map((f) => f.message)
7329
7376
  }));
7330
7377
  }),
7378
+ kept: (name) => {
7379
+ const tip = keptTips.get(scoped(name));
7380
+ return tip === void 0 ? void 0 : {
7381
+ hash: tip.hash,
7382
+ changes: changesBetween(tip.before, tip.after)
7383
+ };
7384
+ },
7331
7385
  vars: input.vars,
7332
7386
  env: input.env,
7333
7387
  start: () => input.start,
@@ -8293,7 +8347,11 @@ const planStep = (snapshot, opts = {}) => {
8293
8347
  ...uncheckStep(snapshot),
8294
8348
  {
8295
8349
  kind: "gitWrite",
8296
- write: {
8350
+ write: landing.keepTurn !== void 0 ? {
8351
+ kind: "keepTurn",
8352
+ ref: landing.keepTurn,
8353
+ message
8354
+ } : {
8297
8355
  kind: "commitAll",
8298
8356
  message
8299
8357
  }
@@ -8768,6 +8826,7 @@ const promptDef = (common, request, skills, access) => ({
8768
8826
  ...optional("model", request.options.model),
8769
8827
  ...optional("system", request.options.system),
8770
8828
  ...optional("allowEmpty", request.options.allowEmpty),
8829
+ ...optional("keep", request.options.keep),
8771
8830
  ...optional("skills", skills),
8772
8831
  ...optional("access", access)
8773
8832
  });
@@ -8882,11 +8941,21 @@ const setupFor = (config, run, workspace, host, startOverrides = {}) => gen(func
8882
8941
  hash: run.episode.base,
8883
8942
  tree: commitTree(workspace, run.episode.base)
8884
8943
  };
8885
- const episode = run.episode.commits.map((c) => ({
8886
- hash: c.hash,
8887
- message: c.message,
8888
- tree: commitTree(workspace, c.hash)
8889
- }));
8944
+ const git = yield* GitService;
8945
+ const episode = [];
8946
+ for (const c of run.episode.commits) {
8947
+ const tip = parseCommitMessage(c.message).kept;
8948
+ const exists = tip !== void 0 && isSome(yield* git.readRefOption(`${tip}^{commit}`));
8949
+ episode.push({
8950
+ hash: c.hash,
8951
+ message: c.message,
8952
+ tree: commitTree(workspace, c.hash),
8953
+ ...exists ? { kept: {
8954
+ hash: tip,
8955
+ tree: commitTree(workspace, tip)
8956
+ } } : {}
8957
+ });
8958
+ }
8890
8959
  let pendingMemo;
8891
8960
  return {
8892
8961
  resolved,
@@ -9062,6 +9131,7 @@ const closingTrailers = (branch) => ({
9062
9131
  ...isSome(branch) ? { branch: branch.value } : {}
9063
9132
  });
9064
9133
  const isSettledNoop = (rest, to) => rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state;
9134
+ const keptTurn = (rest) => rest.stepDef.keep === true && rest.changes.length > 0 ? { keepTurn: keptRef(rest.run.startHash, rest.state) } : {};
9065
9135
  const landingTo = (rest, to, branch) => {
9066
9136
  if (isSettledNoop(rest, to)) return {
9067
9137
  kind: "noop",
@@ -9086,7 +9156,8 @@ const landingTo = (rest, to, branch) => {
9086
9156
  } : {},
9087
9157
  ...finishes ? closingTrailers(branch) : {}
9088
9158
  },
9089
- ...finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {}
9159
+ ...finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {},
9160
+ ...keptTurn(rest)
9090
9161
  };
9091
9162
  };
9092
9163
  /**
@@ -9223,7 +9294,7 @@ const commitAllowEmpty = (message) => {
9223
9294
  return [
9224
9295
  `if ! out=$(git commit --allow-empty -m ${m} 2>&1); then`,
9225
9296
  ` case "$out" in`,
9226
- ` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m} ;;`,
9297
+ ` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m} || exit 1 ;;`,
9227
9298
  ` *) printf '%s\\n' "$out" >&2; exit 1 ;;`,
9228
9299
  ` esac`,
9229
9300
  `fi`
@@ -9245,6 +9316,36 @@ const hardResetTo = (ref) => `git reset --hard ${shellQuote(ref)}`;
9245
9316
  * `GitWriterOperations.discardPending` for why it's kept anyway.
9246
9317
  */
9247
9318
  const discardPending = () => `git add -A && git reset --hard HEAD`;
9319
+ /**
9320
+ * POSIX sh (runs under dash). The marker is a `commit-tree` over HEAD's own
9321
+ * tree, so no hook runs on it and its tree is the pre-turn tree by
9322
+ * construction; `commit-tree` ignores `commit.gpgSign`, hence the explicit
9323
+ * `-S`, read (and failing closed) before any object or ref is written; the
9324
+ * index is already staged by then, so an early exit leaves the turn staged.
9325
+ * The kept ref is deleted only after `reset -q` succeeds: it is then the
9326
+ * turn's only handle.
9327
+ * The branch moves before the final hard reset, so if that reset fails the
9328
+ * marker has landed and the turn is still in the working tree (kept ref
9329
+ * present); every other failure leaves the branch untouched.
9330
+ */
9331
+ const keepTurn = (refPrefix, message) => {
9332
+ const keptRef = `${shellQuote(`${refPrefix}/`)}"$gtd_kept"`;
9333
+ return [
9334
+ `git add -A || exit 1`,
9335
+ `gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
9336
+ `if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
9337
+ `gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
9338
+ `gtd_tree=$(git write-tree) || exit 1`,
9339
+ `gtd_kept=$(git commit-tree "$gtd_tree" -p "$gtd_head" -m ${shellQuote(message)}) || exit 1`,
9340
+ `git update-ref ${keptRef} "$gtd_kept" || exit 1`,
9341
+ `gtd_marker=$(printf '%s\\nGtd-Kept: %s' ${shellQuote(message)} "$gtd_kept")`,
9342
+ `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`,
9343
+ ` git reset -q && git update-ref -d ${keptRef}`,
9344
+ ` exit 1`,
9345
+ `fi`,
9346
+ `git reset --hard HEAD`
9347
+ ].join("\n");
9348
+ };
9248
9349
  const updateRef = (ref, hash) => `git update-ref ${shellQuote(ref)} ${shellQuote(hash)}`;
9249
9350
  /** `git update-ref -d <ref>` — idempotent: deleting a missing ref is already a no-op in real git. */
9250
9351
  const deleteRef = (ref) => `git update-ref -d ${shellQuote(ref)}`;
@@ -9277,6 +9378,7 @@ const renderGitWrite = (write) => {
9277
9378
  switch (write.kind) {
9278
9379
  case "commitAll": return commitAll(write.message);
9279
9380
  case "updateRef": return updateRef(write.ref, write.target);
9381
+ case "keepTurn": return keepTurn(write.ref, write.message);
9280
9382
  }
9281
9383
  };
9282
9384
  /**
@@ -11406,6 +11508,7 @@ var InMemRepo = class InMemRepo {
11406
11508
  return this.head !== null;
11407
11509
  }
11408
11510
  resolveRef(ref) {
11511
+ if (ref.endsWith("^{commit}")) return this.resolveRef(ref.slice(0, -9));
11409
11512
  if (/^[0-9a-f]{40}$/.test(ref)) return this.commits.has(ref) ? ref : null;
11410
11513
  const tildeMatch = /^(HEAD|[0-9a-f]{40})(~(\d+)|\^)?$/.exec(ref);
11411
11514
  if (tildeMatch) {
@@ -11578,8 +11681,12 @@ var InMemRepo = class InMemRepo {
11578
11681
  stageAll() {
11579
11682
  this.index = new Map(this.worktree);
11580
11683
  }
11581
- /** Commit whatever is currently staged (the index) verbatim, with no implicit staging first — mirrors `git commit --allow-empty -m <message>` after a soft reset. */
11582
- commitAsIs(message) {
11684
+ /** Seeded failure modes (see `GitTierSeed`): a pre-commit hook printing this and exiting 1, and a signer that cannot write. */
11685
+ hookRejection = void 0;
11686
+ signingFails = false;
11687
+ /** Seeded: a pre-commit hook writing and staging this file. */
11688
+ hookStages = void 0;
11689
+ writeCommit(message) {
11583
11690
  const tree = new Map(this.index);
11584
11691
  const parent = this.head;
11585
11692
  const hash = makeHash(message, parent, tree);
@@ -11594,27 +11701,59 @@ var InMemRepo = class InMemRepo {
11594
11701
  this.head = hash;
11595
11702
  this.branches.set(this.currentBranch, hash);
11596
11703
  }
11597
- /** Discard every pending change, tracked or untracked: stage everything, then hard-reset (which drops the freshly-staged untracked paths too). */
11598
- discardPending() {
11599
- this.index = new Map(this.worktree);
11600
- this.resetHard();
11704
+ /**
11705
+ * Twin of the emitted `git commit --allow-empty` if/case retry: a hook
11706
+ * rejection printing "empty git commit" retries without hooks, any other
11707
+ * rejection fails; a failing signer fails either attempt. Commits whatever is
11708
+ * staged verbatim, like `git commit` after a soft reset.
11709
+ */
11710
+ commitAsIs(message) {
11711
+ if (this.hookRejection !== void 0 && !this.hookRejection.includes("empty git commit")) return this.hookRejection;
11712
+ if (this.signingFails) return "error: gpg failed to sign the data";
11713
+ if (this.hookStages !== void 0) {
11714
+ this.worktree.set(this.hookStages.path, this.hookStages.content);
11715
+ this.index.set(this.hookStages.path, this.hookStages.content);
11716
+ }
11717
+ this.writeCommit(message);
11601
11718
  }
11602
- commitAllWithPrefix(prefix) {
11719
+ /** Setup commit: stages everything, and throws if a failure is seeded, as the Live tier's hooks and signer would. */
11720
+ seedCommit(message) {
11721
+ if (this.hookRejection !== void 0 || this.hookStages !== void 0 || this.signingFails) throw new Error("seedCommit after a seeded commit failure");
11603
11722
  this.index = new Map(this.worktree);
11723
+ this.writeCommit(message);
11724
+ }
11725
+ /** Twin of `GitScript.keepTurn`: no hook runs; `signingFails` fails the marker. */
11726
+ keepTurn(refPrefix, message) {
11727
+ this.stageAll();
11604
11728
  const tree = new Map(this.index);
11605
- const message = prefix;
11606
11729
  const parent = this.head;
11607
11730
  const hash = makeHash(message, parent, tree);
11608
- const commit = {
11731
+ this.commits.set(hash, {
11609
11732
  hash,
11610
11733
  message,
11611
11734
  files: new Map(tree),
11612
11735
  parent,
11613
11736
  seq: this.nextSeq++
11614
- };
11615
- this.commits.set(hash, commit);
11616
- this.head = hash;
11617
- this.branches.set(this.currentBranch, hash);
11737
+ });
11738
+ const ref = `${refPrefix}/${hash}`;
11739
+ this.refs.set(ref, hash);
11740
+ this.index = new Map(this.headTree());
11741
+ if (this.signingFails) {
11742
+ this.refs.delete(ref);
11743
+ return "error: gpg failed to sign the data";
11744
+ }
11745
+ this.writeCommit(`${message}\nGtd-Kept: ${hash}`);
11746
+ this.index = new Map(tree);
11747
+ this.resetHard();
11748
+ }
11749
+ /** Discard every pending change, tracked or untracked: stage everything, then hard-reset (which drops the freshly-staged untracked paths too). */
11750
+ discardPending() {
11751
+ this.index = new Map(this.worktree);
11752
+ this.resetHard();
11753
+ }
11754
+ commitAllWithPrefix(prefix) {
11755
+ this.index = new Map(this.worktree);
11756
+ return this.commitAsIs(prefix);
11618
11757
  }
11619
11758
  static EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
11620
11759
  softResetTo(ref) {
@@ -11767,6 +11906,7 @@ const makeGitReaderOps = (repo, root) => ({
11767
11906
  return content !== null ? succeed$6(content) : fail$5(/* @__PURE__ */ new Error(`path not found at ${ref}: ${path}`));
11768
11907
  },
11769
11908
  readRefOption: (ref) => {
11909
+ if (/^[0-9a-f]{40}$/.test(ref)) return succeed$6(some(ref));
11770
11910
  const hash = repo.resolveRef(ref);
11771
11911
  return succeed$6(hash !== null ? some(hash) : none());
11772
11912
  },
@@ -11789,10 +11929,13 @@ const makeGitReaderOps = (repo, root) => ({
11789
11929
  subjectHistory: (pageSize, skip, head) => succeed$6(repo.subjectHistory(pageSize, skip, head)),
11790
11930
  changedPaths: (base) => succeed$6(repo.changedPathsWorktree(base))
11791
11931
  });
11932
+ const failOn = (output) => {
11933
+ if (output !== void 0) throw new Error(output);
11934
+ };
11792
11935
  const makeGitWriterOps = (repo) => ({
11793
- commitAllWithPrefix: (prefix) => tryCatch(() => repo.commitAllWithPrefix(prefix)),
11936
+ commitAllWithPrefix: (prefix) => tryCatch(() => failOn(repo.commitAllWithPrefix(prefix))),
11794
11937
  softResetTo: (ref) => tryCatch(() => repo.softResetTo(ref)),
11795
- commitAsIs: (message) => tryCatch(() => repo.commitAsIs(message)),
11938
+ commitAsIs: (message) => tryCatch(() => failOn(repo.commitAsIs(message))),
11796
11939
  discardPending: () => tryCatch(() => repo.discardPending()),
11797
11940
  updateRef: (ref, hash) => tryCatch(() => repo.updateRef(ref, hash)),
11798
11941
  deleteRef: (ref) => tryCatch(() => repo.deleteRef(ref)),
@@ -12295,25 +12438,51 @@ const conditionStatement = (block) => {
12295
12438
  const end = block.indexOf(CONDITION_TERMINATOR, start);
12296
12439
  return end === -1 ? block.slice(start) : block.slice(start, end);
12297
12440
  };
12441
+ /** `keepTurn`: an invalid ref name fails before anything moves, as `git update-ref` does. */
12442
+ const recognizeKeepTurn = (repo, block) => {
12443
+ const [message, refDir] = extractQuotedTokens(block);
12444
+ const ref = refDir?.slice(0, -1);
12445
+ if (message === void 0 || ref === void 0 || keepTurn(ref, message) !== block) return;
12446
+ if (refNameFault(ref) !== void 0) return {
12447
+ kind: "failed",
12448
+ error: `fatal: invalid ref format: ${ref}`,
12449
+ status: 128
12450
+ };
12451
+ const failure = repo.keepTurn(ref, message);
12452
+ return failure === void 0 ? { kind: "applied" } : {
12453
+ kind: "failed",
12454
+ error: failure,
12455
+ status: 1
12456
+ };
12457
+ };
12298
12458
  const recognizeGitBuilders = (repo, block) => {
12299
12459
  if (block === discardPending()) {
12300
12460
  repo.discardPending();
12301
12461
  return { kind: "applied" };
12302
12462
  }
12303
12463
  const conditionLine = conditionStatement(block);
12464
+ if (block.startsWith("git add -A || exit 1\ngtd_sign_cfg=")) return recognizeKeepTurn(repo, block);
12304
12465
  if (block.startsWith("git add -A || exit 1\n")) {
12305
12466
  const [message] = extractQuotedTokens(conditionLine);
12306
12467
  if (message !== void 0 && commitAll(message) === block) {
12307
- repo.commitAllWithPrefix(message);
12308
- return { kind: "applied" };
12468
+ const failure = repo.commitAllWithPrefix(message);
12469
+ return failure === void 0 ? { kind: "applied" } : {
12470
+ kind: "failed",
12471
+ error: failure,
12472
+ status: 1
12473
+ };
12309
12474
  }
12310
12475
  return;
12311
12476
  }
12312
12477
  if (block.startsWith("if ! out=$(git commit --allow-empty -m ")) {
12313
12478
  const [message] = extractQuotedTokens(conditionLine);
12314
12479
  if (message !== void 0 && commitAsIs(message) === block) {
12315
- repo.commitAsIs(message);
12316
- return { kind: "applied" };
12480
+ const failure = repo.commitAsIs(message);
12481
+ return failure === void 0 ? { kind: "applied" } : {
12482
+ kind: "failed",
12483
+ error: failure,
12484
+ status: 1
12485
+ };
12317
12486
  }
12318
12487
  return;
12319
12488
  }
@@ -12850,7 +13019,7 @@ const testRepo = (options) => {
12850
13019
  const id = `module-${++registered}`;
12851
13020
  repo.writeFile("gtd.config.ts", typeof workflow === "string" ? workflow : moduleSource(id, workflow));
12852
13021
  for (const [path, content] of Object.entries(options.files ?? {})) repo.writeFile(path, content);
12853
- repo.commitAllWithPrefix("chore: initial commit");
13022
+ repo.seedCommit("chore: initial commit");
12854
13023
  const gtd = async (...args) => {
12855
13024
  const { io, result } = makeCapturingCliIo(repo, env);
12856
13025
  if (typeof workflow !== "string") registry().set(id, workflow);
@@ -12918,7 +13087,7 @@ const testRepo = (options) => {
12918
13087
  read: (path) => repo.readFile(path),
12919
13088
  write: (path, content) => repo.writeFile(path, content),
12920
13089
  remove: (path) => repo.deleteFile(path),
12921
- commit: (message) => repo.commitAllWithPrefix(message),
13090
+ commit: (message) => repo.seedCommit(message),
12922
13091
  show: (ref, path) => repo.fileAtRef(ref, path) ?? void 0,
12923
13092
  runScript: (script) => {
12924
13093
  const flow = applyFlowScript(repo, /* @__PURE__ */ new Map(), script);
@@ -12958,6 +13127,14 @@ const testRepo = (options) => {
12958
13127
  };
12959
13128
  //#endregion
12960
13129
  //#region src/testkit/GitTiers.ts
13130
+ const writeRejectingHook = (hooksDir, output) => {
13131
+ mkdirSync(hooksDir, { recursive: true });
13132
+ writeFileSync(join(hooksDir, "pre-commit"), `#!/bin/sh\ncat >&2 <<'GTD_HOOK_EOF'\n${output}\nGTD_HOOK_EOF\nexit 1\n`, { mode: 493 });
13133
+ };
13134
+ const writeStagingHook = (hooksDir, path, content) => {
13135
+ mkdirSync(hooksDir, { recursive: true });
13136
+ writeFileSync(join(hooksDir, "pre-commit"), `#!/bin/sh\nprintf '%s' ${shellQuote(content)} > ${shellQuote(path)} && git add ${shellQuote(path)}\nexit 0\n`, { mode: 493 });
13137
+ };
12961
13138
  /** No-op — these tests assert on git/config behavior, not narration. */
12962
13139
  const noopNarratorLayer = Narrator.layer(() => {}, false);
12963
13140
  const gitExecIn = (dir, ...args) => execSync(`git ${args.join(" ")}`, {
@@ -13007,7 +13184,6 @@ const makeLiveTier = (initialCommit = true) => {
13007
13184
  root,
13008
13185
  capabilities: {
13009
13186
  onDiskContent: true,
13010
- commitHooks: true,
13011
13187
  linkedWorktrees: true,
13012
13188
  directoryChainConfig: true,
13013
13189
  remotes: true
@@ -13035,7 +13211,22 @@ const makeLiveTier = (initialCommit = true) => {
13035
13211
  },
13036
13212
  stageAll: () => gitExec("add", "-A"),
13037
13213
  updateRef: (ref, hash) => gitExec("update-ref", ref, hash),
13038
- mixedReset: (ref) => gitExec("reset", "--mixed", ref)
13214
+ mixedReset: (ref) => gitExec("reset", "--mixed", ref),
13215
+ rejectingHook: (output) => {
13216
+ const hooks = join(root, ".git/hooks");
13217
+ gitExec("config", "core.hooksPath", hooks);
13218
+ writeRejectingHook(hooks, output);
13219
+ },
13220
+ stagingHook: (path, content) => {
13221
+ const hooks = join(root, ".git/hooks");
13222
+ gitExec("config", "core.hooksPath", hooks);
13223
+ writeStagingHook(hooks, path, content);
13224
+ },
13225
+ failingSigner: () => {
13226
+ gitExec("config", "commit.gpgsign", "true");
13227
+ gitExec("config", "gpg.format", "openpgp");
13228
+ gitExec("config", "gpg.program", "false");
13229
+ }
13039
13230
  },
13040
13231
  observe: {
13041
13232
  resolveRef: (ref) => gitExec("rev-parse", ref),
@@ -13101,7 +13292,7 @@ const makeInMemTier = (initialCommit = true) => {
13101
13292
  const repo = new InMemRepo();
13102
13293
  if (initialCommit) {
13103
13294
  repo.writeFile("readme.txt", "hello");
13104
- repo.commitAllWithPrefix("init: first commit");
13295
+ repo.seedCommit("init: first commit");
13105
13296
  }
13106
13297
  const gitLayer = gitTestLayer(repo, IN_MEM_ROOT);
13107
13298
  const provide = (eff) => runPromise(eff.pipe(provide$2(gitLayer), provide$2(noopNarratorLayer)));
@@ -13111,7 +13302,6 @@ const makeInMemTier = (initialCommit = true) => {
13111
13302
  root: IN_MEM_ROOT,
13112
13303
  capabilities: {
13113
13304
  onDiskContent: false,
13114
- commitHooks: false,
13115
13305
  linkedWorktrees: false,
13116
13306
  directoryChainConfig: true,
13117
13307
  remotes: false
@@ -13122,17 +13312,29 @@ const makeInMemTier = (initialCommit = true) => {
13122
13312
  seed: {
13123
13313
  commit: (message, files = { "file.txt": message }) => {
13124
13314
  for (const [path, content] of Object.entries(files)) repo.writeFile(path, content);
13125
- repo.commitAllWithPrefix(message);
13315
+ repo.seedCommit(message);
13126
13316
  },
13127
13317
  writeFile: (path, content) => repo.writeFile(path, content),
13128
13318
  deleteFile: (path) => repo.deleteFile(path),
13129
13319
  commitDeletion: (path, message) => {
13130
13320
  repo.deleteFile(path);
13131
- repo.commitAllWithPrefix(message);
13321
+ repo.seedCommit(message);
13132
13322
  },
13133
13323
  stageAll: () => repo.stageAll(),
13134
13324
  updateRef: (ref, hash) => repo.updateRef(ref, hash),
13135
- mixedReset: (ref) => repo.mixedResetTo(ref)
13325
+ mixedReset: (ref) => repo.mixedResetTo(ref),
13326
+ rejectingHook: (output) => {
13327
+ repo.hookRejection = output;
13328
+ },
13329
+ stagingHook: (path, content) => {
13330
+ repo.hookStages = {
13331
+ path,
13332
+ content
13333
+ };
13334
+ },
13335
+ failingSigner: () => {
13336
+ repo.signingFails = true;
13337
+ }
13136
13338
  },
13137
13339
  observe: {
13138
13340
  resolveRef: (ref) => {
@@ -13463,16 +13665,6 @@ const runGitServiceContract = (makeTier) => {
13463
13665
  const history = await runGit(t, (g) => g.commitHistory());
13464
13666
  expect(history[history.length - 1]?.message).toBe("gtd: grilled");
13465
13667
  });
13466
- it.skipIf(!capabilities.commitHooks)("retries with --no-verify when a hook blocks the empty commit", async () => {
13467
- const hookPath = join(t.root, ".git/hooks/pre-commit");
13468
- writeFileSync(hookPath, `#!/bin/sh\necho "lint-staged prevented an empty git commit." >&2\nexit 1\n`);
13469
- execSync(`chmod +x "${hookPath}"`);
13470
- const headBefore = t.observe.resolveRef("HEAD");
13471
- await runGit(t, (g) => g.commitAllWithPrefix("gtd: grilled"));
13472
- expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore);
13473
- const history = await runGit(t, (g) => g.commitHistory());
13474
- expect(history[history.length - 1]?.message).toBe("gtd: grilled");
13475
- });
13476
13668
  });
13477
13669
  describe("commitAsIs", () => {
13478
13670
  it("commits exactly what's staged, ignoring later worktree writes (the squash mechanic)", async () => {
@@ -13491,6 +13683,29 @@ const runGitServiceContract = (makeTier) => {
13491
13683
  expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore);
13492
13684
  });
13493
13685
  });
13686
+ describe.each([["commitAllWithPrefix", (g) => g.commitAllWithPrefix("gtd: building")], ["commitAsIs", (g) => g.commitAsIs("gtd: building")]])("%s seeded commit failures", (_name, commit) => {
13687
+ it("retries with --no-verify when a hook blocks the empty commit", async () => {
13688
+ t.seed.rejectingHook("lint-staged prevented an empty git commit.");
13689
+ const headBefore = t.observe.resolveRef("HEAD");
13690
+ await runGit(t, commit);
13691
+ expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore);
13692
+ const history = await runGit(t, (g) => g.commitHistory());
13693
+ expect(history[history.length - 1]?.message).toBe("gtd: building");
13694
+ });
13695
+ it("fails on a hook rejection that is not the empty-commit one, leaving HEAD unchanged", async () => {
13696
+ t.seed.rejectingHook("lint error");
13697
+ const headBefore = t.observe.resolveRef("HEAD");
13698
+ expect(isFailure(await runGitExit(t, commit))).toBe(true);
13699
+ expect(t.observe.resolveRef("HEAD")).toBe(headBefore);
13700
+ });
13701
+ it("fails both attempts when the signer cannot write, leaving HEAD unchanged", async () => {
13702
+ t.seed.rejectingHook("rejected: empty git commit");
13703
+ t.seed.failingSigner();
13704
+ const headBefore = t.observe.resolveRef("HEAD");
13705
+ expect(isFailure(await runGitExit(t, commit))).toBe(true);
13706
+ expect(t.observe.resolveRef("HEAD")).toBe(headBefore);
13707
+ });
13708
+ });
13494
13709
  describe("softResetTo", () => {
13495
13710
  it("moves HEAD back but keeps worktree changes from second commit", async () => {
13496
13711
  const firstHash = t.observe.resolveRef("HEAD");
@@ -13538,6 +13753,20 @@ const runGitServiceContract = (makeTier) => {
13538
13753
  it("readRefOption is None for a ref that doesn't exist — never a failure", async () => {
13539
13754
  expect((await runGit(t, (g) => g.readRefOption("refs/gtd/nonexistent")))._tag).toBe("None");
13540
13755
  });
13756
+ it("readRefOption of <hash>^{commit} is Some for an existing commit", async () => {
13757
+ const head = t.observe.resolveRef("HEAD");
13758
+ const resolved = await runGit(t, (g) => g.readRefOption(`${head}^{commit}`));
13759
+ expect(resolved._tag).toBe("Some");
13760
+ if (resolved._tag === "Some") expect(resolved.value).toBe(head);
13761
+ });
13762
+ it("readRefOption of <hash>^{commit} is None for a 40-hex hash with no object", async () => {
13763
+ const missing = "0123456789abcdef0123456789abcdef01234567";
13764
+ expect((await runGit(t, (g) => g.readRefOption(`${missing}^{commit}`)))._tag).toBe("None");
13765
+ });
13766
+ it("readRefOption of a bare 40-hex hash with no object is Some", async () => {
13767
+ const missing = "0123456789abcdef0123456789abcdef01234567";
13768
+ expect((await runGit(t, (g) => g.readRefOption(missing)))._tag).toBe("Some");
13769
+ });
13541
13770
  it("deleteRef removes a ref idempotently (deleting twice is a no-op)", async () => {
13542
13771
  const head = t.observe.resolveRef("HEAD");
13543
13772
  await runGit(t, (g) => g.updateRef("refs/gtd/marker", head));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "26.0.0",
3
+ "version": "26.1.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
@@ -1231,7 +1231,7 @@ describe("runCli — exit codes", () => {
1231
1231
  const repo = new InMemRepo()
1232
1232
  repo.writeFile("NOTE.md", "a note\n")
1233
1233
  repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
1234
- repo.commitAllWithPrefix("gtd: init")
1234
+ repo.seedCommit("gtd: init")
1235
1235
  const { io, captured } = capturingIo(() => testLayers(repo))
1236
1236
  await Effect.runPromise(runCli(["node", "gtd.js", "next", "--json=does.not.exist"], io))
1237
1237
  const { stdout, stderr, exitCode } = captured()
@@ -1316,7 +1316,7 @@ describe("runCli — stdout stays byte-empty on every failing surface", () => {
1316
1316
  // produce zero calls, not merely an empty joined string.
1317
1317
  const repo = new InMemRepo()
1318
1318
  repo.writeFile("README.md", "x\n")
1319
- repo.commitAllWithPrefix("chore: init gtd workflow")
1319
+ repo.seedCommit("chore: init gtd workflow")
1320
1320
  repo.writeFile(".gtd/TODO.md", "## Open Questions\n\n###\n\nno question text.\n")
1321
1321
  const stdoutCalls: string[] = []
1322
1322
  const io: CliIo = {
@@ -1396,7 +1396,7 @@ describe("runCli — flush normalizes the artifact's trailing newline", () => {
1396
1396
  const repo = new InMemRepo()
1397
1397
  repo.writeFile(".gitignore", "node_modules\n")
1398
1398
  repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
1399
- repo.commitAllWithPrefix("chore: initial commit")
1399
+ repo.seedCommit("chore: initial commit")
1400
1400
  const { io, captured } = capturingIo(() => testLayers(repo))
1401
1401
  await Effect.runPromise(runCli(["node", "gtd.js", "land"], io))
1402
1402
  const { stdout } = captured()
@@ -36,7 +36,7 @@ const finishedProcess = async (repo: InMemRepo, file: string) => {
36
36
  const repoWithConfig = (): InMemRepo => {
37
37
  const repo = new InMemRepo()
38
38
  repo.writeFile("gtd.config.ts", CONFIG)
39
- repo.commitAllWithPrefix("chore: add workflow")
39
+ repo.seedCommit("chore: add workflow")
40
40
  return repo
41
41
  }
42
42
 
@@ -90,7 +90,7 @@ describe("historyEntries", () => {
90
90
  const repo = repoWithConfig()
91
91
  const { entry: hash } = await finishedProcess(repo, "a.txt")
92
92
  repo.writeFile("x.txt", "x\n")
93
- repo.commitAllWithPrefix("chore: bare tip")
93
+ repo.seedCommit("chore: bare tip")
94
94
  repo.updateRef(`refs/gtd/history/${hash}`, "HEAD")
95
95
  expect((await entries(repo))[0]?.branch).toBeNull()
96
96
  })