@pmelab/gtd 27.2.0 → 27.4.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 (67) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +14 -1
  3. package/dist/chunks/{reserved-B2QaImnN.mjs → reserved-BPdVZkgv.mjs} +29 -4
  4. package/dist/chunks/{tail-BnhIxCYv.mjs → tail-iLAMoigl.mjs} +1 -1
  5. package/dist/chunks/{wire-BY1S-obW.mjs → wire-C12dahrH.mjs} +6 -4
  6. package/dist/flows.mjs +24 -1
  7. package/dist/gtd.bundle.mjs +174 -23
  8. package/dist/testkit.mjs +327 -27
  9. package/dist/wire.mjs +1 -1
  10. package/dist/workflows/cleanup.mjs +2 -2
  11. package/dist/workflows/feature.mjs +119 -6
  12. package/dist/workflows/fix.mjs +2 -2
  13. package/dist/workflows/review.mjs +2 -2
  14. package/dist/workflows/rollout.mjs +1 -1
  15. package/dist/workflows.mjs +146 -8
  16. package/package.json +1 -1
  17. package/src/cli/Cli.ts +5 -5
  18. package/src/cli/History.ts +5 -0
  19. package/src/cli/Program.ts +1 -0
  20. package/src/edge/Edge.test.ts +160 -0
  21. package/src/edge/Edge.ts +90 -8
  22. package/src/emit/GitScript.test.ts +39 -3
  23. package/src/emit/GitScript.ts +25 -1
  24. package/src/emit/index.ts +2 -0
  25. package/src/flows/helpers.ts +28 -0
  26. package/src/flows/runtime.ts +7 -0
  27. package/src/install/Install.ts +2 -1
  28. package/src/platform/Git.test.ts +25 -1
  29. package/src/platform/Git.ts +30 -0
  30. package/src/platform/index.ts +1 -0
  31. package/src/replay/Format.test.ts +16 -1
  32. package/src/replay/Replay.test.ts +91 -0
  33. package/src/replay/Replay.ts +22 -2
  34. package/src/replay/Trailers.test.ts +110 -1
  35. package/src/replay/Trailers.ts +37 -1
  36. package/src/replay/index.ts +2 -0
  37. package/src/replay/seamGate.test.ts +62 -0
  38. package/src/step/LandStep.ts +1 -0
  39. package/src/step/planStep.test.ts +32 -0
  40. package/src/step/planStep.ts +3 -1
  41. package/src/testkit/EmittedScriptRecognizer.test.ts +27 -0
  42. package/src/testkit/EmittedScriptRecognizer.ts +33 -0
  43. package/src/testkit/EmittedScripts.test.ts +9 -0
  44. package/src/testkit/FakeGitOperations.ts +5 -0
  45. package/src/testkit/GitTiers.ts +158 -0
  46. package/src/testkit/InMemRepo.ts +38 -3
  47. package/src/wire/BeatDocument.test.ts +25 -0
  48. package/src/wire/BeatDocument.ts +4 -2
  49. package/src/wire/BeatPlain.test.ts +1 -0
  50. package/src/wire/BeatStatus.test.ts +2 -0
  51. package/src/wire/BeatStatus.ts +13 -2
  52. package/src/wire/WireSchema.test.ts +10 -0
  53. package/src/wire/types.ts +8 -0
  54. package/src/workflow/Workflow.ts +1 -0
  55. package/src/workflows/feature/index.ts +1 -0
  56. package/src/workflows/feature/refactor.test.ts +466 -0
  57. package/src/workflows/feature/refactor.ts +178 -0
  58. package/src/workflows/index.ts +1 -0
  59. package/src/workflows/shared/access.ts +2 -1
  60. package/src/workflows/shared/health.test.ts +67 -3
  61. package/src/workflows/shared/health.ts +5 -3
  62. package/src/workflows/shared/options.test.ts +8 -0
  63. package/src/workflows/shared/options.ts +1 -0
  64. package/src/workflows/shared/skills.test.ts +6 -0
  65. package/src/workflows/shared/skills.ts +5 -1
  66. package/src/workflows/shared/text.ts +35 -0
  67. package/src/workflows/shared/vars.ts +1 -0
package/dist/testkit.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { checkScript, configProblems, configure, installContext, isFactoryWorkflow, moveScript, protocolMismatch, removeScript, restoreScript, revertScript, showScript, t as flows_exports } from "./flows.mjs";
2
- import { E as ACCESS_REFUSAL, _ as demandOf, a as landFields, c as renderLandJson, d as renderBeatPlain, f as beatDocument, g as beatKindOf, i as judgeJson, k as renderFormat, l as renderLandPlain, m as statusOf, n as resolveSession, p as renderBeatJson, r as selectPath, s as noopText, t as globMatches } from "./chunks/wire-BY1S-obW.mjs";
2
+ import { E as ACCESS_REFUSAL, _ as demandOf, a as landFields, c as renderLandJson, d as renderBeatPlain, f as beatDocument, g as beatKindOf, i as judgeJson, k as renderFormat, l as renderLandPlain, m as statusOf, n as resolveSession, p as renderBeatJson, r as selectPath, s as noopText, t as globMatches } from "./chunks/wire-C12dahrH.mjs";
3
3
  import { C as headingSections, S as headingSectionBodies, a as unansweredQuestions, b as parseThreadsWithFindings, n as openThreads, r as steeringFormatFor, s as parseCodeThreads, t as BUILT_IN_MODE_NAMES, w as formatFinding } from "./chunks/steering-DVILanVf.mjs";
