@pmelab/gtd 25.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 (47) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +7 -0
  3. package/dist/flows.mjs +15 -1
  4. package/dist/gtd.bundle.mjs +161 -20
  5. package/dist/testkit.mjs +311 -57
  6. package/package.json +2 -1
  7. package/src/cli/Cli.test.ts +28 -3
  8. package/src/cli/Cli.ts +12 -1
  9. package/src/cli/History.test.ts +2 -2
  10. package/src/cli/Program.test.ts +10 -12
  11. package/src/edge/Edge.test.ts +113 -16
  12. package/src/edge/Edge.ts +22 -6
  13. package/src/emit/GitScript.test.ts +65 -2
  14. package/src/emit/GitScript.ts +34 -1
  15. package/src/emit/index.ts +1 -0
  16. package/src/flags/index.test.ts +67 -0
  17. package/src/flags/index.ts +38 -0
  18. package/src/flows/runtime.test.ts +33 -0
  19. package/src/flows/runtime.ts +31 -0
  20. package/src/platform/RetainedHistory.test.ts +9 -0
  21. package/src/platform/RetainedHistory.ts +3 -0
  22. package/src/platform/Workspace.test.ts +1 -1
  23. package/src/platform/index.ts +1 -0
  24. package/src/replay/RefName.test.ts +40 -0
  25. package/src/replay/RefName.ts +19 -0
  26. package/src/replay/Replay.test.ts +193 -2
  27. package/src/replay/Replay.ts +51 -6
  28. package/src/replay/Trailers.test.ts +26 -0
  29. package/src/replay/Trailers.ts +8 -0
  30. package/src/replay/index.ts +1 -0
  31. package/src/step/LandStep.ts +1 -0
  32. package/src/step/RepoSnapshot.ts +2 -0
  33. package/src/step/planStart.test.ts +5 -5
  34. package/src/step/planStep.test.ts +53 -0
  35. package/src/step/planStep.ts +7 -1
  36. package/src/testkit/EmittedScriptRecognizer.test.ts +57 -29
  37. package/src/testkit/EmittedScriptRecognizer.ts +26 -4
  38. package/src/testkit/EmittedScripts.test.ts +182 -0
  39. package/src/testkit/FakeGitOperations.ts +8 -2
  40. package/src/testkit/GitTiers.ts +103 -24
  41. package/src/testkit/InMemRepo.ts +57 -12
  42. package/src/testkit/TestRepo.ts +2 -2
  43. package/src/workflow/Workflow.ts +1 -0
  44. package/src/workflow/discovery.test.ts +1 -1
  45. package/src/workflow/vars.test.ts +16 -0
  46. package/src/workflow/vars.ts +4 -1
  47. 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
@@ -6085,6 +6087,26 @@ const restorability = (git, headHash, tipHash) => gen(function* () {
6085
6087
  };
6086
6088
  });
6087
6089
  //#endregion
6090
+ //#region src/flags/index.ts
6091
+ const FLAGS$1 = [];
6092
+ const BOOL = {
6093
+ "": false,
6094
+ "0": false,
6095
+ false: false,
6096
+ "1": true,
6097
+ true: true
6098
+ };
6099
+ const resolveFlags = (env, registry = FLAGS$1) => {
6100
+ const out = {};
6101
+ for (const { name } of registry) {
6102
+ const variable = `GTD_FLAG_${name.toUpperCase()}`;
6103
+ const key = (env[variable] ?? "").toLowerCase();
6104
+ if (!Object.hasOwn(BOOL, key)) throw new Error(`${variable} must be 1, 0, true or false`);
6105
+ out[name] = BOOL[key];
6106
+ }
6107
+ return out;
6108
+ };
6109
+ //#endregion
6088
6110
  //#region src/install/Version.ts
6089
6111
  const _require = createRequire(import.meta.url);
