@pmelab/gtd 27.3.0 → 27.5.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 (79) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +22 -1
  3. package/dist/chunks/{Schema-CyImoHTj.mjs → Schema-DO5ttJaI.mjs} +1 -1
  4. package/dist/chunks/{reserved-BPdVZkgv.mjs → reserved-DM5W_LAt.mjs} +64 -3
  5. package/dist/chunks/{tail-iLAMoigl.mjs → tail-6gvLXXcg.mjs} +1 -1
  6. package/dist/chunks/{wire-BY1S-obW.mjs → wire-C12dahrH.mjs} +6 -4
  7. package/dist/flows.mjs +27 -2
  8. package/dist/gtd.bundle.mjs +218 -32
  9. package/dist/testkit.mjs +358 -37
  10. package/dist/wire.mjs +1 -1
  11. package/dist/workflows/cleanup.mjs +2 -2
  12. package/dist/workflows/feature.mjs +140 -64
  13. package/dist/workflows/fix.mjs +2 -2
  14. package/dist/workflows/review.mjs +2 -2
  15. package/dist/workflows/rollout.mjs +1 -1
  16. package/dist/workflows.mjs +346 -107
  17. package/package.json +1 -1
  18. package/src/cli/Cli.test.ts +49 -1
  19. package/src/cli/Cli.ts +25 -8
  20. package/src/cli/History.ts +5 -0
  21. package/src/cli/Program.ts +1 -0
  22. package/src/edge/Edge.test.ts +205 -1
  23. package/src/edge/Edge.ts +105 -12
  24. package/src/emit/GitScript.test.ts +39 -3
  25. package/src/emit/GitScript.ts +25 -1
  26. package/src/emit/index.ts +2 -0
  27. package/src/flags/index.test.ts +14 -1
  28. package/src/flags/index.ts +14 -3
  29. package/src/flows/helpers.ts +34 -2
  30. package/src/flows/runtime.ts +7 -0
  31. package/src/install/Install.ts +2 -1
  32. package/src/platform/Git.test.ts +25 -1
  33. package/src/platform/Git.ts +30 -0
  34. package/src/platform/index.ts +1 -0
  35. package/src/replay/Format.test.ts +16 -1
  36. package/src/replay/Replay.test.ts +91 -0
  37. package/src/replay/Replay.ts +22 -2
  38. package/src/replay/Trailers.test.ts +110 -1
  39. package/src/replay/Trailers.ts +37 -1
  40. package/src/replay/index.ts +2 -0
  41. package/src/replay/judgeDiffShape.test.ts +43 -0
  42. package/src/replay/seamGate.test.ts +62 -0
  43. package/src/step/LandStep.ts +1 -0
  44. package/src/step/planStart.ts +3 -2
  45. package/src/step/planStep.test.ts +32 -0
  46. package/src/step/planStep.ts +3 -1
  47. package/src/testkit/EmittedScriptRecognizer.test.ts +27 -0
  48. package/src/testkit/EmittedScriptRecognizer.ts +12 -0
  49. package/src/testkit/FakeGitOperations.ts +5 -0
  50. package/src/testkit/GitTiers.ts +158 -0
  51. package/src/testkit/InMemRepo.ts +38 -3
  52. package/src/testkit/TestRepo.ts +3 -0
  53. package/src/testkit/steps.ts +18 -0
  54. package/src/wire/BeatDocument.test.ts +25 -0
  55. package/src/wire/BeatDocument.ts +4 -2
  56. package/src/wire/BeatPlain.test.ts +1 -0
  57. package/src/wire/BeatStatus.test.ts +2 -0
  58. package/src/wire/BeatStatus.ts +13 -2
  59. package/src/wire/WireSchema.test.ts +10 -0
  60. package/src/wire/types.ts +8 -0
  61. package/src/workflow/Workflow.ts +1 -0
  62. package/src/workflow/index.ts +8 -1
  63. package/src/workflow/vars.test.ts +23 -1
  64. package/src/workflow/vars.ts +24 -0
  65. package/src/workflows/feature/feature.ts +9 -2
  66. package/src/workflows/feature/index.ts +1 -0
  67. package/src/workflows/feature/seam-pin.feature +52 -0
  68. package/src/workflows/feature/seam.feature +387 -0
  69. package/src/workflows/feature/seam.test.ts +88 -0
  70. package/src/workflows/feature/seam.ts +110 -0
  71. package/src/workflows/index.ts +1 -0
  72. package/src/workflows/shared/options.test.ts +12 -1
  73. package/src/workflows/shared/options.ts +1 -0
  74. package/src/workflows/shared/skills.test.ts +20 -2
  75. package/src/workflows/shared/skills.ts +6 -0
  76. package/src/workflows/shared/text.test.ts +59 -0
  77. package/src/workflows/shared/text.ts +86 -0
  78. package/src/workflows/shared/vars.ts +4 -0
  79. package/src/workflows/shared/workflows.steps.ts +39 -1
package/dist/testkit.mjs CHANGED
@@ -1,7 +1,7 @@
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
- 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";
4
+ import { $ as logWarning, $i as NodeInspectSymbol, $n as map$5, $r as make$19, $t as provide$1, A as catchIf, Ai as add, An as scopeMake, Ar as flatMap$3, At as suspend$5, B as flatMap$4, Bi as getOrElse, 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 fromIterable, Fn as join$3, Fr as map$3, Ft as tryMap, G as forkIn, Gi as none, Gn as TypeId$10, Gr as whenEffect, Gt as zipLeft, H as fnUntraced, Hi as isSome, Hn as modify$1, Hr as uninterruptible, Ht as when, I as exit, Ii as splitAt, In as sequential, Ir as onInterrupt, It as tryPromise, J as ignore, Ji as left, Jn as all, Jr as DeferredTypeId$1, Jt as spaced$1, K as forkScoped, Ki as some, Kn as Class, Kr as withFiberRuntime, Kt as zipRight$3, L as fail$5, Li as filter$1, Ln as bind, Lr as scopeClose, Lt as try_, M as die$2, Mi as getOption, Mn as _await$2, Mr as interruptAsFiber, Mt as tap, N as dieMessage, Ni as omit, Nn as inheritAll$1, Nr as interruptFiber, Nt as tapErrorCause, O as catchAll$1, Oi as Reference, On as ensuring$3, Or as fiberId, Ot as serviceOption, P as either, Pi as empty$5, Pn as interruptAllAs$1, Pr as interruptible$1, Pt as timeout, Q as isEffect, Qi as BaseProto, Qn as isFailure, Qr as ids, Qt as mergeAll$2, R as failCause$4, Ri as flatMap$5, 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 map$8, Un as set$1, Ur as uninterruptibleMask$1, Ut as withFiberRuntime$1, V as flatten$4, Vi as isNone, Vn as make$16, Vr as tap$1, Vt as unsafeMakeSemaphore$1, W as forkDaemon, Wi as match$4, Wn as update$1, Wr as void_$2, Wt as zip$1, X as interruptible, Xi as right, Xn as fail$4, Xr as combine, Xt as tagged, Y as interrupt$4, Yi as match$2, Yn as die, Yr as empty$6, Yt as TaggedError$1, Z as intoDeferred, Zi as pipeArguments, 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 constTrue, 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 dual, 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 format, 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 constFalse, ii as has, in as unsafeRunPromiseExit, ir as zip$2, it as match$5, j as catchTag, ji as get$5, jn as scopeTag, jr as interrupt$2, jt as sync$2, k as catchAllCause, ki as Tag, kn as forEachConcurrentDiscard, kr as fiberRefLocally, kt as succeed$6, l as Struct, la as identity, 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 equals, ni as make$17, nn as succeed$3, nr as succeed$5, nt as mapBoth$1, o as Never, oa as constUndefined, 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 isLeft, qn as spanTag, qr as zipRight$1, qt as zipWith, r as Cause, ra as hasProperty, ri as set$2, rn as unsafeFork, rr as void_$3, rt as mapError, s as Number$, sa as constVoid, si as isFinite, sn as unsafeMakeSemaphore, sr as asVoid, st as matchEffect, t as Array$, ta as toJSON, ti as get$4, tn as scoped$3, tr as match$3, tt as map$4, u as TaggedError, ua as pipe, 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 fromNullable, zn as sleep, zr as suspend$4, zt as uninterruptibleMask } from "./chunks/Schema-DO5ttJaI.mjs";
5
5
  import { createRequire } from "node:module";