4
4
  import { $ as logWarning, $i as format, $n as map$5, $r as make$19, $t as provide$1, A as catchIf, Ai as get$5, An as scopeMake, Ar as flatMap$3, At as suspend$5, B as flatMap$4, Bi as isNone, Bn as get$3, Br as sync$1, Bt as unsafeMakeLatch, C as all$1, Ci as toArray, Cn as isInterrupted, Cr as exitFail, Ct as sandbox, D as async, Di as GenericTag, Dn as squash, Dr as exitZipRight, Dt as scopedWith$1, E as asVoid$1, Ei as unsafeHead, En as reduceWithContext, Er as exitVoid, Et as scoped$4, F as ensuring$2, Fi as splitAt, Fn as join$3, Fr as map$3, Ft as tryMap, G as forkIn, Gi as some, Gn as TypeId$10, Gr as whenEffect, Gt as zipLeft, H as fnUntraced, Hi as map$8, Hn as modify$1, Hr as uninterruptible, Ht as when, I as exit, Ii as filter$1, In as sequential, Ir as onInterrupt, It as tryPromise, J as ignore, Ji as match$2, Jn as all, Jr as DeferredTypeId$1, Jt as spaced$1, K as forkScoped, Ki as isLeft, Kn as Class, Kr as withFiberRuntime, Kt as zipRight$3, L as fail$5, Li as flatMap$5, Ln as bind, Lr as scopeClose, Lt as try_, M as die$2, Mi as omit, Mn as _await$2, Mr as interruptAsFiber, Mt as tap, N as dieMessage, Ni as empty$5, Nn as inheritAll$1, Nr as interruptFiber, Nt as tapErrorCause, O as catchAll$1, Oi as Tag, On as ensuring$3, Or as fiberId, Ot as serviceOption, P as either, Pi as fromIterable, Pn as interruptAllAs$1, Pr as interruptible$1, Pt as timeout, Q as isEffect, Qi as NodeInspectSymbol, Qn as isFailure, Qr as ids, Qt as mergeAll$2, R as failCause$4, Ri as fromNullable, Rn as bindTo, Rr as succeed$4, Rt as uninterruptible$1, S as addFinalizer$1, Si as takeRight, Sn as isDieType, Sr as exitAs, St as runtime, T as as, Ti as unsafeGet, Tn as map$6, Tr as exitSucceed, Tt as scopeWith, U as forEach$2, Ui as match$4, Un as set$1, Ur as uninterruptibleMask$1, Ut as withFiberRuntime$1, V as flatten$4, Vi as isSome, Vn as make$16, Vr as tap$1, Vt as unsafeMakeSemaphore$1, W as forkDaemon, Wi as none, Wn as update$1, Wr as void_$2, Wt as zip$1, X as interruptible, Xi as pipeArguments, Xn as fail$4, Xr as combine, Xt as tagged, Y as interrupt$4, Yi as right, Yn as die, Yr as empty$6, Yt as TaggedError$1, Z as intoDeferred, Zi as BaseProto, Zn as failCause$3, Zr as combineAll, Zt as fromEffect$5, _ as decodeSync, _i as of, _n as fail$3, _r as deferredIsDone, _t as raceWith, a as Literal, aa as constUndefined, ai as size$2, an as updateContext$1, ar as zipRight$2, at as matchCause, b as acquireRelease, bi as reverse, bn as interrupt$3, br as deferredUnsafeDone, bt as runPromise, c as String$, ca as identity, ci as append$1, cn as Scope, cr as asyncInterrupt, ct as negate, d as Union, di as empty$4, dn as close, dr as deferredAwait, dt as orElse, ea as toJSON, ei as none$1, en as provideMerge$1, er as mapBoth, et as makeSemaphore, f as between, fi as filter, fn as extend, fr as deferredComplete, ft as orElseSucceed, gi as map$7, gn as die$1, gr as deferredInterruptWith, gt as race, hi as isEmpty$2, hn as NoSuchElementException, hr as deferredInterrupt, ht as provideService, i as Defect, ia as constTrue, ii as has, in as unsafeRunPromiseExit, ir as zip$2, it as match$5, j as catchTag, ji as getOption, jn as scopeTag, jr as interrupt$2, jt as sync$2, k as catchAllCause, ki as add, kn as forEachConcurrentDiscard, kr as fiberRefLocally, kt as succeed$6, l as Struct, la as pipe, li as appendAll, ln as addFinalizer, lr as catchAll, lt as onError, m as optional$1, mi as isChunk, mn as make$18, mr as deferredFailCause, mt as provide$2, n as Boolean$, na as hasProperty, ni as make$17, nn as succeed$3, nr as succeed$5, nt as mapBoth$1, o as Never, oa as constVoid, oi as decode, on as forkIn$1, or as as$1, ot as matchCauseEffect, p as decodeUnknown, pi as fromIterable$1, pn as fork, pr as deferredFail, pt as promise, q as gen, qi as left, qn as spanTag, qr as zipRight$1, qt as zipWith, r as Cause, ra as constFalse, ri as set$2, rn as unsafeFork, rr as void_$3, rt as mapError, s as Number$, sa as dual, si as isFinite, sn as unsafeMakeSemaphore, sr as asVoid, st as matchEffect, t as Array$, ta as equals, ti as get$4, tn as scoped$3, tr as match$3, tt as map$4, u as TaggedError, ui as drop, un as addFinalizerExit, ur as currentContext$1, ut as orDie, v as encodeSync, vi as prepend, vn as failureOrCause, vr as deferredMake, vt as repeat, w as annotateLogs, wi as unsafeFromArray, wn as isInterruptedOnly, wr as exitFailCause, wt as scope, x as acquireUseRelease$2, xi as take$2, xn as interruptors, xr as deferredUnsafeMake, xt as runPromiseExit$1, y as _void, yi as reduce, yn as flipCauseOption, yr as deferredSucceed, yt as retry, z as fiberIdWith, zi as getOrElse, zn as sleep, zr as suspend$4, zt as uninterruptibleMask } from "./chunks/Schema-CyImoHTj.mjs";
5
5
  import { createRequire } from "node:module";