6090
6112
  const findPackageJson = (fromUrl) => {
@@ -6750,6 +6772,22 @@ const accessShapeFault = (value) => {
6750
6772
  const sameGlobs = (a, b) => a === void 0 || b === void 0 ? a === b : a.length === b.length && a.every((g, i) => g === b[i]);
6751
6773
  const sameAccess = (a, b) => sameGlobs(a?.read, b?.read) && sameGlobs(a?.write, b?.write);
6752
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
6753
6791
  //#region src/replay/Tree.ts
6754
6792
  const treeFromRecord = (files) => ({
6755
6793
  paths: () => Object.keys(files).sort(),
@@ -6844,6 +6882,7 @@ const decodePath = (raw) => {
6844
6882
  return typeof path === "string" && path !== "" && !CONTROL_CHARACTER$1.test(path) ? path : void 0;
6845
6883
  };
6846
6884
  const FORMAT_RE = /^[1-9][0-9]*$/;
6885
+ const HASH_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
6847
6886
  const TRAILER_READERS = {
6848
6887
  "Gtd-Step": (value, into) => {
6849
6888
  into.step ??= parseStepId(value);
@@ -6873,6 +6912,9 @@ const TRAILER_READERS = {
6873
6912
  "Gtd-Version": (value, into) => {
6874
6913
  if (value !== "") into.version ??= value;
6875
6914
  },
6915
+ "Gtd-Kept": (value, into) => {
6916
+ if (HASH_RE.test(value)) into.kept ??= value;
6917
+ },
6876
6918
  "Gtd-Format": (value, into) => {
6877
6919
  if (FORMAT_RE.test(value)) into.format ??= Number(value);
6878
6920
  },
@@ -6893,6 +6935,7 @@ const parseCommitMessage = (message) => {
6893
6935
  reviewBase: void 0,
6894
6936
  branch: void 0,
6895
6937
  version: void 0,
6938
+ kept: void 0,
6896
6939
  judge: [],
6897
6940
  vars: {},
6898
6941
  config: {},
@@ -7007,7 +7050,8 @@ const KNOWN_OPTIONS = {
7007
7050
  ...STEERING_OPTIONS,
7008
7051
  "model",
7009
7052
  "system",
7010
- "allowEmpty"
7053
+ "allowEmpty",
7054
+ "keep"
7011
7055
  ]),
7012
7056
  human: /* @__PURE__ */ new Set([
7013
7057
  ...STEERING_OPTIONS,
@@ -7039,6 +7083,7 @@ const replay = async (input) => {
7039
7083
  const scopeCallIds = [];
7040
7084
  let scopeCallCount = 0;
7041
7085
  const trace = [];
7086
+ const keptTips = /* @__PURE__ */ new Map();
7042
7087
  let landed;
7043
7088
  let outcome;
7044
7089
  let signal;
@@ -7198,6 +7243,12 @@ const replay = async (input) => {
7198
7243
  }
7199
7244
  cursor++;
7200
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
+ });
7201
7252
  const to = commit.parsed.parsed?.to;
7202
7253
  if (to !== void 0) expectedNext = {
7203
7254
  name: to,
@@ -7205,6 +7256,21 @@ const replay = async (input) => {
7205
7256
  };
7206
7257
  return replyFor(step.request, commit.parsed.judge);
7207
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
+ };
7208
7274
  const answer = (step) => {
7209
7275
  while (cursor < commits.length && isAttemptAt(commits[cursor], step.name)) cursor++;
7210
7276
  const commit = commits[cursor];
@@ -7215,17 +7281,18 @@ const replay = async (input) => {
7215
7281
  trace,
7216
7282
  landed
7217
7283
  });
7218
- pendingUsed = true;
7219
- landed = step;
7220
- advance({
7221
- hash: "",
7222
- tree: input.pending.tree
7223
- });
7224
- return replyFor(step.request, input.pending.verdicts ?? []);
7284
+ return landPending(step, input.pending);
7225
7285
  };
7226
7286
  const requestFault = (request) => {
7227
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)`;
7228
- 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();
7229
7296
  };
7230
7297
  const handleStep = async (request) => {
7231
7298
  if (outcome !== void 0) throw new Stop();
@@ -7308,6 +7375,13 @@ const replay = async (input) => {
7308
7375
  faults: findings.filter((f) => f.message.startsWith(`Code thread at ${c.path}:${t.line + 1}: `)).map((f) => f.message)
7309
7376
  }));
7310
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
+ },
7311
7385
  vars: input.vars,
7312
7386
  env: input.env,
7313
7387
  start: () => input.start,
@@ -7408,7 +7482,9 @@ const resolveVars = (workflowVars, entryVars, env) => {
7408
7482
  ...entryVars
7409
7483
  };
7410
7484
  for (const name of Object.keys(merged)) {
7411
- const value = env[PREFIX + name.toUpperCase()];
7485
+ const key = PREFIX + name.toUpperCase();
7486
+ if (key.startsWith("GTD_FLAG_")) continue;
7487
+ const value = env[key];
7412
7488
  if (value !== void 0) merged[name] = value;
7413
7489
  }
7414
7490
  return merged;
@@ -8271,7 +8347,11 @@ const planStep = (snapshot, opts = {}) => {
8271
8347
  ...uncheckStep(snapshot),
8272
8348
  {
8273
8349
  kind: "gitWrite",
8274
- write: {
8350
+ write: landing.keepTurn !== void 0 ? {
8351
+ kind: "keepTurn",
8352
+ ref: landing.keepTurn,
8353
+ message
8354
+ } : {
8275
8355
  kind: "commitAll",
8276
8356
  message
8277
8357
  }
@@ -8746,6 +8826,7 @@ const promptDef = (common, request, skills, access) => ({
8746
8826
  ...optional("model", request.options.model),
8747
8827
  ...optional("system", request.options.system),
8748
8828
  ...optional("allowEmpty", request.options.allowEmpty),
8829
+ ...optional("keep", request.options.keep),
8749
8830
  ...optional("skills", skills),
8750
8831
  ...optional("access", access)
8751
8832
  });
@@ -8860,11 +8941,21 @@ const setupFor = (config, run, workspace, host, startOverrides = {}) => gen(func
8860
8941
  hash: run.episode.base,
8861
8942
  tree: commitTree(workspace, run.episode.base)
8862
8943
  };
8863
- const episode = run.episode.commits.map((c) => ({
8864
- hash: c.hash,
8865
- message: c.message,
8866
- tree: commitTree(workspace, c.hash)
8867
- }));
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
+ }
8868
8959
  let pendingMemo;
8869
8960
  return {
8870
8961
  resolved,
@@ -9040,6 +9131,7 @@ const closingTrailers = (branch) => ({
9040
9131
  ...isSome(branch) ? { branch: branch.value } : {}
9041
9132
  });
9042
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) } : {};
9043
9135
  const landingTo = (rest, to, branch) => {
9044
9136
  if (isSettledNoop(rest, to)) return {
9045
9137
  kind: "noop",
@@ -9064,7 +9156,8 @@ const landingTo = (rest, to, branch) => {
9064
9156
  } : {},
9065
9157
  ...finishes ? closingTrailers(branch) : {}
9066
9158
  },
9067
- ...finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {}
9159
+ ...finishes ? { keepHistory: processHistoryRef(rest.run.startHash) } : {},
9160
+ ...keptTurn(rest)
9068
9161
  };
9069
9162
  };
9070
9163
  /**
@@ -9201,7 +9294,7 @@ const commitAllowEmpty = (message) => {
9201
9294
  return [
9202
9295
  `if ! out=$(git commit --allow-empty -m ${m} 2>&1); then`,
9203
9296
  ` case "$out" in`,
9204
- ` *"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 ;;`,
9205
9298
  ` *) printf '%s\\n' "$out" >&2; exit 1 ;;`,
9206
9299
  ` esac`,
9207
9300
  `fi`
@@ -9223,6 +9316,36 @@ const hardResetTo = (ref) => `git reset --hard ${shellQuote(ref)}`;
9223
9316
  * `GitWriterOperations.discardPending` for why it's kept anyway.
9224
9317
  */
9225
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
+ };
9226
9349
  const updateRef = (ref, hash) => `git update-ref ${shellQuote(ref)} ${shellQuote(hash)}`;
9227
9350
  /** `git update-ref -d <ref>` — idempotent: deleting a missing ref is already a no-op in real git. */
9228
9351
  const deleteRef = (ref) => `git update-ref -d ${shellQuote(ref)}`;
@@ -9255,6 +9378,7 @@ const renderGitWrite = (write) => {
9255
9378
  switch (write.kind) {
9256
9379
  case "commitAll": return commitAll(write.message);
9257
9380
  case "updateRef": return updateRef(write.ref, write.target);
9381
+ case "keepTurn": return keepTurn(write.ref, write.message);
9258
9382
  }
9259
9383
  };
9260
9384
  /**
@@ -11303,7 +11427,10 @@ const runCli = (argv, io) => {
11303
11427
  io.exit(2);
11304
11428
  });
11305
11429
  const out = bufferedArtifactOut(io);
11306
- return runCommand$1(plan.command, plan.json, out).pipe(map$4(() => {
11430
+ return flatMap$4(Host, ({ env }) => try_({
11431
+ try: () => resolveFlags(env, io.flags),
11432
+ catch: (e) => new GtdError(e.message)
11433
+ })).pipe(flatMap$4(() => runCommand$1(plan.command, plan.json, out)), map$4(() => {
11307
11434
  out.flush();
11308
11435
  io.exit(0);
11309
11436
  }), provide$2(io.layers(plan.verbose)), sandbox, catchAll$1((cause) => isInterruptedOnly(cause) ? failCause$4(cause) : report(io, plan.json.kind !== "off")(cause)));
@@ -11381,6 +11508,7 @@ var InMemRepo = class InMemRepo {
11381
11508
  return this.head !== null;
11382
11509
  }
11383
11510
  resolveRef(ref) {
11511
+ if (ref.endsWith("^{commit}")) return this.resolveRef(ref.slice(0, -9));
11384
11512
  if (/^[0-9a-f]{40}$/.test(ref)) return this.commits.has(ref) ? ref : null;
11385
11513
  const tildeMatch = /^(HEAD|[0-9a-f]{40})(~(\d+)|\^)?$/.exec(ref);
11386
11514
  if (tildeMatch) {
@@ -11553,8 +11681,12 @@ var InMemRepo = class InMemRepo {
11553
11681
  stageAll() {
11554
11682
  this.index = new Map(this.worktree);
11555
11683
  }
11556
- /** Commit whatever is currently staged (the index) verbatim, with no implicit staging first — mirrors `git commit --allow-empty -m <message>` after a soft reset. */
11557
- 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) {
11558
11690
  const tree = new Map(this.index);
11559
11691
  const parent = this.head;
11560
11692
  const hash = makeHash(message, parent, tree);
@@ -11569,27 +11701,59 @@ var InMemRepo = class InMemRepo {
11569
11701
  this.head = hash;
11570
11702
  this.branches.set(this.currentBranch, hash);
11571
11703
  }
11572
- /** Discard every pending change, tracked or untracked: stage everything, then hard-reset (which drops the freshly-staged untracked paths too). */
11573
- discardPending() {
11574
- this.index = new Map(this.worktree);
11575
- 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);
11576
11718
  }
11577
- 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");
11578
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();
11579
11728
  const tree = new Map(this.index);
11580
- const message = prefix;
11581
11729
  const parent = this.head;
11582
11730
  const hash = makeHash(message, parent, tree);
11583
- const commit = {
11731
+ this.commits.set(hash, {
11584
11732
  hash,
11585
11733
  message,
11586
11734
  files: new Map(tree),
11587
11735
  parent,
11588
11736
  seq: this.nextSeq++
11589
- };
11590
- this.commits.set(hash, commit);
11591
- this.head = hash;
11592
- 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);
11593
11757
  }
11594
11758
  static EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
11595
11759
  softResetTo(ref) {
@@ -11742,6 +11906,7 @@ const makeGitReaderOps = (repo, root) => ({
11742
11906
  return content !== null ? succeed$6(content) : fail$5(/* @__PURE__ */ new Error(`path not found at ${ref}: ${path}`));
11743
11907
  },
11744
11908
  readRefOption: (ref) => {
11909
+ if (/^[0-9a-f]{40}$/.test(ref)) return succeed$6(some(ref));
11745
11910
  const hash = repo.resolveRef(ref);
11746
11911
  return succeed$6(hash !== null ? some(hash) : none());
11747
11912
  },
@@ -11764,10 +11929,13 @@ const makeGitReaderOps = (repo, root) => ({
11764
11929
  subjectHistory: (pageSize, skip, head) => succeed$6(repo.subjectHistory(pageSize, skip, head)),
11765
11930
  changedPaths: (base) => succeed$6(repo.changedPathsWorktree(base))
11766
11931
  });
11932
+ const failOn = (output) => {
11933
+ if (output !== void 0) throw new Error(output);
11934
+ };
11767
11935
  const makeGitWriterOps = (repo) => ({
11768
- commitAllWithPrefix: (prefix) => tryCatch(() => repo.commitAllWithPrefix(prefix)),
11936
+ commitAllWithPrefix: (prefix) => tryCatch(() => failOn(repo.commitAllWithPrefix(prefix))),
11769
11937
  softResetTo: (ref) => tryCatch(() => repo.softResetTo(ref)),
11770
- commitAsIs: (message) => tryCatch(() => repo.commitAsIs(message)),
11938
+ commitAsIs: (message) => tryCatch(() => failOn(repo.commitAsIs(message))),
11771
11939
  discardPending: () => tryCatch(() => repo.discardPending()),
11772
11940
  updateRef: (ref, hash) => tryCatch(() => repo.updateRef(ref, hash)),
11773
11941
  deleteRef: (ref) => tryCatch(() => repo.deleteRef(ref)),
@@ -12270,25 +12438,51 @@ const conditionStatement = (block) => {
12270
12438
  const end = block.indexOf(CONDITION_TERMINATOR, start);
12271
12439
  return end === -1 ? block.slice(start) : block.slice(start, end);
12272
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
+ };
12273
12458
  const recognizeGitBuilders = (repo, block) => {
12274
12459
  if (block === discardPending()) {
12275
12460
  repo.discardPending();
12276
12461
  return { kind: "applied" };
12277
12462
  }
12278
12463
  const conditionLine = conditionStatement(block);
12464
+ if (block.startsWith("git add -A || exit 1\ngtd_sign_cfg=")) return recognizeKeepTurn(repo, block);
12279
12465
  if (block.startsWith("git add -A || exit 1\n")) {
12280
12466
  const [message] = extractQuotedTokens(conditionLine);
12281
12467
  if (message !== void 0 && commitAll(message) === block) {
12282
- repo.commitAllWithPrefix(message);
12283
- 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
+ };
12284
12474
  }
12285
12475
  return;
12286
12476
  }
12287
12477
  if (block.startsWith("if ! out=$(git commit --allow-empty -m ")) {
12288
12478
  const [message] = extractQuotedTokens(conditionLine);
12289
12479
  if (message !== void 0 && commitAsIs(message) === block) {
12290
- repo.commitAsIs(message);
12291
- 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
+ };
12292
12486
  }
12293
12487
  return;
12294
12488
  }
@@ -12825,7 +13019,7 @@ const testRepo = (options) => {
12825
13019
  const id = `module-${++registered}`;
12826
13020
  repo.writeFile("gtd.config.ts", typeof workflow === "string" ? workflow : moduleSource(id, workflow));
12827
13021
  for (const [path, content] of Object.entries(options.files ?? {})) repo.writeFile(path, content);
12828
- repo.commitAllWithPrefix("chore: initial commit");
13022
+ repo.seedCommit("chore: initial commit");
12829
13023
  const gtd = async (...args) => {
12830
13024
  const { io, result } = makeCapturingCliIo(repo, env);
12831
13025
  if (typeof workflow !== "string") registry().set(id, workflow);
@@ -12893,7 +13087,7 @@ const testRepo = (options) => {
12893
13087
  read: (path) => repo.readFile(path),
12894
13088
  write: (path, content) => repo.writeFile(path, content),
12895
13089
  remove: (path) => repo.deleteFile(path),
12896
- commit: (message) => repo.commitAllWithPrefix(message),
13090
+ commit: (message) => repo.seedCommit(message),
12897
13091
  show: (ref, path) => repo.fileAtRef(ref, path) ?? void 0,
12898
13092
  runScript: (script) => {
12899
13093
  const flow = applyFlowScript(repo, /* @__PURE__ */ new Map(), script);
@@ -12933,6 +13127,14 @@ const testRepo = (options) => {
12933
13127
  };
12934
13128
  //#endregion
12935
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
+ };
12936
13138
  /** No-op — these tests assert on git/config behavior, not narration. */
12937
13139
  const noopNarratorLayer = Narrator.layer(() => {}, false);
12938
13140
  const gitExecIn = (dir, ...args) => execSync(`git ${args.join(" ")}`, {
@@ -12982,7 +13184,6 @@ const makeLiveTier = (initialCommit = true) => {
12982
13184
  root,
12983
13185
  capabilities: {
12984
13186
  onDiskContent: true,
12985
- commitHooks: true,
12986
13187
  linkedWorktrees: true,
12987
13188
  directoryChainConfig: true,
12988
13189
  remotes: true
@@ -13010,7 +13211,22 @@ const makeLiveTier = (initialCommit = true) => {
13010
13211
  },
13011
13212
  stageAll: () => gitExec("add", "-A"),
13012
13213
  updateRef: (ref, hash) => gitExec("update-ref", ref, hash),
13013
- 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
+ }
13014
13230
  },
13015
13231
  observe: {
13016
13232
  resolveRef: (ref) => gitExec("rev-parse", ref),
@@ -13076,7 +13292,7 @@ const makeInMemTier = (initialCommit = true) => {
13076
13292
  const repo = new InMemRepo();
13077
13293
  if (initialCommit) {
13078
13294
  repo.writeFile("readme.txt", "hello");
13079
- repo.commitAllWithPrefix("init: first commit");
13295
+ repo.seedCommit("init: first commit");
13080
13296
  }
13081
13297
  const gitLayer = gitTestLayer(repo, IN_MEM_ROOT);
13082
13298
  const provide = (eff) => runPromise(eff.pipe(provide$2(gitLayer), provide$2(noopNarratorLayer)));
@@ -13086,7 +13302,6 @@ const makeInMemTier = (initialCommit = true) => {
13086
13302
  root: IN_MEM_ROOT,
13087
13303
  capabilities: {
13088
13304
  onDiskContent: false,
13089
- commitHooks: false,
13090
13305
  linkedWorktrees: false,
13091
13306
  directoryChainConfig: true,
13092
13307
  remotes: false
@@ -13097,17 +13312,29 @@ const makeInMemTier = (initialCommit = true) => {
13097
13312
  seed: {
13098
13313
  commit: (message, files = { "file.txt": message }) => {
13099
13314
  for (const [path, content] of Object.entries(files)) repo.writeFile(path, content);
13100
- repo.commitAllWithPrefix(message);
13315
+ repo.seedCommit(message);
13101
13316
  },
13102
13317
  writeFile: (path, content) => repo.writeFile(path, content),
13103
13318
  deleteFile: (path) => repo.deleteFile(path),
13104
13319
  commitDeletion: (path, message) => {
13105
13320
  repo.deleteFile(path);
13106
- repo.commitAllWithPrefix(message);
13321
+ repo.seedCommit(message);
13107
13322
  },
13108
13323
  stageAll: () => repo.stageAll(),
13109
13324
  updateRef: (ref, hash) => repo.updateRef(ref, hash),
13110
- 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
+ }
13111
13338
  },
13112
13339
  observe: {
13113
13340
  resolveRef: (ref) => {
@@ -13438,16 +13665,6 @@ const runGitServiceContract = (makeTier) => {
13438
13665
  const history = await runGit(t, (g) => g.commitHistory());
13439
13666
  expect(history[history.length - 1]?.message).toBe("gtd: grilled");
13440
13667
  });
13441
- it.skipIf(!capabilities.commitHooks)("retries with --no-verify when a hook blocks the empty commit", async () => {
13442
- const hookPath = join(t.root, ".git/hooks/pre-commit");
13443
- writeFileSync(hookPath, `#!/bin/sh\necho "lint-staged prevented an empty git commit." >&2\nexit 1\n`);
13444
- execSync(`chmod +x "${hookPath}"`);
13445
- const headBefore = t.observe.resolveRef("HEAD");
13446
- await runGit(t, (g) => g.commitAllWithPrefix("gtd: grilled"));
13447
- expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore);
13448
- const history = await runGit(t, (g) => g.commitHistory());
13449
- expect(history[history.length - 1]?.message).toBe("gtd: grilled");
13450
- });
13451
13668
  });
13452
13669
  describe("commitAsIs", () => {
13453
13670
  it("commits exactly what's staged, ignoring later worktree writes (the squash mechanic)", async () => {
@@ -13466,6 +13683,29 @@ const runGitServiceContract = (makeTier) => {
13466
13683
  expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore);
13467
13684
  });
13468
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
+ });
13469
13709
  describe("softResetTo", () => {
13470
13710
  it("moves HEAD back but keeps worktree changes from second commit", async () => {
13471
13711
  const firstHash = t.observe.resolveRef("HEAD");
@@ -13513,6 +13753,20 @@ const runGitServiceContract = (makeTier) => {
13513
13753
  it("readRefOption is None for a ref that doesn't exist — never a failure", async () => {
13514
13754
  expect((await runGit(t, (g) => g.readRefOption("refs/gtd/nonexistent")))._tag).toBe("None");
13515
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
+ });
13516
13770
  it("deleteRef removes a ref idempotently (deleting twice is a no-op)", async () => {
13517
13771
  const head = t.observe.resolveRef("HEAD");
13518
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": "25.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": {
@@ -69,6 +69,7 @@
69
69
  "src/cli/",
70
70
  "src/edge/",
71
71
  "src/emit/",
72
+ "src/flags/",
72
73
  "src/install/",
73
74
  "src/platform/",
74
75
  "src/replay/",