6
6
  import * as Crypto from "node:crypto";
7
7
  import { createHash } from "node:crypto";
@@ -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))),
@@ -6089,13 +6097,23 @@ const restorability = (git, headHash, tipHash) => gen(function* () {
6089
6097
  });
6090
6098
  //#endregion
6091
6099
  //#region src/flags/index.ts
6092
- const FLAGS$1 = [];
6100
+ const FLAGS$1 = [{
6101
+ name: "naive_pass",
6102
+ owner: "pmelab",
6103
+ expiry: "2027-01-15",
6104
+ kind: "release"
6105
+ }];
6093
6106
  const BOOL = {
6094
6107
  "0": false,
6095
6108
  false: false,
6096
6109
  "1": true,
6097
6110
  true: true
6098
6111
  };
6112
+ /** A `flag_<name>` setting's value as the pinned `"1"`/`"0"`, or undefined when garbled. */
6113
+ const flagSettingValue = (raw) => {
6114
+ const key = raw.toLowerCase();
6115
+ return Object.hasOwn(BOOL, key) ? BOOL[key] ? "1" : "0" : void 0;
6116
+ };
6099
6117
  const resolveFlags = (env, registry = FLAGS$1) => {
6100
6118
  const out = {};
6101
6119
  for (const { name, defaultOn } of registry) {
@@ -6110,6 +6128,7 @@ const resolveFlags = (env, registry = FLAGS$1) => {
6110
6128
  }
6111
6129
  return out;
6112
6130
  };
6131
+ const flagSettings = (flags) => Object.fromEntries(Object.entries(flags).map(([name, on]) => [`flag_${name}`, on ? "1" : "0"]));
6113
6132
  //#endregion
6114
6133
  //#region src/install/Version.ts
6115
6134
  const _require = createRequire(import.meta.url);
@@ -6358,7 +6377,8 @@ skills, \`skills\` (an array of the skill names, already trimmed and split —
6358
6377
  (\`{read, write}\`, each an array of globs or \`null\` for unrestricted). When set: \`model\`, \`memory\`,
6359
6378
  \`label\`, \`file\`, \`mode\`, \`edges\`, \`judge\` (the pending judgment's rendered
6360
6379
  document). When a cost has been recorded (a prior \`gtd land --cost=<n>\`):
6361
- \`cost\`, \`costByModel\`.
6380
+ \`cost\`, \`costByModel\`. Always \`approvals\` (an array of
6381
+ \`{step, name, email, commit}\`, \`[]\` when none).
6362
6382
 
6363
6383
  \`gtd land\` fields, in fixed order: \`schema\`, \`script\` (the POSIX sh to run), \`settled\`
6364
6384
  (true for a no-op at a \`script\` rest — stop immediately, nothing more to
@@ -6879,12 +6899,38 @@ const TRAILER_RE = /^(Gtd-[A-Za-z-]+):[ \t]*(.*?)[ \t]*$/gm;
6879
6899
  const COST_RE = /^([0-9]+(?:\.[0-9]+)?)(?:[ \t]+(.+))?$/;
6880
6900
  const VAR_RE = /^([^=\s]+)=(.*)$/;
6881
6901
  const CONFIG_RE = /^([0-9a-f]{16}) (.+)$/;
6882
- const CONTROL_CHARACTER$1 = /[\x00-\x1f\x7f-\x9f]/;
6902
+ const CONTROL_CHARACTER$1 = /[\x00-\x1f\x7f-\x9f\u2028\u2029]/;
6883
6903
  const encodePath = (path) => path.trim() !== path || path.startsWith("\"") || CONTROL_CHARACTER$1.test(path) ? JSON.stringify(path) : path;
6884
6904
  const decodePath = (raw) => {
6885
6905
  const path = raw.startsWith("\"") ? parseJson(raw) : raw;
6886
6906
  return typeof path === "string" && path !== "" && !CONTROL_CHARACTER$1.test(path) ? path : void 0;
6887
6907
  };
6908
+ const decodeApproval = (raw) => {
6909
+ const parsed = parseJson(raw);
6910
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
6911
+ const { step, name, email } = parsed;
6912
+ if (typeof step !== "string" || typeof name !== "string" || typeof email !== "string") return void 0;
6913
+ if ([
6914
+ step,
6915
+ name,
6916
+ email
6917
+ ].some((field) => CONTROL_CHARACTER$1.test(field))) return void 0;
6918
+ const id = parseStepId(step);
6919
+ return id === void 0 ? void 0 : {
6920
+ step: id,
6921
+ name,
6922
+ email
6923
+ };
6924
+ };
6925
+ const encodeApproval = ({ step, name, email }) => JSON.stringify({
6926
+ step: formatStepId(step),
6927
+ name,
6928
+ email
6929
+ });
6930
+ const isRecordableApproval = (approval) => {
6931
+ const back = decodeApproval(encodeApproval(approval));
6932
+ return back !== void 0 && encodeApproval(back) === encodeApproval(approval);
6933
+ };
6888
6934
  const FORMAT_RE = /^[1-9][0-9]*$/;
6889
6935
  const HASH_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
6890
6936
  const TRAILER_READERS = {
@@ -6919,6 +6965,9 @@ const TRAILER_READERS = {
6919
6965
  "Gtd-Kept": (value, into) => {
6920
6966
  if (HASH_RE.test(value)) into.kept ??= value;
6921
6967
  },
6968
+ "Gtd-Approved": (value, into) => {
6969
+ into.approved ??= decodeApproval(value);
6970
+ },
6922
6971
  "Gtd-Format": (value, into) => {
6923
6972
  if (FORMAT_RE.test(value)) into.format ??= Number(value);
6924
6973
  },
@@ -6940,6 +6989,7 @@ const parseCommitMessage = (message) => {
6940
6989
  branch: void 0,
6941
6990
  version: void 0,
6942
6991
  kept: void 0,
6992
+ approved: void 0,
6943
6993
  judge: [],
6944
6994
  vars: {},
6945
6995
  config: {},
@@ -6965,6 +7015,7 @@ const formatCommitMessage = (spec) => {
6965
7015
  for (const [path, hash] of sortedEntries(spec.config)) lines.push(`Gtd-Config: ${hash} ${encodePath(path)}`);
6966
7016
  if (spec.cost !== void 0) lines.push(`Gtd-Cost: ${spec.cost.cost}${spec.cost.model !== void 0 ? ` ${spec.cost.model}` : ""}`);
6967
7017
  for (const verdict of spec.judge ?? []) lines.push(`Gtd-Judge: ${JSON.stringify(verdict)}`);
7018
+ if (spec.approved !== void 0) lines.push(`Gtd-Approved: ${encodeApproval(spec.approved)}`);
6968
7019
  if (spec.branch !== void 0) lines.push(`Gtd-Branch: ${spec.branch}`);
6969
7020
  if (spec.version !== void 0) lines.push(`Gtd-Version: ${spec.version}`);
6970
7021
  lines.push(`Gtd-Format: 1`);
@@ -7060,7 +7111,8 @@ const KNOWN_OPTIONS = {
7060
7111
  human: /* @__PURE__ */ new Set([
7061
7112
  ...STEERING_OPTIONS,
7062
7113
  "message",
7063
- "acceptClean"
7114
+ "acceptClean",
7115
+ "approve"
7064
7116
  ]),
7065
7117
  run: new Set(STEERING_OPTIONS),
7066
7118
  judge: /* @__PURE__ */ new Set([...STEERING_OPTIONS, "message"])
@@ -7070,6 +7122,13 @@ const unknownOptions = (step) => {
7070
7122
  if (unknown.length === 0) return void 0;
7071
7123
  return `gtd: step "${step.name}": unknown key(s) ${unknown.join(", ")} in ${step.kind}() options`;
7072
7124
  };
7125
+ const approveConflict = (step) => {
7126
+ const request = step.request;
7127
+ if (request.kind !== "human" || request.options.approve !== true) return void 0;
7128
+ const keys = ["mode", "file"].filter((key) => request.options[key] !== void 0);
7129
+ if (keys.length === 0) return void 0;
7130
+ 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`;
7131
+ };
7073
7132
  const replay = async (input) => {
7074
7133
  const commits = input.episode.commits.map((c) => ({
7075
7134
  ...c,
@@ -7229,12 +7288,21 @@ const replay = async (input) => {
7229
7288
  kind: "divergence",
7230
7289
  message: `gtd: commit ${short(expected.hash)} names "${expected.name}" as the next step, but replay reached "${step.name}" — ${DIVERGED}`
7231
7290
  };
7232
- const error = unknownOptions(step) ?? checkIdentity(step);
7291
+ const error = unknownOptions(step) ?? approveConflict(step) ?? checkIdentity(step);
7233
7292
  return error === void 0 ? void 0 : {
7234
7293
  kind: "failed",
7235
7294
  message: error
7236
7295
  };
7237
7296
  };
7297
+ const missingApproval = (step, commit, expected) => {
7298
+ if (step.request.kind !== "human" || step.request.options.approve !== true) return void 0;
7299
+ const approved = commit.parsed.approved;
7300
+ if (approved !== void 0 && formatStepId(approved.step) === expected) return void 0;
7301
+ return {
7302
+ kind: "divergence",
7303
+ message: `gtd: commit ${short(commit.hash)} completes approval gate "${expected}" but records no Gtd-Approved for it — ${DIVERGED}`
7304
+ };
7305
+ };
7238
7306
  const consumeCommit = (step, commit) => {
7239
7307
  const recorded = commit.parsed.step;
7240
7308
  const expected = formatStepId(step.id);
@@ -7245,6 +7313,8 @@ const replay = async (input) => {
7245
7313
  message: `gtd: commit ${short(commit.hash)} ${what}, but replay expected "${expected}" — ${DIVERGED}`
7246
7314
  });
7247
7315
  }
7316
+ const unapproved = missingApproval(step, commit, expected);
7317
+ if (unapproved !== void 0) return finish(unapproved);
7248
7318
  cursor++;
7249
7319
  advance(commit);
7250
7320
  if (commit.parsed.kept !== void 0) if (commit.kept === void 0) keptTips.delete(step.name);
@@ -7474,6 +7544,8 @@ const compileConfig = (value, origin) => {
7474
7544
  //#endregion
7475
7545
  //#region src/workflow/vars.ts
7476
7546
  const PREFIX = "GTD_";
7547
+ /** The live release flags as `flag_<name>` settings, provided by the CLI; absent means no flags. */
7548
+ var StartFlags = class extends Reference()("StartFlags", { defaultValue: () => ({}) }) {};
7477
7549
  /**
7478
7550
  * One kind of setting, later wins: the workflow's own defaults, `--var`
7479
7551
  * overrides (process settings only; environment settings pass `{}`), then
@@ -7493,6 +7565,18 @@ const resolveVars = (workflowVars, entryVars, env) => {
7493
7565
  }
7494
7566
  return merged;
7495
7567
  };
7568
+ /**
7569
+ * `resolveVars` for a process being STARTED: `flags` land between the workflow
7570
+ * defaults and `--var`, for already-declared names only. A running process never
7571
+ * calls this — it replays its pinned record.
7572
+ */
7573
+ const resolveStartVars = (workflowVars, flags, entryVars, env) => {
7574
+ const declared = Object.fromEntries(Object.entries(flags).filter(([name]) => Object.hasOwn(workflowVars, name)));
7575
+ return resolveVars({
7576
+ ...workflowVars,
7577
+ ...declared
7578
+ }, entryVars, env);
7579
+ };
7496
7580
  /** The first process setting whose value spans lines — a `Gtd-Var:` trailer cannot carry it. */
7497
7581
  const multilineSetting = (vars) => Object.keys(vars).sort().find((name) => /[\r\n]/.test(vars[name]));
7498
7582
  const SETTING_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -8355,6 +8439,9 @@ const planStep = (snapshot, opts = {}) => {
8355
8439
  kind: "keepTurn",
8356
8440
  ref: landing.keepTurn,
8357
8441
  message
8442
+ } : landing.spec.approved !== void 0 ? {
8443
+ kind: "commitClean",
8444
+ message
8358
8445
  } : {
8359
8446
  kind: "commitAll",
8360
8447
  message
@@ -8418,7 +8505,7 @@ const planStart = (current, actor, start) => gen(function* () {
8418
8505
  const overrideRefusal = yield* checkOverrides(commandLabel, varOverrides, Object.keys(workflow.vars), Object.keys(workflow.env));
8419
8506
  if (overrideRefusal !== void 0) return overrideRefusal;
8420
8507
  const baseOf = workflow.def.base;
8421
- const vars = resolveVars(workflow.vars, varOverrides, (yield* Host).env);
8508
+ const vars = resolveStartVars(workflow.vars, yield* StartFlags, varOverrides, (yield* Host).env);
8422
8509
  const multiline = multilineSetting(vars);
8423
8510
  if (multiline !== void 0) return refusal(`${commandLabel}: process setting "${multiline}" spans several lines — a process setting is pinned in a commit trailer and must be a single line`);
8424
8511
  const template = yield* try_({
@@ -8556,6 +8643,12 @@ const pinnedConfigOf = (history) => {
8556
8643
  };
8557
8644
  }
8558
8645
  };
8646
+ const approvalEntryOf = (hash, message) => message.approved === void 0 ? [] : [{
8647
+ step: formatStepId(message.approved.step),
8648
+ name: message.approved.name,
8649
+ email: message.approved.email,
8650
+ commit: hash
8651
+ }];
8559
8652
  const buildRun = (history, location, closingHash) => {
8560
8653
  const processCommits = history.slice(location.processStart);
8561
8654
  const parsed = processCommits.map((c) => parseCommitMessage(c.message));
@@ -8571,6 +8664,7 @@ const buildRun = (history, location, closingHash) => {
8571
8664
  trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i])),
8572
8665
  costEntries: parsed.flatMap(costEntriesOf),
8573
8666
  judgeVerdicts: parsed.flatMap((m) => m.judge),
8667
+ approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i])),
8574
8668
  pinnedVars: { ...first?.vars },
8575
8669
  pinnedConfig: pinnedConfigOf(history.slice(Math.max(location.baseIndex, 0))),
8576
8670
  headTurn: headTurnOf(head),
@@ -8759,9 +8853,9 @@ const pendingTree = (workspace, entries, rewrite) => {
8759
8853
  * are always live. `startOverrides` are `--var` values, passed only while a
8760
8854
  * process is being started.
8761
8855
  */
8762
- const settingsFor = (workflow, pinnedVars, hostEnv, startOverrides = {}) => ({
8856
+ const settingsFor = (workflow, pinnedVars, hostEnv, startOverrides = {}, startFlags) => ({
8763
8857
  vars: {
8764
- ...resolveVars(workflow.vars, startOverrides, hostEnv),
8858
+ ...resolveStartVars(workflow.vars, startFlags, startOverrides, hostEnv),
8765
8859
  ...pinnedVars
8766
8860
  },
8767
8861
  env: resolveVars(workflow.env, {}, hostEnv)
@@ -8856,7 +8950,8 @@ const messageDef = (common, request, step) => ({
8856
8950
  ...common,
8857
8951
  kind: "message",
8858
8952
  content: request.options.message ?? request.options.label ?? step.name,
8859
- ...optional("acceptClean", request.options.acceptClean)
8953
+ ...optional("acceptClean", request.options.approve === true || request.options.acceptClean),
8954
+ ...optional("approve", request.options.approve)
8860
8955
  });
8861
8956
  const stepDefOf = (step) => {
8862
8957
  const request = step.request;
@@ -8934,7 +9029,8 @@ const costByModel = (entries) => {
8934
9029
  const setupFor = (config, run, workspace, host, startOverrides = {}) => gen(function* () {
8935
9030
  const resolved = yield* workflowOf(config, run);
8936
9031
  const def = resolved.def;
8937
- const { vars, env } = settingsFor(resolved, run.pinnedVars, host.env, startOverrides);
9032
+ const startFlags = run.trace.length === 0 ? yield* StartFlags : {};
9033
+ const { vars, env } = settingsFor(resolved, run.pinnedVars, host.env, startOverrides, startFlags);
8938
9034
  const skills = yield* resolveScopeSkills(def, vars);
8939
9035
  const access = yield* resolveScopeAccess(def, skills, vars);
8940
9036
  const budget = yield* try_({
@@ -8985,6 +9081,12 @@ const setupFor = (config, run, workspace, host, startOverrides = {}) => gen(func
8985
9081
  }
8986
9082
  };
8987
9083
  });
9084
+ const pendingChanges = (git, withIndex) => gen(function* () {
9085
+ const worktree = yield* git.changedPaths();
9086
+ if (!withIndex) return worktree;
9087
+ const seen = new Set(worktree.map((e) => e.path));
9088
+ return [...worktree, ...(yield* git.stagedPaths()).filter((e) => !seen.has(e.path))];
9089
+ });
8988
9090
  /** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
8989
9091
  const restAt = (ref, startOverrides = {}) => gen(function* () {
8990
9092
  const git = yield* GitService;
@@ -9000,7 +9102,7 @@ const restAt = (ref, startOverrides = {}) => gen(function* () {
9000
9102
  yield* (yield* Narrator).narrate(`rest resolved: ${step.name} (awaits ${step.actor})`);
9001
9103
  const stepDef = stepDefOf(step);
9002
9104
  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) => ({
9105
+ const changes = ref === void 0 ? (yield* pendingChanges(git, stepDef.approve === true)).map((e) => ({
9004
9106
  status: normalizeStatus(e.status),
9005
9107
  path: e.path
9006
9108
  })) : [];
@@ -9038,7 +9140,7 @@ const currentRest = restAt(void 0);
9038
9140
  */
9039
9141
  const startRefusal = (rest, resolved, startVars) => gen(function* () {
9040
9142
  const host = yield* Host;
9041
- const { vars, env } = settingsFor(resolved, {}, host.env, startVars);
9143
+ const { vars, env } = settingsFor(resolved, {}, host.env, startVars, yield* StartFlags);
9042
9144
  const skills = yield* resolveScopeSkills(resolved.def, vars);
9043
9145
  const access = yield* resolveScopeAccess(resolved.def, skills, vars);
9044
9146
  const workspace = rest.setup.workspace;
@@ -9086,6 +9188,25 @@ const noProcessUnderway = (rest) => rest.run.workflow === void 0 && rest.state =
9086
9188
  /** `idle` means exactly one thing: no process underway, clean tree. */
9087
9189
  const restIsIdle = (rest) => noProcessUnderway(rest) && rest.changes.length === 0;
9088
9190
  const isHumanReviewGate = (def) => def.actor === "human" && def.mode === "review";
9191
+ const approvalRefusal = (rest, approver) => {
9192
+ if (rest.stepDef.approve !== true) return void 0;
9193
+ const id = rest.step.id;
9194
+ if (rest.changes.length > 0) return {
9195
+ kind: "refusal",
9196
+ 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\``
9197
+ };
9198
+ if (isNone(approver)) return {
9199
+ kind: "refusal",
9200
+ message: `gtd land: approval-gate: ${formatStepId(id)} records who approved it, but git has no author identity — set user.name and user.email`
9201
+ };
9202
+ if (!isRecordableApproval({
9203
+ step: id,
9204
+ ...approver.value
9205
+ })) return {
9206
+ kind: "refusal",
9207
+ 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)`
9208
+ };
9209
+ };
9089
9210
  /** The landing a clean tree decides before any replay, if it decides one. */
9090
9211
  const cleanLanding = (rest) => {
9091
9212
  if (rest.changes.length > 0) return void 0;
@@ -9140,7 +9261,11 @@ const closingTrailers = (branch) => ({
9140
9261
  });
9141
9262
  const isSettledNoop = (rest, to) => rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state;
9142
9263
  const keptTurn = (rest) => rest.stepDef.keep === true && rest.changes.length > 0 ? { keepTurn: keptRef(rest.run.startHash, rest.state) } : {};
9143
- const landingTo = (rest, to, branch) => {
9264
+ const approvedTrailer = (rest, approver) => isSome(approver) ? { approved: {
9265
+ step: rest.step.id,
9266
+ ...approver.value
9267
+ } } : {};
9268
+ const landingTo = (rest, to, branch, approver) => {
9144
9269
  if (isSettledNoop(rest, to)) return {
9145
9270
  kind: "noop",
9146
9271
  settled: true
@@ -9158,6 +9283,7 @@ const landingTo = (rest, to, branch) => {
9158
9283
  from: rest.state,
9159
9284
  to,
9160
9285
  step: rest.step.id,
9286
+ ...approvedTrailer(rest, approver),
9161
9287
  ...starts ? {
9162
9288
  vars: rest.vars,
9163
9289
  config: rest.config
@@ -9173,7 +9299,9 @@ const landingTo = (rest, to, branch) => {
9173
9299
  * replay with the working tree as the pending turn. The target step is
9174
9300
  * computed by replaying, never matched.
9175
9301
  */
9176
- const decideLanding = (rest, verdicts, branch) => gen(function* () {
9302
+ const decideLanding = (rest, verdicts, branch, approver) => gen(function* () {
9303
+ const unapproved = approvalRefusal(rest, approver);
9304
+ if (unapproved !== void 0) return unapproved;
9177
9305
  const early = cleanLanding(rest);
9178
9306
  if (early !== void 0) return early;
9179
9307
  if (rest.stepDef.kind === "prompt" && rest.stepDef.access?.write != null) {
@@ -9189,14 +9317,16 @@ const decideLanding = (rest, verdicts, branch) => gen(function* () {
9189
9317
  }));
9190
9318
  const to = yield* landingTarget(outcome, rest);
9191
9319
  if (typeof to !== "string") return to;
9192
- return landingTo(rest, to, branch);
9320
+ return landingTo(rest, to, branch, approver);
9193
9321
  });
9194
9322
  /** 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)));
9323
+ 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
9324
  /** Every fact the pure landing planner needs, read once: the rest, its steering file, and the landing decision. */
9197
9325
  const snapshotFromRest = (rest, verdicts) => gen(function* () {
9198
- const branch = yield* (yield* GitService).currentBranch();
9199
- const landing = yield* decideLanding(rest, verdicts, branch);
9326
+ const git = yield* GitService;
9327
+ const branch = yield* git.currentBranch();
9328
+ const approver = rest.stepDef.approve === true ? yield* git.authorIdent() : none();
9329
+ const landing = yield* decideLanding(rest, verdicts, branch, approver);
9200
9330
  return {
9201
9331
  stepDef: rest.stepDef,
9202
9332
  state: rest.state,
@@ -9224,7 +9354,7 @@ const summaryFor = (run) => gen(function* () {
9224
9354
  })),
9225
9355
  processCost: run.costEntries.reduce((sum, entry) => sum + entry.cost, 0),
9226
9356
  processCostByModel: costByModel(run.costEntries),
9227
- ...settingsFor(resolved, run.pinnedVars, host.env)
9357
+ ...settingsFor(resolved, run.pinnedVars, host.env, {}, {})
9228
9358
  });
9229
9359
  });
9230
9360
  /** Repository-relative paths of the config files today's config and the process's own record name; one outside the repository cannot be read at a ref, so it is left out. */
@@ -9250,7 +9380,8 @@ const historySteps = (run) => {
9250
9380
  actor: entry.actor,
9251
9381
  from: message.parsed?.from,
9252
9382
  to: entry.state,
9253
- verdicts: message.judge
9383
+ verdicts: message.judge,
9384
+ ...optional("approval", approvalEntryOf(entry.hash, message)[0])
9254
9385
  };
9255
9386
  });
9256
9387
  };
@@ -9308,8 +9439,26 @@ const commitAllowEmpty = (message) => {
9308
9439
  `fi`
9309
9440
  ].join("\n");
9310
9441
  };
9442
+ const CHANGED_SINCE_LAND = "gtd: the working tree changed since `gtd land` — revert the edits and run `gtd land` again to approve";
9311
9443
  /** `git add -A || exit 1` (an `&&` left side would not abort under `set -e`) then `commitAllowEmpty`. */
9312
9444
  const commitAll = (message) => `git add -A || exit 1\n${commitAllowEmpty(message)}`;
9445
+ /**
9446
+ * An approval records exactly the judged tree: HEAD is pinned before the
9447
+ * clean check and the commit is built over its tree with `commit-tree`, so no
9448
+ * hook runs and none can add to it. A HEAD that moved fails the guarded
9449
+ * `update-ref`. `commit-tree` ignores `commit.gpgSign`, so it is read here.
9450
+ */
9451
+ const commitClean = (message) => [
9452
+ `gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
9453
+ `if [ -n "$(git status --porcelain --untracked-files=all --ignore-submodules=none)" ]; then`,
9454
+ ` printf '%s\\n' ${shellQuote(CHANGED_SINCE_LAND)} >&2`,
9455
+ ` exit 1`,
9456
+ `fi`,
9457
+ `gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
9458
+ `if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
9459
+ `gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m ${shellQuote(message)}) || exit 1`,
9460
+ `git update-ref -m ${shellQuote(`commit: ${message}`)} HEAD "$gtd_tip" "$gtd_head" || exit 1`
9461
+ ].join("\n");
9313
9462
  /** No current caller — see `Git.ts`'s `GitWriterOperations.commitAsIs` for why it's kept anyway. */
9314
9463
  const commitAsIs = (message) => commitAllowEmpty(message);
9315
9464
  /** No current caller — see `Git.ts`'s `GitWriterOperations.softResetTo` for why it's kept anyway. */
@@ -9347,7 +9496,7 @@ const keepTurn = (refPrefix, message) => {
9347
9496
  `gtd_kept=$(git commit-tree "$gtd_tree" -p "$gtd_head" -m ${shellQuote(message)}) || exit 1`,
9348
9497
  `git update-ref ${keptRef} "$gtd_kept" || exit 1`,
9349
9498
  `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`,
9499
+ `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
9500
  ` git reset -q && git update-ref -d ${keptRef}`,
9352
9501
  ` exit 1`,
9353
9502
  `fi`,
@@ -9385,6 +9534,7 @@ const renderOutcome = (outcome) => {
9385
9534
  const renderGitWrite = (write) => {
9386
9535
  switch (write.kind) {
9387
9536
  case "commitAll": return commitAll(write.message);
9537
+ case "commitClean": return commitClean(write.message);
9388
9538
  case "updateRef": return updateRef(write.ref, write.target);
9389
9539
  case "keepTurn": return keepTurn(write.ref, write.message);
9390
9540
  }
@@ -9858,6 +10008,7 @@ const runHistoryShowCommand = (ref, json, out) => gen(function* () {
9858
10008
  for (const step of trail.steps) {
9859
10009
  lines.push(`${seven(step.hash)} ${step.subject}`);
9860
10010
  for (const v of step.verdicts) lines.push(` judge ${v.id} = ${v.answer} (p=${v.p})`);
10011
+ if (step.approval !== void 0) lines.push(` approved ${step.approval.step} by ${step.approval.name} <${step.approval.email}>`);
9861
10012
  }
9862
10013
  lines.push(`ended (${trail.ended})`);
9863
10014
  out.write(`${lines.join("\n")}\n`);
@@ -10391,7 +10542,8 @@ const gatherBeatDocument = (rest, rendered) => gen(function* () {
10391
10542
  changes: computeStatusChanges(rest.changes),
10392
10543
  next: yield* computeNextMatch(rest),
10393
10544
  cost: rest.cost.total,
10394
- costByModel: rest.cost.byModel
10545
+ costByModel: rest.cost.byModel,
10546
+ approvals: rest.run.approvals
10395
10547
  }));
10396
10548
  });
10397
10549
  /**
@@ -10674,6 +10826,12 @@ const FLAGS = [
10674
10826
  if (/[\r\n]/.test(value)) return left(`gtd: --var ${name} must be a single-line value`);
10675
10827
  if (seen.has(name)) return left(`gtd: --var ${name} specified more than once`);
10676
10828
  seen.add(name);
10829
+ if (name.startsWith("flag_")) {
10830
+ const pinned = flagSettingValue(value);
10831
+ if (pinned === void 0) return left(`gtd: --var ${name} must be 1, 0, true or false`);
10832
+ vars[name] = pinned;
10833
+ continue;
10834
+ }
10677
10835
  vars[name] = value;
10678
10836
  }
10679
10837
  return right(vars);
@@ -11080,11 +11238,11 @@ const COMMAND_ROWS = [
11080
11238
  arity: { name: "ref" },
11081
11239
  details: [
11082
11240
  "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"
11241
+ "steps, judge verdicts and approvals. <ref> is a full ref, an",
11242
+ "entry hash (or unique prefix) or a branch name. Refuses",
11243
+ "(exit 1) when a config file differs from its content at the",
11244
+ "ref's tip. A version mismatch only warns on stderr. --json",
11245
+ "prints the trail as one document; writes nothing"
11088
11246
  ]
11089
11247
  },
11090
11248
  {
@@ -11498,7 +11656,7 @@ const runCli = (argv, io) => {
11498
11656
  return flatMap$4(Host, ({ env }) => try_({
11499
11657
  try: () => resolveFlags(env, io.flags),
11500
11658
  catch: (e) => new GtdError(e.message)
11501
- })).pipe(flatMap$4(() => runCommand$1(plan.command, plan.json, out)), map$4((code) => {
11659
+ })).pipe(flatMap$4((flags) => runCommand$1(plan.command, plan.json, out).pipe(provideService(StartFlags, flagSettings(flags)))), map$4((code) => {
11502
11660
  out.flush();
11503
11661
  io.exit(code);
11504
11662
  }), provide$2(io.layers(plan.verbose)), sandbox, catchAll$1((cause) => isInterruptedOnly(cause) ? failCause$4(cause) : report(io, plan.json.kind !== "off")(cause)));
@@ -11697,6 +11855,10 @@ var InMemRepo = class InMemRepo {
11697
11855
  changedPathsWorktree(base) {
11698
11856
  return diffTrees(this.treeAt(base), new Map(this.worktree));
11699
11857
  }
11858
+ /** The index vs HEAD — the port's `stagedPaths`. */
11859
+ stagedPaths() {
11860
+ return diffTrees(this.treeAt(void 0), this.index);
11861
+ }
11700
11862
  diffPaths(base, tip) {
11701
11863
  for (const ref of [base, tip]) if (this.resolveRef(ref) === null) throw new Error(`Invalid ref: ${ref}`);
11702
11864
  return diffTrees(this.treeAt(base), this.treeAt(tip)).map((e) => e.path);
@@ -11756,6 +11918,7 @@ var InMemRepo = class InMemRepo {
11756
11918
  /** Seeded failure modes (see `GitTierSeed`): a pre-commit hook printing this and exiting 1, and a signer that cannot write. */
11757
11919
  hookRejection = void 0;
11758
11920
  signingFails = false;
11921
+ signingEnabled = false;
11759
11922
  /** Seeded: a pre-commit hook writing and staging this file. */
11760
11923
  hookStages = void 0;
11761
11924
  writeCommit(message) {
@@ -11767,7 +11930,8 @@ var InMemRepo = class InMemRepo {
11767
11930
  message,
11768
11931
  files: new Map(tree),
11769
11932
  parent,
11770
- seq: this.nextSeq++
11933
+ seq: this.nextSeq++,
11934
+ signed: this.signingEnabled
11771
11935
  };
11772
11936
  this.commits.set(hash, commit);
11773
11937
  this.head = hash;
@@ -11788,6 +11952,16 @@ var InMemRepo = class InMemRepo {
11788
11952
  }
11789
11953
  this.writeCommit(message);
11790
11954
  }
11955
+ /** Twin of `GitScript.commitClean`: no hook runs, HEAD's tree is recorded; `undefined` on success, else the stderr text. */
11956
+ commitClean(message) {
11957
+ if (this.head === null) return "fatal: Needed a single revision";
11958
+ if (this.statusPorcelain().trim() !== "") return CHANGED_SINCE_LAND;
11959
+ if (this.signingFails) return "error: gpg failed to sign the data";
11960
+ this.writeCommit(message);
11961
+ }
11962
+ headSigned() {
11963
+ return this.headCommit()?.signed === true;
11964
+ }
11791
11965
  /** Setup commit: stages everything, and throws if a failure is seeded, as the Live tier's hooks and signer would. */
11792
11966
  seedCommit(message) {
11793
11967
  if (this.hookRejection !== void 0 || this.hookStages !== void 0 || this.signingFails) throw new Error("seedCommit after a seeded commit failure");
@@ -11805,7 +11979,8 @@ var InMemRepo = class InMemRepo {
11805
11979
  message,
11806
11980
  files: new Map(tree),
11807
11981
  parent,
11808
- seq: this.nextSeq++
11982
+ seq: this.nextSeq++,
11983
+ signed: false
11809
11984
  });
11810
11985
  const ref = `${refPrefix}/${hash}`;
11811
11986
  this.refs.set(ref, hash);
@@ -12000,7 +12175,12 @@ const makeGitReaderOps = (repo, root) => ({
12000
12175
  commitHistory: (base, head) => succeed$6(repo.commitHistory(base, head)),
12001
12176
  subjectHistory: (pageSize, skip, head) => succeed$6(repo.subjectHistory(pageSize, skip, head)),
12002
12177
  changedPaths: (base) => succeed$6(repo.changedPathsWorktree(base)),
12003
- diffPaths: (base, tip) => tryCatch(() => repo.diffPaths(base, tip))
12178
+ stagedPaths: () => succeed$6(repo.stagedPaths()),
12179
+ diffPaths: (base, tip) => tryCatch(() => repo.diffPaths(base, tip)),
12180
+ authorIdent: () => succeed$6(some({
12181
+ name: "Test",
12182
+ email: "test@test.com"
12183
+ }))
12004
12184
  });
12005
12185
  const failOn = (output) => {
12006
12186
  if (output !== void 0) throw new Error(output);
@@ -12557,6 +12737,18 @@ const recognizeGitBuilders = (repo, block) => {
12557
12737
  }
12558
12738
  return;
12559
12739
  }
12740
+ if (block.startsWith("gtd_head=$(git rev-parse --verify HEAD) || exit 1\nif [ -n ")) {
12741
+ const [message] = extractQuotedTokens(block.slice(block.indexOf("\"$gtd_head\" -m ")));
12742
+ if (message !== void 0 && commitClean(message) === block) {
12743
+ const failure = repo.commitClean(message);
12744
+ return failure === void 0 ? { kind: "applied" } : {
12745
+ kind: "failed",
12746
+ error: failure,
12747
+ status: 1
12748
+ };
12749
+ }
12750
+ return;
12751
+ }
12560
12752
  if (block.startsWith("if ! out=$(git commit --allow-empty -m ")) {
12561
12753
  const [message] = extractQuotedTokens(conditionLine);
12562
12754
  if (message !== void 0 && commitAsIs(message) === block) {
@@ -13216,7 +13408,8 @@ const testRepo = (options) => {
13216
13408
  }
13217
13409
  throw new Error(`the process did not rest within ${maxBeats} beats`);
13218
13410
  },
13219
- subjects: () => repo.commitHistory().map(({ message }) => message.split("\n")[0] ?? "")
13411
+ subjects: () => repo.commitHistory().map(({ message }) => message.split("\n")[0] ?? ""),
13412
+ messages: () => repo.commitHistory().map(({ message }) => message)
13220
13413
  };
13221
13414
  return self;
13222
13415
  };
@@ -13321,6 +13514,13 @@ const makeLiveTier = (initialCommit = true) => {
13321
13514
  gitExec("config", "commit.gpgsign", "true");
13322
13515
  gitExec("config", "gpg.format", "openpgp");
13323
13516
  gitExec("config", "gpg.program", "false");
13517
+ },
13518
+ workingSigner: () => {
13519
+ const signer = join(root, ".git/gtd-test-signer.sh");
13520
+ 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 });
13521
+ gitExec("config", "commit.gpgsign", "true");
13522
+ gitExec("config", "gpg.format", "openpgp");
13523
+ gitExec("config", "gpg.program", signer);
13324
13524
  }
13325
13525
  },
13326
13526
  observe: {
@@ -13350,7 +13550,8 @@ const makeLiveTier = (initialCommit = true) => {
13350
13550
  });
13351
13551
  },
13352
13552
  headSubject: () => gitExec("log", "-1", "--pretty=%s"),
13353
- headTouchedPaths: () => gitExec("show", "--name-only", "--pretty=format:", "HEAD").split("\n").filter((line) => line.length > 0)
13553
+ headTouchedPaths: () => gitExec("show", "--name-only", "--pretty=format:", "HEAD").split("\n").filter((line) => line.length > 0),
13554
+ headSigned: () => gitExec("cat-file", "commit", "HEAD").includes("\ngpgsig ")
13354
13555
  },
13355
13556
  runScript: (script) => {
13356
13557
  const result = spawnSync("sh", ["-c", `exec 2>&1\n${script}`], {
@@ -13429,6 +13630,9 @@ const makeInMemTier = (initialCommit = true) => {
13429
13630
  },
13430
13631
  failingSigner: () => {
13431
13632
  repo.signingFails = true;
13633
+ },
13634
+ workingSigner: () => {
13635
+ repo.signingEnabled = true;
13432
13636
  }
13433
13637
  },
13434
13638
  observe: {
@@ -13449,7 +13653,8 @@ const makeInMemTier = (initialCommit = true) => {
13449
13653
  headSubject: () => repo.lastCommitSubject() ?? "",
13450
13654
  headTouchedPaths: () => {
13451
13655
  return [.../* @__PURE__ */ new Set([...repo.pathsAtRef("HEAD~1"), ...repo.pathsAtRef("HEAD")])].filter((p) => repo.fileAtRef("HEAD~1", p) !== repo.fileAtRef("HEAD", p)).sort();
13452
- }
13656
+ },
13657
+ headSigned: () => repo.headSigned()
13453
13658
  },
13454
13659
  runScript: (script, commands = /* @__PURE__ */ new Map()) => {
13455
13660
  const { ok, status, output, error } = applyEmittedScript(repo, commands, script);
@@ -13526,7 +13731,9 @@ const runGitServiceContract = (makeTier) => {
13526
13731
  g.readFileAtRef("HEAD", "second.txt"),
13527
13732
  g.changedPaths(),
13528
13733
  g.changedPaths("HEAD~1"),
13529
- g.diffPaths("HEAD~1", "HEAD")
13734
+ g.stagedPaths(),
13735
+ g.diffPaths("HEAD~1", "HEAD"),
13736
+ g.authorIdent()
13530
13737
  ]));
13531
13738
  expect(await t.observe.snapshot()).toBe(before);
13532
13739
  await runGit(t, (g) => g.updateRef("refs/gtd/marker", t.observe.resolveRef("HEAD")));
@@ -14014,6 +14221,13 @@ const runGitServiceContract = (makeTier) => {
14014
14221
  it("returns [] on a clean tree", async () => {
14015
14222
  expect(await runGit(t, (g) => g.changedPaths())).toEqual([]);
14016
14223
  });
14224
+ it("ignores an edit staged and then reverted in the worktree", async () => {
14225
+ t.seed.commit("chore: seed", { "a.txt": "1" });
14226
+ t.seed.writeFile("a.txt", "2");
14227
+ t.seed.stageAll();
14228
+ t.seed.writeFile("a.txt", "1");
14229
+ expect(await runGit(t, (g) => g.changedPaths())).toEqual([]);
14230
+ });
14017
14231
  for (const { label, path } of PATHOLOGICAL_PATHS) it(`reports an untracked path containing ${label}, verbatim`, async () => {
14018
14232
  t.seed.writeFile(path, "content");
14019
14233
  expect(await runGit(t, (g) => g.changedPaths())).toEqual([{
@@ -14098,6 +14312,45 @@ const runGitServiceContract = (makeTier) => {
14098
14312
  });
14099
14313
  });
14100
14314
  });
14315
+ describe("stagedPaths", () => {
14316
+ it("returns [] when the index matches HEAD, whatever the worktree holds", async () => {
14317
+ t.seed.commit("chore: seed", { "a.txt": "1" });
14318
+ t.seed.writeFile("a.txt", "2");
14319
+ t.seed.writeFile("u.txt", "u");
14320
+ expect(await runGit(t, (g) => g.stagedPaths())).toEqual([]);
14321
+ });
14322
+ it("reports an edit staged and then reverted in the worktree", async () => {
14323
+ t.seed.commit("chore: seed", { "a.txt": "1" });
14324
+ t.seed.writeFile("a.txt", "2");
14325
+ t.seed.stageAll();
14326
+ t.seed.writeFile("a.txt", "1");
14327
+ expect(await runGit(t, (g) => g.stagedPaths())).toEqual([{
14328
+ path: "a.txt",
14329
+ status: "M"
14330
+ }]);
14331
+ });
14332
+ it("reports staged additions and deletions", async () => {
14333
+ t.seed.commit("chore: seed", { "gone.txt": "g" });
14334
+ t.seed.deleteFile("gone.txt");
14335
+ t.seed.writeFile("new.txt", "n");
14336
+ t.seed.stageAll();
14337
+ expect([...await runGit(t, (g) => g.stagedPaths())].sort((a, b) => a.path.localeCompare(b.path))).toEqual([{
14338
+ path: "gone.txt",
14339
+ status: "D"
14340
+ }, {
14341
+ path: "new.txt",
14342
+ status: "A"
14343
+ }]);
14344
+ });
14345
+ });
14346
+ describe("authorIdent", () => {
14347
+ it("is the configured name and email", async () => {
14348
+ expect(await runGit(t, (g) => g.authorIdent())).toEqual(some({
14349
+ name: "Test",
14350
+ email: "test@test.com"
14351
+ }));
14352
+ });
14353
+ });
14101
14354
  describe("topLevel", () => {
14102
14355
  it("resolves to this tier's own root", async () => {
14103
14356
  expect(await runGit(t, (g) => g.topLevel())).toBe(t.root);
@@ -14192,6 +14445,74 @@ const runGitScriptContract = (makeTier) => {
14192
14445
  expect(t.observe.headSubject()).toBe("gtd: grilled");
14193
14446
  });
14194
14447
  });
14448
+ describe("commitClean", () => {
14449
+ it("on a clean tree commits an empty commit with the message", () => {
14450
+ const headBefore = t.observe.resolveRef("HEAD");
14451
+ run(t, commitClean("gtd(human): gate → again"));
14452
+ expect(t.observe.resolveRef("HEAD")).not.toBe(headBefore);
14453
+ expect(t.observe.headSubject()).toBe("gtd(human): gate → again");
14454
+ expect(t.observe.headTouchedPaths()).toEqual([]);
14455
+ });
14456
+ it("a staging hook never runs: nothing touched, nothing staged, file absent", () => {
14457
+ const headBefore = t.observe.resolveRef("HEAD");
14458
+ t.seed.stagingHook("hooked.txt", "hooked");
14459
+ run(t, commitClean("gtd(human): gate → again"));
14460
+ expect(t.observe.headTouchedPaths()).toEqual([]);
14461
+ expect(t.observe.resolveRef("HEAD^")).toBe(headBefore);
14462
+ expect(t.observe.statusPorcelain().trim()).toBe("");
14463
+ expect(t.observe.existsPath("hooked.txt")).toBe(false);
14464
+ });
14465
+ it("a rejecting hook is never consulted", () => {
14466
+ t.seed.rejectingHook("rejected");
14467
+ run(t, commitClean("gtd(human): gate → again"));
14468
+ expect(t.observe.headSubject()).toBe("gtd(human): gate → again");
14469
+ });
14470
+ it("a working signer signs the approval", () => {
14471
+ t.seed.workingSigner();
14472
+ run(t, commitClean("gtd(human): gate → again"));
14473
+ expect(t.observe.headSigned()).toBe(true);
14474
+ });
14475
+ it("no signing config: the approval does not try to sign", () => {
14476
+ run(t, commitClean("gtd(human): gate → again"));
14477
+ expect(t.observe.headSigned()).toBe(false);
14478
+ });
14479
+ it("a failing signer fails the approval: HEAD and status unchanged", () => {
14480
+ t.seed.failingSigner();
14481
+ const headBefore = t.observe.resolveRef("HEAD");
14482
+ const result = t.runScript(commitClean("gtd(human): gate → again"));
14483
+ expect(result.ok).toBe(false);
14484
+ expect(result.status).toBe(1);
14485
+ expect(result.output).toContain("failed to sign");
14486
+ expect(t.observe.resolveRef("HEAD")).toBe(headBefore);
14487
+ expect(t.observe.statusPorcelain().trim()).toBe("");
14488
+ });
14489
+ const refused = (name, dirty, pending) => {
14490
+ it(`${name}: fails, HEAD unchanged, edit still pending`, () => {
14491
+ dirty();
14492
+ const headBefore = t.observe.resolveRef("HEAD");
14493
+ const result = t.runScript(commitClean("gtd(human): gate → again"));
14494
+ expect(result.ok).toBe(false);
14495
+ expect(result.output).toContain("changed since `gtd land`");
14496
+ expect(t.observe.resolveRef("HEAD")).toBe(headBefore);
14497
+ expect(t.observe.statusPorcelain()).toContain(pending);
14498
+ });
14499
+ };
14500
+ refused("a modified tracked file", () => {
14501
+ t.seed.commit("feat: base", { "a.txt": "1" });
14502
+ t.seed.writeFile("a.txt", "2");
14503
+ }, "a.txt");
14504
+ refused("a staged change", () => {
14505
+ t.seed.writeFile("s.txt", "s");
14506
+ t.seed.stageAll();
14507
+ }, "s.txt");
14508
+ refused("an untracked file", () => t.seed.writeFile("u.txt", "u"), "u.txt");
14509
+ refused("an edit staged then reverted in the worktree", () => {
14510
+ t.seed.commit("feat: base", { "a.txt": "1" });
14511
+ t.seed.writeFile("a.txt", "2");
14512
+ t.seed.stageAll();
14513
+ t.seed.writeFile("a.txt", "1");
14514
+ }, "a.txt");
14515
+ });
14195
14516
  describe("commitAsIs", () => {
14196
14517
  it("does NOT stage — a worktree write after staging is excluded (the squash mechanic)", () => {
14197
14518
  t.seed.writeFile("a.txt", "a");