@@ -5402,6 +5402,13 @@ const makeGitImpl = (executor, root) => {
5402
5402
  hasCommits: () => exec("git", "rev-parse", "--verify", "HEAD").pipe(map$4(() => true), catchAll$1(() => succeed$6(false))),
5403
5403
  readFileAtRef: (ref, path) => exec("git", "show", `${ref}:${path}`),
5404
5404
  diffPaths: (base, tip) => exec("git", "diff", "--name-only", "-z", "--no-renames", "--no-relative", "--ignore-submodules=none", "--end-of-options", base, tip, "--").pipe(map$4(splitNul)),
5405
+ authorIdent: () => exec("git", "var", "GIT_AUTHOR_IDENT").pipe(map$4((out) => {
5406
+ const match = /^(.*?) <([^<>]*)> \d+ [+-]\d{4}$/.exec(out.trim());
5407
+ return match === null || match[1] === "" || match[2] === "" ? none() : some({
5408
+ name: match[1],
5409
+ email: match[2]
5410
+ });
5411
+ }), catchAll$1(() => succeed$6(none()))),
5405
5412
  changedPaths: (base) => gen(function* () {
5406
5413
  const ref = base ?? "HEAD";
5407
5414
  const nameStatusOut = yield* exec("git", "diff", "--name-status", "-z", ref).pipe(catchAll$1(() => succeed$6("")));
@@ -5419,6 +5426,7 @@ const makeGitImpl = (executor, root) => {
5419
5426
  }
5420
5427
  return all;
5421
5428
  }),
5429
+ stagedPaths: () => exec("git", "diff", "--cached", "--name-status", "--no-renames", "-z", "HEAD").pipe(catchAll$1(() => succeed$6("")), map$4((out) => parseNameStatus(splitNul(out)))),
5422
5430
  resolveRef: (ref) => exec("git", "rev-parse", "--verify", "--end-of-options", ref).pipe(map$4((s) => s.trim()), flatMap$4((hash) => resolvedRefOrCorrupted(ref, hash))),
5423
5431
  readRefOption: (ref) => exec("git", "rev-parse", "--verify", "--quiet", ref).pipe(map$4((s) => some(s.trim())), catchAll$1(() => succeed$6(none()))),
5424
5432
  isAncestor: (a, b) => run$1(root, "git", "merge-base", "--is-ancestor", a, b).pipe(provide$2(succeed$1(CommandExecutor, executor)), map$4(() => true), catchAll$1(() => succeed$6(false))),
@@ -6358,7 +6366,8 @@ skills, \`skills\` (an array of the skill names, already trimmed and split —
6358
6366
  (\`{read, write}\`, each an array of globs or \`null\` for unrestricted). When set: \`model\`, \`memory\`,
6359
6367
  \`label\`, \`file\`, \`mode\`, \`edges\`, \`judge\` (the pending judgment's rendered
6360
6368
  document). When a cost has been recorded (a prior \`gtd land --cost=<n>\`):
6361
- \`cost\`, \`costByModel\`.
6369
+ \`cost\`, \`costByModel\`. Always \`approvals\` (an array of
6370
+ \`{step, name, email, commit}\`, \`[]\` when none).
6362
6371
 
6363
6372
  \`gtd land\` fields, in fixed order: \`schema\`, \`script\` (the POSIX sh to run), \`settled\`
6364
6373
  (true for a no-op at a \`script\` rest — stop immediately, nothing more to
@@ -6879,12 +6888,38 @@ const TRAILER_RE = /^(Gtd-[A-Za-z-]+):[ \t]*(.*?)[ \t]*$/gm;
6879
6888
  const COST_RE = /^([0-9]+(?:\.[0-9]+)?)(?:[ \t]+(.+))?$/;
6880
6889
  const VAR_RE = /^([^=\s]+)=(.*)$/;
6881
6890
  const CONFIG_RE = /^([0-9a-f]{16}) (.+)$/;
6882
- const CONTROL_CHARACTER$1 = /[\x00-\x1f\x7f-\x9f]/;
6891
+ const CONTROL_CHARACTER$1 = /[\x00-\x1f\x7f-\x9f\u2028\u2029]/;
6883
6892
  const encodePath = (path) => path.trim() !== path || path.startsWith("\"") || CONTROL_CHARACTER$1.test(path) ? JSON.stringify(path) : path;
6884
6893
  const decodePath = (raw) => {
6885
6894
  const path = raw.startsWith("\"") ? parseJson(raw) : raw;
6886
6895
  return typeof path === "string" && path !== "" && !CONTROL_CHARACTER$1.test(path) ? path : void 0;
6887
6896
  };
6897
+ const decodeApproval = (raw) => {
6898
+ const parsed = parseJson(raw);
6899
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
6900
+ const { step, name, email } = parsed;
6901
+ if (typeof step !== "string" || typeof name !== "string" || typeof email !== "string") return void 0;
6902
+ if ([
6903
+ step,
6904
+ name,
6905
+ email
6906
+ ].some((field) => CONTROL_CHARACTER$1.test(field))) return void 0;
6907
+ const id = parseStepId(step);
6908
+ return id === void 0 ? void 0 : {
6909
+ step: id,
6910
+ name,
6911
+ email
6912
+ };
6913
+ };
6914
+ const encodeApproval = ({ step, name, email }) => JSON.stringify({
6915
+ step: formatStepId(step),
6916
+ name,
6917
+ email
6918
+ });
6919
+ const isRecordableApproval = (approval) => {
6920
+ const back = decodeApproval(encodeApproval(approval));
6921
+ return back !== void 0 && encodeApproval(back) === encodeApproval(approval);
6922
+ };
6888
6923
  const FORMAT_RE = /^[1-9][0-9]*$/;
6889
6924
  const HASH_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
6890
6925
  const TRAILER_READERS = {
@@ -6919,6 +6954,9 @@ const TRAILER_READERS = {
6919
6954
  "Gtd-Kept": (value, into) => {
6920
6955
  if (HASH_RE.test(value)) into.kept ??= value;
6921
6956
  },
6957
+ "Gtd-Approved": (value, into) => {
6958
+ into.approved ??= decodeApproval(value);
6959
+ },
6922
6960
  "Gtd-Format": (value, into) => {
6923
6961
  if (FORMAT_RE.test(value)) into.format ??= Number(value);
6924
6962
  },
@@ -6940,6 +6978,7 @@ const parseCommitMessage = (message) => {
6940
6978
  branch: void 0,
6941
6979
  version: void 0,
6942
6980
  kept: void 0,
6981
+ approved: void 0,
6943
6982
  judge: [],
6944
6983
  vars: {},
6945
6984
  config: {},
@@ -6965,6 +7004,7 @@ const formatCommitMessage = (spec) => {
6965
7004
  for (const [path, hash] of sortedEntries(spec.config)) lines.push(`Gtd-Config: ${hash} ${encodePath(path)}`);
6966
7005
  if (spec.cost !== void 0) lines.push(`Gtd-Cost: ${spec.cost.cost}${spec.cost.model !== void 0 ? ` ${spec.cost.model}` : ""}`);
6967
7006
  for (const verdict of spec.judge ?? []) lines.push(`Gtd-Judge: ${JSON.stringify(verdict)}`);
7007
+ if (spec.approved !== void 0) lines.push(`Gtd-Approved: ${encodeApproval(spec.approved)}`);
6968
7008
  if (spec.branch !== void 0) lines.push(`Gtd-Branch: ${spec.branch}`);
6969
7009
  if (spec.version !== void 0) lines.push(`Gtd-Version: ${spec.version}`);
6970
7010
  lines.push(`Gtd-Format: 1`);
@@ -7060,7 +7100,8 @@ const KNOWN_OPTIONS = {
7060
7100
  human: /* @__PURE__ */ new Set([
7061
7101
  ...STEERING_OPTIONS,
7062
7102
  "message",
7063
- "acceptClean"
7103
+ "acceptClean",
7104
+ "approve"
7064
7105
  ]),
7065
7106
  run: new Set(STEERING_OPTIONS),
7066
7107
  judge: /* @__PURE__ */ new Set([...STEERING_OPTIONS, "message"])
@@ -7070,6 +7111,13 @@ const unknownOptions = (step) => {
7070
7111
  if (unknown.length === 0) return void 0;
7071
7112
  return `gtd: step "${step.name}": unknown key(s) ${unknown.join(", ")} in ${step.kind}() options`;
7072
7113
  };
7114
+ const approveConflict = (step) => {
7115
+ const request = step.request;
7116
+ if (request.kind !== "human" || request.options.approve !== true) return void 0;
7117
+ const keys = ["mode", "file"].filter((key) => request.options[key] !== void 0);
7118
+ if (keys.length === 0) return void 0;
7119
+ return `gtd: step "${step.name}": approve cannot be combined with ${keys.join(", ")} in human() options — an approval records exactly the judged tree, and a steering file is rewritten on landing`;
7120
+ };
7073
7121
  const replay = async (input) => {
7074
7122
  const commits = input.episode.commits.map((c) => ({
7075
7123
  ...c,
@@ -7229,12 +7277,21 @@ const replay = async (input) => {
7229
7277
  kind: "divergence",
7230
7278
  message: `gtd: commit ${short(expected.hash)} names "${expected.name}" as the next step, but replay reached "${step.name}" — ${DIVERGED}`
7231
7279
  };
7232
- const error = unknownOptions(step) ?? checkIdentity(step);
7280
+ const error = unknownOptions(step) ?? approveConflict(step) ?? checkIdentity(step);
7233
7281
  return error === void 0 ? void 0 : {
7234
7282
  kind: "failed",
7235
7283
  message: error
7236
7284
  };
7237
7285
  };
7286
+ const missingApproval = (step, commit, expected) => {
7287
+ if (step.request.kind !== "human" || step.request.options.approve !== true) return void 0;
7288
+ const approved = commit.parsed.approved;
7289
+ if (approved !== void 0 && formatStepId(approved.step) === expected) return void 0;
7290
+ return {
7291
+ kind: "divergence",
7292
+ message: `gtd: commit ${short(commit.hash)} completes approval gate "${expected}" but records no Gtd-Approved for it — ${DIVERGED}`
7293
+ };
7294
+ };
7238
7295
  const consumeCommit = (step, commit) => {
7239
7296
  const recorded = commit.parsed.step;
7240
7297
  const expected = formatStepId(step.id);
@@ -7245,6 +7302,8 @@ const replay = async (input) => {
7245
7302
  message: `gtd: commit ${short(commit.hash)} ${what}, but replay expected "${expected}" — ${DIVERGED}`
7246
7303
  });
7247
7304
  }
7305
+ const unapproved = missingApproval(step, commit, expected);
7306
+ if (unapproved !== void 0) return finish(unapproved);
7248
7307
  cursor++;
7249
7308
  advance(commit);
7250
7309
  if (commit.parsed.kept !== void 0) if (commit.kept === void 0) keptTips.delete(step.name);
@@ -8355,6 +8414,9 @@ const planStep = (snapshot, opts = {}) => {
8355
8414
  kind: "keepTurn",
8356
8415
  ref: landing.keepTurn,
8357
8416
  message
8417
+ } : landing.spec.approved !== void 0 ? {
8418
+ kind: "commitClean",
8419
+ message
8358
8420
  } : {
8359
8421
  kind: "commitAll",
8360
8422
  message
@@ -8556,6 +8618,12 @@ const pinnedConfigOf = (history) => {
8556
8618
  };
8557
8619
  }
8558
8620
  };
8621
+ const approvalEntryOf = (hash, message) => message.approved === void 0 ? [] : [{
8622
+ step: formatStepId(message.approved.step),
8623
+ name: message.approved.name,
8624
+ email: message.approved.email,
8625
+ commit: hash
8626
+ }];
8559
8627
  const buildRun = (history, location, closingHash) => {
8560
8628
  const processCommits = history.slice(location.processStart);
8561
8629
  const parsed = processCommits.map((c) => parseCommitMessage(c.message));
@@ -8571,6 +8639,7 @@ const buildRun = (history, location, closingHash) => {
8571
8639
  trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i])),
8572
8640
  costEntries: parsed.flatMap(costEntriesOf),
8573
8641
  judgeVerdicts: parsed.flatMap((m) => m.judge),
8642
+ approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i])),
8574
8643
  pinnedVars: { ...first?.vars },
8575
8644
  pinnedConfig: pinnedConfigOf(history.slice(Math.max(location.baseIndex, 0))),
8576
8645
  headTurn: headTurnOf(head),
@@ -8856,7 +8925,8 @@ const messageDef = (common, request, step) => ({
8856
8925
  ...common,
8857
8926
  kind: "message",
8858
8927
  content: request.options.message ?? request.options.label ?? step.name,
8859
- ...optional("acceptClean", request.options.acceptClean)
8928
+ ...optional("acceptClean", request.options.approve === true || request.options.acceptClean),
8929
+ ...optional("approve", request.options.approve)
8860
8930
  });
8861
8931
  const stepDefOf = (step) => {
8862
8932
  const request = step.request;
@@ -8985,6 +9055,12 @@ const setupFor = (config, run, workspace, host, startOverrides = {}) => gen(func
8985
9055
  }
8986
9056
  };
8987
9057
  });
9058
+ const pendingChanges = (git, withIndex) => gen(function* () {
9059
+ const worktree = yield* git.changedPaths();
9060
+ if (!withIndex) return worktree;
9061
+ const seen = new Set(worktree.map((e) => e.path));
9062
+ return [...worktree, ...(yield* git.stagedPaths()).filter((e) => !seen.has(e.path))];
9063
+ });
8988
9064
  /** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
8989
9065
  const restAt = (ref, startOverrides = {}) => gen(function* () {
8990
9066
  const git = yield* GitService;
@@ -9000,7 +9076,7 @@ const restAt = (ref, startOverrides = {}) => gen(function* () {
9000
9076
  yield* (yield* Narrator).narrate(`rest resolved: ${step.name} (awaits ${step.actor})`);
9001
9077
  const stepDef = stepDefOf(step);
9002
9078
  if (stepDef.mode !== void 0 && def.modes[stepDef.mode] === void 0) return yield* fail$5(/* @__PURE__ */ new Error(`gtd config: step "${step.name}": mode "${stepDef.mode}" is not a mode this workflow knows (${knownModes(def).join(", ")})`));
9003
- const changes = ref === void 0 ? (yield* git.changedPaths()).map((e) => ({
9079
+ const changes = ref === void 0 ? (yield* pendingChanges(git, stepDef.approve === true)).map((e) => ({
9004
9080
  status: normalizeStatus(e.status),
9005
9081
  path: e.path
9006
9082
  })) : [];
@@ -9086,6 +9162,25 @@ const noProcessUnderway = (rest) => rest.run.workflow === void 0 && rest.state =
9086
9162
  /** `idle` means exactly one thing: no process underway, clean tree. */
9087
9163
  const restIsIdle = (rest) => noProcessUnderway(rest) && rest.changes.length === 0;
9088
9164
  const isHumanReviewGate = (def) => def.actor === "human" && def.mode === "review";
9165
+ const approvalRefusal = (rest, approver) => {
9166
+ if (rest.stepDef.approve !== true) return void 0;
9167
+ const id = rest.step.id;
9168
+ if (rest.changes.length > 0) return {
9169
+ kind: "refusal",
9170
+ message: `gtd land: approval-gate: ${formatStepId(id)} approves exactly the judged diff — revert your edits and run \`gtd land\` on a clean tree to approve, or run \`gtd abandon\``
9171
+ };
9172
+ if (isNone(approver)) return {
9173
+ kind: "refusal",
9174
+ message: `gtd land: approval-gate: ${formatStepId(id)} records who approved it, but git has no author identity — set user.name and user.email`
9175
+ };
9176
+ if (!isRecordableApproval({
9177
+ step: id,
9178
+ ...approver.value
9179
+ })) return {
9180
+ kind: "refusal",
9181
+ message: `gtd land: approval-gate: ${formatStepId(id)} cannot record ${approver.value.name} <${approver.value.email}> as approver — it would not read back unchanged from its Gtd-Approved trailer (a control character, for example)`
9182
+ };
9183
+ };
9089
9184
  /** The landing a clean tree decides before any replay, if it decides one. */
9090
9185
  const cleanLanding = (rest) => {
9091
9186
  if (rest.changes.length > 0) return void 0;
@@ -9140,7 +9235,11 @@ const closingTrailers = (branch) => ({
9140
9235
  });
9141
9236
  const isSettledNoop = (rest, to) => rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state;
9142
9237
  const keptTurn = (rest) => rest.stepDef.keep === true && rest.changes.length > 0 ? { keepTurn: keptRef(rest.run.startHash, rest.state) } : {};
9143
- const landingTo = (rest, to, branch) => {
9238
+ const approvedTrailer = (rest, approver) => isSome(approver) ? { approved: {
9239
+ step: rest.step.id,
9240
+ ...approver.value
9241
+ } } : {};
9242
+ const landingTo = (rest, to, branch, approver) => {
9144
9243
  if (isSettledNoop(rest, to)) return {
9145
9244
  kind: "noop",
9146
9245
  settled: true
@@ -9158,6 +9257,7 @@ const landingTo = (rest, to, branch) => {
9158
9257
  from: rest.state,
9159
9258
  to,
9160
9259
  step: rest.step.id,
9260
+ ...approvedTrailer(rest, approver),
9161
9261
  ...starts ? {
9162
9262
  vars: rest.vars,
9163
9263
  config: rest.config
@@ -9173,7 +9273,9 @@ const landingTo = (rest, to, branch) => {
9173
9273
  * replay with the working tree as the pending turn. The target step is
9174
9274
  * computed by replaying, never matched.
9175
9275
  */
9176
- const decideLanding = (rest, verdicts, branch) => gen(function* () {
9276
+ const decideLanding = (rest, verdicts, branch, approver) => gen(function* () {
9277
+ const unapproved = approvalRefusal(rest, approver);
9278
+ if (unapproved !== void 0) return unapproved;
9177
9279
  const early = cleanLanding(rest);
9178
9280
  if (early !== void 0) return early;
9179
9281
  if (rest.stepDef.kind === "prompt" && rest.stepDef.access?.write != null) {
@@ -9189,14 +9291,16 @@ const decideLanding = (rest, verdicts, branch) => gen(function* () {
9189
9291
  }));
9190
9292
  const to = yield* landingTarget(outcome, rest);
9191
9293
  if (typeof to !== "string") return to;
9192
- return landingTo(rest, to, branch);
9294
+ return landingTo(rest, to, branch, approver);
9193
9295
  });
9194
9296
  /** Where landing the pending turn would leave the process, or `undefined` when it would land nothing. */
9195
- const previewLanding = (rest) => decideLanding(rest, void 0, none()).pipe(map$4((landing) => landing.kind === "commit" ? landing.to : void 0), catchAll$1(() => succeed$6(void 0)));
9297
+ const previewLanding = (rest) => decideLanding(rest, void 0, none(), none()).pipe(map$4((landing) => landing.kind === "commit" ? landing.to : void 0), catchAll$1(() => succeed$6(void 0)));
9196
9298
  /** Every fact the pure landing planner needs, read once: the rest, its steering file, and the landing decision. */
9197
9299
  const snapshotFromRest = (rest, verdicts) => gen(function* () {
9198
- const branch = yield* (yield* GitService).currentBranch();
9199
- const landing = yield* decideLanding(rest, verdicts, branch);
9300
+ const git = yield* GitService;
9301
+ const branch = yield* git.currentBranch();
9302
+ const approver = rest.stepDef.approve === true ? yield* git.authorIdent() : none();
9303
+ const landing = yield* decideLanding(rest, verdicts, branch, approver);
9200
9304
  return {
9201
9305
  stepDef: rest.stepDef,
9202
9306
  state: rest.state,
@@ -9250,7 +9354,8 @@ const historySteps = (run) => {
9250
9354
  actor: entry.actor,
9251
9355
  from: message.parsed?.from,
9252
9356
  to: entry.state,
9253
- verdicts: message.judge
9357
+ verdicts: message.judge,
9358
+ ...optional("approval", approvalEntryOf(entry.hash, message)[0])
9254
9359
  };
9255
9360
  });
9256
9361
  };
@@ -9308,8 +9413,26 @@ const commitAllowEmpty = (message) => {
9308
9413
  `fi`
9309
9414
  ].join("\n");
9310
9415
  };
9416
+ const CHANGED_SINCE_LAND = "gtd: the working tree changed since `gtd land` — revert the edits and run `gtd land` again to approve";
9311
9417
  /** `git add -A || exit 1` (an `&&` left side would not abort under `set -e`) then `commitAllowEmpty`. */
9312
9418
  const commitAll = (message) => `git add -A || exit 1\n${commitAllowEmpty(message)}`;
9419
+ /**
9420
+ * An approval records exactly the judged tree: HEAD is pinned before the
9421
+ * clean check and the commit is built over its tree with `commit-tree`, so no
9422
+ * hook runs and none can add to it. A HEAD that moved fails the guarded
9423
+ * `update-ref`. `commit-tree` ignores `commit.gpgSign`, so it is read here.
9424
+ */
9425
+ const commitClean = (message) => [
9426
+ `gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
9427
+ `if [ -n "$(git status --porcelain --untracked-files=all --ignore-submodules=none)" ]; then`,
9428
+ ` printf '%s\\n' ${shellQuote(CHANGED_SINCE_LAND)} >&2`,
9429
+ ` exit 1`,
9430
+ `fi`,
9431
+ `gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
9432
+ `if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
9433
+ `gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m ${shellQuote(message)}) || exit 1`,
9434
+ `git update-ref -m ${shellQuote(`commit: ${message}`)} HEAD "$gtd_tip" "$gtd_head" || exit 1`
9435
+ ].join("\n");
9313
9436
  /** No current caller — see `Git.ts`'s `GitWriterOperations.commitAsIs` for why it's kept anyway. */
9314
9437
  const commitAsIs = (message) => commitAllowEmpty(message);
9315
9438
  /** No current caller — see `Git.ts`'s `GitWriterOperations.softResetTo` for why it's kept anyway. */
@@ -9347,7 +9470,7 @@ const keepTurn = (refPrefix, message) => {
9347
9470
  `gtd_kept=$(git commit-tree "$gtd_tree" -p "$gtd_head" -m ${shellQuote(message)}) || exit 1`,
9348
9471
  `git update-ref ${keptRef} "$gtd_kept" || exit 1`,
9349
9472
  `gtd_marker=$(printf '%s\\nGtd-Kept: %s' ${shellQuote(message)} "$gtd_kept")`,
9350
- `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`,
9473
+ `if ! gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m "$gtd_marker") || ! git update-ref -m ${shellQuote(`commit: ${message}`)} HEAD "$gtd_tip" "$gtd_head"; then`,
9351
9474
  ` git reset -q && git update-ref -d ${keptRef}`,
9352
9475
  ` exit 1`,
9353
9476
  `fi`,
@@ -9385,6 +9508,7 @@ const renderOutcome = (outcome) => {
9385
9508
  const renderGitWrite = (write) => {
9386
9509
  switch (write.kind) {
9387
9510
  case "commitAll": return commitAll(write.message);
9511
+ case "commitClean": return commitClean(write.message);
9388
9512
  case "updateRef": return updateRef(write.ref, write.target);
9389
9513
  case "keepTurn": return keepTurn(write.ref, write.message);
9390
9514
  }
@@ -9858,6 +9982,7 @@ const runHistoryShowCommand = (ref, json, out) => gen(function* () {
9858
9982
  for (const step of trail.steps) {
9859
9983
  lines.push(`${seven(step.hash)} ${step.subject}`);
9860
9984
  for (const v of step.verdicts) lines.push(` judge ${v.id} = ${v.answer} (p=${v.p})`);
9985
+ if (step.approval !== void 0) lines.push(` approved ${step.approval.step} by ${step.approval.name} <${step.approval.email}>`);
9861
9986
  }
9862
9987
  lines.push(`ended (${trail.ended})`);
9863
9988
  out.write(`${lines.join("\n")}\n`);
@@ -10391,7 +10516,8 @@ const gatherBeatDocument = (rest, rendered) => gen(function* () {
10391
10516
  changes: computeStatusChanges(rest.changes),
10392
10517
  next: yield* computeNextMatch(rest),
10393
10518
  cost: rest.cost.total,
10394
- costByModel: rest.cost.byModel
10519
+ costByModel: rest.cost.byModel,
10520
+ approvals: rest.run.approvals
10395
10521
  }));
10396
10522
  });
10397
10523
  /**
@@ -11080,11 +11206,11 @@ const COMMAND_ROWS = [
11080
11206
  arity: { name: "ref" },
11081
11207
  details: [
11082
11208
  "Replay one kept process under today's config and print its",
11083
- "steps and judge verdicts. <ref> is a full ref, an entry hash",
11084
- "(or unique prefix) or a branch name. Refuses (exit 1) when a",
11085
- "config file differs from its content at the ref's tip. A",
11086
- "version mismatch only warns on stderr. --json prints the",
11087
- "trail as one document; writes nothing"
11209
+ "steps, judge verdicts and approvals. <ref> is a full ref, an",
11210
+ "entry hash (or unique prefix) or a branch name. Refuses",
11211
+ "(exit 1) when a config file differs from its content at the",
11212
+ "ref's tip. A version mismatch only warns on stderr. --json",
11213
+ "prints the trail as one document; writes nothing"
11088
11214
  ]
11089
11215
  },
11090
11216
  {
@@ -11697,6 +11823,10 @@ var InMemRepo = class InMemRepo {
11697
11823
  changedPathsWorktree(base) {
11698
11824
  return diffTrees(this.treeAt(base), new Map(this.worktree));
11699
11825
  }
11826
+ /** The index vs HEAD — the port's `stagedPaths`. */
11827
+ stagedPaths() {
11828
+ return diffTrees(this.treeAt(void 0), this.index);
11829
+ }
11700
11830
  diffPaths(base, tip) {
11701
11831
  for (const ref of [base, tip]) if (this.resolveRef(ref) === null) throw new Error(`Invalid ref: ${ref}`);
11702
11832
  return diffTrees(this.treeAt(base), this.treeAt(tip)).map((e) => e.path);
@@ -11756,6 +11886,7 @@ var InMemRepo = class InMemRepo {
11756
11886
  /** Seeded failure modes (see `GitTierSeed`): a pre-commit hook printing this and exiting 1, and a signer that cannot write. */
11757
11887
  hookRejection = void 0;
11758
11888
  signingFails = false;
11889
+ signingEnabled = false;
11759
11890
  /** Seeded: a pre-commit hook writing and staging this file. */
11760
11891
  hookStages = void 0;
11761
11892
  writeCommit(message) {
@@ -11767,7 +11898,8 @@ var InMemRepo = class InMemRepo {
11767
11898
  message,
11768
11899
  files: new Map(tree),
11769
11900
  parent,
11770
- seq: this.nextSeq++
11901
+ seq: this.nextSeq++,
11902
+ signed: this.signingEnabled
11771
11903
  };
11772
11904
  this.commits.set(hash, commit);
11773
11905
  this.head = hash;
@@ -11788,6 +11920,16 @@ var InMemRepo = class InMemRepo {
11788
11920
  }
11789
11921
  this.writeCommit(message);
11790
11922
  }
11923
+ /** Twin of `GitScript.commitClean`: no hook runs, HEAD's tree is recorded; `undefined` on success, else the stderr text. */
11924
+ commitClean(message) {
11925
+ if (this.head === null) return "fatal: Needed a single revision";
11926
+ if (this.statusPorcelain().trim() !== "") return CHANGED_SINCE_LAND;
11927
+ if (this.signingFails) return "error: gpg failed to sign the data";
11928
+ this.writeCommit(message);
11929
+ }
11930
+ headSigned() {
11931
+ return this.headCommit()?.signed === true;
11932
+ }
11791
11933
  /** Setup commit: stages everything, and throws if a failure is seeded, as the Live tier's hooks and signer would. */
11792
11934
  seedCommit(message) {
11793
11935
  if (this.hookRejection !== void 0 || this.hookStages !== void 0 || this.signingFails) throw new Error("seedCommit after a seeded commit failure");
@@ -11805,7 +11947,8 @@ var InMemRepo = class InMemRepo {
11805
11947
  message,
11806
11948
  files: new Map(tree),
11807
11949
  parent,
11808
- seq: this.nextSeq++
11950
+ seq: this.nextSeq++,
11951
+ signed: false
11809
11952
  });
11810
11953
  const ref = `${refPrefix}/${hash}`;
11811
11954
  this.refs.set(ref, hash);
@@ -12000,7 +12143,12 @@ const makeGitReaderOps = (repo, root) => ({
12000
12143
  commitHistory: (base, head) => succeed$6(repo.commitHistory(base, head)),
12001
12144
  subjectHistory: (pageSize, skip, head) => succeed$6(repo.subjectHistory(pageSize, skip, head)),
12002
12145
  changedPaths: (base) => succeed$6(repo.changedPathsWorktree(base)),
12003
- diffPaths: (base, tip) => tryCatch(() => repo.diffPaths(base, tip))
12146
+ stagedPaths: () => succeed$6(repo.stagedPaths()),
12147
+ diffPaths: (base, tip) => tryCatch(() => repo.diffPaths(base, tip)),
12148
+ authorIdent: () => succeed$6(some({
12149
+ name: "Test",
12150
+ email: "test@test.com"
12151
+ }))
12004
12152
  });
12005
12153
  const failOn = (output) => {
12006
12154
  if (output !== void 0) throw new Error(output);
@@ -12557,6 +12705,18 @@ const recognizeGitBuilders = (repo, block) => {
12557
12705
  }
12558
12706
  return;
12559
12707
  }
12708
+ if (block.startsWith("gtd_head=$(git rev-parse --verify HEAD) || exit 1\nif [ -n ")) {
12709
+ const [message] = extractQuotedTokens(block.slice(block.indexOf("\"$gtd_head\" -m ")));
12710
+ if (message !== void 0 && commitClean(message) === block) {
12711
+ const failure = repo.commitClean(message);
12712
+ return failure === void 0 ? { kind: "applied" } : {
12713
+ kind: "failed",
12714
+ error: failure,
12715
+ status: 1
12716
+ };
12717
+ }
12718
+ return;
12719
+ }
12560
12720
  if (block.startsWith("if ! out=$(git commit --allow-empty -m ")) {
12561
12721
  const [message] = extractQuotedTokens(conditionLine);
12562
12722
  if (message !== void 0 && commitAsIs(message) === block) {
@@ -12961,6 +13121,17 @@ const recognizeDeferNotes = (repo, block) => {
12961
13121
  repo.writeFile(".gtd/TODO.md", `${old}${sep}${lines.join("\n")}\n`);
12962
13122
  return { kind: "applied" };
12963
13123
  };
13124
+ /** `mkdir -p <dir>` then `printf '%s\n' <quoted lines> > <path>`: a flow's one-file write. Re-rendered from what was extracted, so any other shape stays unrecognized. */
13125
+ const recognizeWriteLines = (repo, block) => {
13126
+ const [mkdir, printf, ...rest] = block.split("\n");
13127
+ if (rest.length > 0 || !mkdir?.startsWith("mkdir -p ") || !printf?.startsWith("printf '%s\\n' ")) return;
13128
+ const tokens = extractQuotedTokens(printf.slice(14));
13129
+ const path = tokens.pop();
13130
+ if (path === void 0 || !path.includes("/")) return void 0;
13131
+ if (`mkdir -p ${path.slice(0, path.lastIndexOf("/"))}\nprintf '%s\\n' ${tokens.map(shellQuote).join(" ")} > ${shellQuote(path)}` !== block) return void 0;
13132
+ repo.writeFile(path, `${tokens.join("\n")}\n`);
13133
+ return { kind: "applied" };
13134
+ };
12964
13135
  /** Every recognizer `applyEmittedScript` tries, first match wins — `recognizeScriptedCommand` stays last since it's the exact-hit fallback for anything the rest don't claim. */
12965
13136
  const recognizersFor = (repo, commands) => [
12966
13137
  (block) => recognizeSetFlags(block),
@@ -12970,6 +13141,7 @@ const recognizersFor = (repo, commands) => [
12970
13141
  (block) => recognizeBinaryGuard(commands, block),
12971
13142
  (block) => recognizeGitBuilders(repo, block),
12972
13143
  (block) => recognizeDeferNotes(repo, block),
13144
+ (block) => recognizeWriteLines(repo, block),
12973
13145
  (block) => recognizeFailurePromptWrapper(repo, commands, block),
12974
13146
  (block) => recognizePresentationSubshell(repo, commands, block),
12975
13147
  (block) => recognizeModeContradictionSkipNotice(block),
@@ -13309,6 +13481,13 @@ const makeLiveTier = (initialCommit = true) => {
13309
13481
  gitExec("config", "commit.gpgsign", "true");
13310
13482
  gitExec("config", "gpg.format", "openpgp");
13311
13483
  gitExec("config", "gpg.program", "false");
13484
+ },
13485
+ workingSigner: () => {
13486
+ const signer = join(root, ".git/gtd-test-signer.sh");
13487
+ writeFileSync(signer, `#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' '-----BEGIN PGP SIGNATURE-----' '' 'ZHVtbXk=' '-----END PGP SIGNATURE-----'\nprintf '\\n[GNUPG:] SIG_CREATED ' >&2\n`, { mode: 493 });
13488
+ gitExec("config", "commit.gpgsign", "true");
13489
+ gitExec("config", "gpg.format", "openpgp");
13490
+ gitExec("config", "gpg.program", signer);
13312
13491
  }
13313
13492
  },
13314
13493
  observe: {
@@ -13338,7 +13517,8 @@ const makeLiveTier = (initialCommit = true) => {
13338
13517
  });
13339
13518
  },
13340
13519
  headSubject: () => gitExec("log", "-1", "--pretty=%s"),
13341
- headTouchedPaths: () => gitExec("show", "--name-only", "--pretty=format:", "HEAD").split("\n").filter((line) => line.length > 0)
13520
+ headTouchedPaths: () => gitExec("show", "--name-only", "--pretty=format:", "HEAD").split("\n").filter((line) => line.length > 0),
13521
+ headSigned: () => gitExec("cat-file", "commit", "HEAD").includes("\ngpgsig ")
13342
13522
  },
13343
13523
  runScript: (script) => {
13344
13524
  const result = spawnSync("sh", ["-c", `exec 2>&1\n${script}`], {
@@ -13417,6 +13597,9 @@ const makeInMemTier = (initialCommit = true) => {
13417
13597
  },
13418
13598
  failingSigner: () => {
13419
13599
  repo.signingFails = true;
13600
+ },
13601
+ workingSigner: () => {
13602
+ repo.signingEnabled = true;
13420
13603
  }
13421
13604
  },
13422
13605
  observe: {
@@ -13437,7 +13620,8 @@ const makeInMemTier = (initialCommit = true) => {
13437
13620
  headSubject: () => repo.lastCommitSubject() ?? "",
13438
13621
  headTouchedPaths: () => {
13439
13622
  return [.../* @__PURE__ */ new Set([...repo.pathsAtRef("HEAD~1"), ...repo.pathsAtRef("HEAD")])].filter((p) => repo.fileAtRef("HEAD~1", p) !== repo.fileAtRef("HEAD", p)).sort();
13440
- }
13623
+ },
13624
+ headSigned: () => repo.headSigned()
13441
13625
  },
13442
13626
  runScript: (script, commands = /* @__PURE__ */ new Map()) => {
13443
13627
  const { ok, status, output, error } = applyEmittedScript(repo, commands, script);
@@ -13514,7 +13698,9 @@ const runGitServiceContract = (makeTier) => {
13514
13698
  g.readFileAtRef("HEAD", "second.txt"),
13515
13699
  g.changedPaths(),
13516
13700
  g.changedPaths("HEAD~1"),
13517
- g.diffPaths("HEAD~1", "HEAD")
13701
+ g.stagedPaths(),
13702
+ g.diffPaths("HEAD~1", "HEAD"),
13703
+ g.authorIdent()
13518
13704
  ]));
13519
13705
  expect(await t.observe.snapshot()).toBe(before);
13520
13706
  await runGit(t, (g) => g.updateRef("refs/gtd/marker", t.observe.resolveRef("HEAD")));
@@ -14002,6 +14188,13 @@ const runGitServiceContract = (makeTier) => {
14002
14188
  it("returns [] on a clean tree", async () => {
14003
14189
  expect(await runGit(t, (g) => g.changedPaths())).toEqual([]);
14004
14190
  });
14191
+ it("ignores an edit staged and then reverted in the worktree", async () => {
14192
+ t.seed.commit("chore: seed", { "a.txt": "1" });
14193
+ t.seed.writeFile("a.txt", "2");
14194
+ t.seed.stageAll();
14195
+ t.seed.writeFile("a.txt", "1");
14196
+ expect(await runGit(t, (g) => g.changedPaths())).toEqual([]);
14197
+ });
14005
14198
  for (const { label, path } of PATHOLOGICAL_PATHS) it(`reports an untracked path containing ${label}, verbatim`, async () => {
14006
14199
  t.seed.writeFile(path, "content");
14007
14200
  expect(await runGit(t, (g) => g.changedPaths())).toEqual([{
@@ -14086,6 +14279,45 @@ const runGitServiceContract = (makeTier) => {
14086
14279
  });
14087
14280
  });
14088
14281
  });
14282
+ describe("stagedPaths", () => {
14283
+ it("returns [] when the index matches HEAD, whatever the worktree holds", async () => {
14284
+ t.seed.commit("chore: seed", { "a.txt": "1" });
14285
+ t.seed.writeFile("a.txt", "2");
14286
+ t.seed.writeFile("u.txt", "u");
14287
+ expect(await runGit(t, (g) => g.stagedPaths())).toEqual([]);
14288
+ });
14289
+ it("reports an edit staged and then reverted in the worktree", async () => {
14290
+ t.seed.commit("chore: seed", { "a.txt": "1" });
14291
+ t.seed.writeFile("a.txt", "2");
14292
+ t.seed.stageAll();
14293
+ t.seed.writeFile("a.txt", "1");
14294
+ expect(await runGit(t, (g) => g.stagedPaths())).toEqual([{
14295
+ path: "a.txt",
14296
+ status: "M"
14297
+ }]);
14298
+ });
14299
+ it("reports staged additions and deletions", async () => {
14300
+ t.seed.commit("chore: seed", { "gone.txt": "g" });
14301
+ t.seed.deleteFile("gone.txt");
14302
+ t.seed.writeFile("new.txt", "n");
14303
+ t.seed.stageAll();
14304
+ expect([...await runGit(t, (g) => g.stagedPaths())].sort((a, b) => a.path.localeCompare(b.path))).toEqual([{
14305
+ path: "gone.txt",
14306
+ status: "D"
14307
+ }, {
14308
+ path: "new.txt",
14309
+ status: "A"
14310
+ }]);
14311
+ });
14312
+ });
14313
+ describe("authorIdent", () => {
14314
+ it("is the configured name and email", async () => {
14315
+ expect(await runGit(t, (g) => g.authorIdent())).toEqual(some({
14316
+ name: "Test",
14317
+ email: "test@test.com"
14318
+ }));
14319
+ });
14320
+ });
14089
14321
  describe("topLevel", () => {
14090
14322
  it("resolves to this tier's own root", async () => {
14091
14323
  expect(await runGit(t, (g) => g.topLevel())).toBe(t.root);
@@ -14180,6 +14412,74 @@ const runGitScriptContract = (makeTier) => {
14180
14412
  expect(t.observe.headSubject()).toBe("gtd: grilled");
14181
14413
  });
14182
14414
  });
14415
+ describe("commitClean", () => {
14416
+ it("on a clean tree commits an empty commit with the message", () => {
14417
+ const headBefore = t.observe.resolveRef("HEAD");
14418
+ run(t, commitClean("gtd(human): gate → again"));
14419
+ expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore);
14420
+ expect(t.observe.headSubject()).toBe("gtd(human): gate → again");
14421
+ expect(t.observe.headTouchedPaths()).toEqual([]);
14422
+ });
14423
+ it("a staging hook never runs: nothing touched, nothing staged, file absent", () => {
14424
+ const headBefore = t.observe.resolveRef("HEAD");
14425
+ t.seed.stagingHook("hooked.txt", "hooked");
14426
+ run(t, commitClean("gtd(human): gate → again"));
14427
+ expect(t.observe.headTouchedPaths()).toEqual([]);
14428
+ expect(t.observe.resolveRef("HEAD^")).toBe(headBefore);
14429
+ expect(t.observe.statusPorcelain().trim()).toBe("");
14430
+ expect(t.observe.existsPath("hooked.txt")).toBe(false);
14431
+ });
14432
+ it("a rejecting hook is never consulted", () => {
14433
+ t.seed.rejectingHook("rejected");
14434
+ run(t, commitClean("gtd(human): gate → again"));
14435
+ expect(t.observe.headSubject()).toBe("gtd(human): gate → again");
14436
+ });
14437
+ it("a working signer signs the approval", () => {
14438
+ t.seed.workingSigner();
14439
+ run(t, commitClean("gtd(human): gate → again"));
14440
+ expect(t.observe.headSigned()).toBe(true);
14441
+ });
14442
+ it("no signing config: the approval does not try to sign", () => {
14443
+ run(t, commitClean("gtd(human): gate → again"));
14444
+ expect(t.observe.headSigned()).toBe(false);
14445
+ });
14446
+ it("a failing signer fails the approval: HEAD and status unchanged", () => {
14447
+ t.seed.failingSigner();
14448
+ const headBefore = t.observe.resolveRef("HEAD");
14449
+ const result = t.runScript(commitClean("gtd(human): gate → again"));
14450
+ expect(result.ok).toBe(false);
14451
+ expect(result.status).toBe(1);
14452
+ expect(result.output).toContain("failed to sign");
14453
+ expect(t.observe.resolveRef("HEAD")).toBe(headBefore);
14454
+ expect(t.observe.statusPorcelain().trim()).toBe("");
14455
+ });
14456
+ const refused = (name, dirty, pending) => {
14457
+ it(`${name}: fails, HEAD unchanged, edit still pending`, () => {
14458
+ dirty();
14459
+ const headBefore = t.observe.resolveRef("HEAD");
14460
+ const result = t.runScript(commitClean("gtd(human): gate → again"));
14461
+ expect(result.ok).toBe(false);
14462
+ expect(result.output).toContain("changed since `gtd land`");
14463
+ expect(t.observe.resolveRef("HEAD")).toBe(headBefore);
14464
+ expect(t.observe.statusPorcelain()).toContain(pending);
14465
+ });
14466
+ };
14467
+ refused("a modified tracked file", () => {
14468
+ t.seed.commit("feat: base", { "a.txt": "1" });
14469
+ t.seed.writeFile("a.txt", "2");
14470
+ }, "a.txt");
14471
+ refused("a staged change", () => {
14472
+ t.seed.writeFile("s.txt", "s");
14473
+ t.seed.stageAll();
14474
+ }, "s.txt");
14475
+ refused("an untracked file", () => t.seed.writeFile("u.txt", "u"), "u.txt");
14476
+ refused("an edit staged then reverted in the worktree", () => {
14477
+ t.seed.commit("feat: base", { "a.txt": "1" });
14478
+ t.seed.writeFile("a.txt", "2");
14479
+ t.seed.stageAll();
14480
+ t.seed.writeFile("a.txt", "1");
14481
+ }, "a.txt");
14482
+ });
14183
14483
  describe("commitAsIs", () => {
14184
14484
  it("does NOT stage — a worktree write after staging is excluded (the squash mechanic)", () => {
14185
14485
  t.seed.writeFile("a.txt", "a");