@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
@@ -20269,6 +20269,13 @@ const makeGitImpl = (executor, root) => {
20269
20269
  hasCommits: () => exec("git", "rev-parse", "--verify", "HEAD").pipe(map$4(() => true), catchAll(() => succeed$3(false))),
20270
20270
  readFileAtRef: (ref, path) => exec("git", "show", `${ref}:${path}`),
20271
20271
  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)),
20272
+ authorIdent: () => exec("git", "var", "GIT_AUTHOR_IDENT").pipe(map$4((out) => {
20273
+ const match = /^(.*?) <([^<>]*)> \d+ [+-]\d{4}$/.exec(out.trim());
20274
+ return match === null || match[1] === "" || match[2] === "" ? none$4() : some({
20275
+ name: match[1],
20276
+ email: match[2]
20277
+ });
20278
+ }), catchAll(() => succeed$3(none$4()))),
20272
20279
  changedPaths: (base) => gen(function* () {
20273
20280
  const ref = base ?? "HEAD";
20274
20281
  const nameStatusOut = yield* exec("git", "diff", "--name-status", "-z", ref).pipe(catchAll(() => succeed$3("")));
@@ -20286,6 +20293,7 @@ const makeGitImpl = (executor, root) => {
20286
20293
  }
20287
20294
  return all;
20288
20295
  }),
20296
+ stagedPaths: () => exec("git", "diff", "--cached", "--name-status", "--no-renames", "-z", "HEAD").pipe(catchAll(() => succeed$3("")), map$4((out) => parseNameStatus(splitNul(out)))),
20289
20297
  resolveRef: (ref) => exec("git", "rev-parse", "--verify", "--end-of-options", ref).pipe(map$4((s) => s.trim()), flatMap$4((hash) => resolvedRefOrCorrupted(ref, hash))),
20290
20298
  readRefOption: (ref) => exec("git", "rev-parse", "--verify", "--quiet", ref).pipe(map$4((s) => some(s.trim())), catchAll(() => succeed$3(none$4()))),
20291
20299
  isAncestor: (a, b) => run$4(root, "git", "merge-base", "--is-ancestor", a, b).pipe(provide$1(succeed$2(CommandExecutor, executor)), map$4(() => true), catchAll(() => succeed$3(false))),
@@ -20962,13 +20970,23 @@ const restorability = (git, headHash, tipHash) => gen(function* () {
20962
20970
  });
20963
20971
  //#endregion
20964
20972
  //#region src/flags/index.ts
20965
- const FLAGS$1 = [];
20973
+ const FLAGS$1 = [{
20974
+ name: "naive_pass",
20975
+ owner: "pmelab",
20976
+ expiry: "2027-01-15",
20977
+ kind: "release"
20978
+ }];
20966
20979
  const BOOL = {
20967
20980
  "0": false,
20968
20981
  false: false,
20969
20982
  "1": true,
20970
20983
  true: true
20971
20984
  };
20985
+ /** A `flag_<name>` setting's value as the pinned `"1"`/`"0"`, or undefined when garbled. */
20986
+ const flagSettingValue = (raw) => {
20987
+ const key = raw.toLowerCase();
20988
+ return Object.hasOwn(BOOL, key) ? BOOL[key] ? "1" : "0" : void 0;
20989
+ };
20972
20990
  const resolveFlags = (env, registry = FLAGS$1) => {
20973
20991
  const out = {};
20974
20992
  for (const { name, defaultOn } of registry) {
@@ -20983,6 +21001,7 @@ const resolveFlags = (env, registry = FLAGS$1) => {
20983
21001
  }
20984
21002
  return out;
20985
21003
  };
21004
+ const flagSettings = (flags) => Object.fromEntries(Object.entries(flags).map(([name, on]) => [`flag_${name}`, on ? "1" : "0"]));
20986
21005
  //#endregion
20987
21006
  //#region src/install/Version.ts
20988
21007
  const _require = createRequire(import.meta.url);
@@ -21231,7 +21250,8 @@ skills, \`skills\` (an array of the skill names, already trimmed and split —
21231
21250
  (\`{read, write}\`, each an array of globs or \`null\` for unrestricted). When set: \`model\`, \`memory\`,
21232
21251
  \`label\`, \`file\`, \`mode\`, \`edges\`, \`judge\` (the pending judgment's rendered
21233
21252
  document). When a cost has been recorded (a prior \`gtd land --cost=<n>\`):
21234
- \`cost\`, \`costByModel\`.
21253
+ \`cost\`, \`costByModel\`. Always \`approvals\` (an array of
21254
+ \`{step, name, email, commit}\`, \`[]\` when none).
21235
21255
 
21236
21256
  \`gtd land\` fields, in fixed order: \`schema\`, \`script\` (the POSIX sh to run), \`settled\`
21237
21257
  (true for a no-op at a \`script\` rest — stop immediately, nothing more to
@@ -21967,7 +21987,8 @@ const strayContent = (strays) => strays.map((c) => [
21967
21987
  */
21968
21988
  const judgeDiffShape = async (name, options) => {
21969
21989
  const { changes: all, allow } = options;
21970
- const isStray = (c) => !allow.some((pattern) => matches(c.path, pattern));
21990
+ const exempt = options.exempt ?? [];
21991
+ const isStray = (c) => !exempt.includes(c.path) && !allow.some((pattern) => matches(c.path, pattern));
21971
21992
  const strayChanges = all.filter(isStray).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0).map(({ path, status, before, after }) => ({
21972
21993
  path,
21973
21994
  status,
@@ -22013,6 +22034,28 @@ const judgeDiffShape = async (name, options) => {
22013
22034
  judgment
22014
22035
  };
22015
22036
  };
22037
+ const displayPath = (path) => /[\x00-\x1f\x7f-\x9f]/.test(path) ? JSON.stringify(path) : path;
22038
+ const seamReport = (shape) => {
22039
+ const unreadable = new Set(shape.unreadable);
22040
+ return [
22041
+ "Seam report: the judge found the diff's shape needs a refactor first. These paths stray outside the allowlist:",
22042
+ "",
22043
+ ...shape.strays.map((path) => `- ${displayPath(path)}${unreadable.has(path) ? " (unreadable — not judged)" : ""}`),
22044
+ "",
22045
+ "Landing a clean tree with `gtd land` approves this diff exactly as judged; edits are refused. To stop instead, run `gtd abandon`."
22046
+ ].join("\n");
22047
+ };
22048
+ /**
22049
+ * Rest at an approval gate `name` showing the seam report — only when
22050
+ * `shape.choice` is `refactor-first`. `land` and `reject` reach no step.
22051
+ */
22052
+ const seamGate = async (name, shape) => {
22053
+ if (shape.choice !== "refactor-first") return;
22054
+ await human(name, {
22055
+ message: seamReport(shape),
22056
+ approve: true
22057
+ });
22058
+ };
22016
22059
  //#endregion
22017
22060
  //#region src/flows/factory.ts
22018
22061
  const FACTORY = Symbol.for("@pmelab/gtd/workflow-factory");
@@ -22191,6 +22234,7 @@ var flows_exports = /* @__PURE__ */ __exportAll({
22191
22234
  configure: () => configure$1,
22192
22235
  defineConfig: () => defineConfig,
22193
22236
  defineWorkflow: () => defineWorkflow,
22237
+ displayPath: () => displayPath,
22194
22238
  env: () => env,
22195
22239
  glob: () => glob,
22196
22240
  hasThreadFor: () => hasThreadFor,
@@ -22220,6 +22264,7 @@ var flows_exports = /* @__PURE__ */ __exportAll({
22220
22264
  revertScript: () => revertScript,
22221
22265
  run: () => run$3,
22222
22266
  scope: () => scope,
22267
+ seamGate: () => seamGate,
22223
22268
  sectionBodies: () => sectionBodies,
22224
22269
  sections: () => sections,
22225
22270
  showScript: () => showScript,
@@ -37282,7 +37327,7 @@ var init_Demand = __esmMin((() => {
37282
37327
  var statusOf;
37283
37328
  var init_BeatStatus = __esmMin((() => {
37284
37329
  statusOf = (input) => {
37285
- const { rendered, idle, initial, workflow, log, changes, next, cost, costByModel } = input;
37330
+ const { rendered, idle, initial, workflow, log, changes, next, cost, costByModel, approvals } = input;
37286
37331
  return {
37287
37332
  idle,
37288
37333
  initial,
@@ -37300,7 +37345,8 @@ var init_BeatStatus = __esmMin((() => {
37300
37345
  next,
37301
37346
  cost,
37302
37347
  costByModel,
37303
- judge: rendered.judge
37348
+ judge: rendered.judge,
37349
+ approvals
37304
37350
  };
37305
37351
  };
37306
37352
  }));
@@ -37360,7 +37406,8 @@ var init_BeatDocument = __esmMin((() => {
37360
37406
  costByModel: hasCost ? status.costByModel : void 0,
37361
37407
  judge: status.judge,
37362
37408
  skills,
37363
- access
37409
+ access,
37410
+ approvals: status.approvals
37364
37411
  };
37365
37412
  };
37366
37413
  renderBeatJson = (document) => JSON.stringify(document) + "\n";
@@ -37689,12 +37736,38 @@ const TRAILER_RE = /^(Gtd-[A-Za-z-]+):[ \t]*(.*?)[ \t]*$/gm;
37689
37736
  const COST_RE = /^([0-9]+(?:\.[0-9]+)?)(?:[ \t]+(.+))?$/;
37690
37737
  const VAR_RE = /^([^=\s]+)=(.*)$/;
37691
37738
  const CONFIG_RE = /^([0-9a-f]{16}) (.+)$/;
37692
- const CONTROL_CHARACTER$1 = /[\x00-\x1f\x7f-\x9f]/;
37739
+ const CONTROL_CHARACTER$1 = /[\x00-\x1f\x7f-\x9f\u2028\u2029]/;
37693
37740
  const encodePath = (path) => path.trim() !== path || path.startsWith("\"") || CONTROL_CHARACTER$1.test(path) ? JSON.stringify(path) : path;
37694
37741
  const decodePath = (raw) => {
37695
37742
  const path = raw.startsWith("\"") ? parseJson$1(raw) : raw;
37696
37743
  return typeof path === "string" && path !== "" && !CONTROL_CHARACTER$1.test(path) ? path : void 0;
37697
37744
  };
37745
+ const decodeApproval = (raw) => {
37746
+ const parsed = parseJson$1(raw);
37747
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
37748
+ const { step, name, email } = parsed;
37749
+ if (typeof step !== "string" || typeof name !== "string" || typeof email !== "string") return void 0;
37750
+ if ([
37751
+ step,
37752
+ name,
37753
+ email
37754
+ ].some((field) => CONTROL_CHARACTER$1.test(field))) return void 0;
37755
+ const id = parseStepId(step);
37756
+ return id === void 0 ? void 0 : {
37757
+ step: id,
37758
+ name,
37759
+ email
37760
+ };
37761
+ };
37762
+ const encodeApproval = ({ step, name, email }) => JSON.stringify({
37763
+ step: formatStepId(step),
37764
+ name,
37765
+ email
37766
+ });
37767
+ const isRecordableApproval = (approval) => {
37768
+ const back = decodeApproval(encodeApproval(approval));
37769
+ return back !== void 0 && encodeApproval(back) === encodeApproval(approval);
37770
+ };
37698
37771
  const FORMAT_RE = /^[1-9][0-9]*$/;
37699
37772
  const HASH_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
37700
37773
  const TRAILER_READERS = {
@@ -37729,6 +37802,9 @@ const TRAILER_READERS = {
37729
37802
  "Gtd-Kept": (value, into) => {
37730
37803
  if (HASH_RE.test(value)) into.kept ??= value;
37731
37804
  },
37805
+ "Gtd-Approved": (value, into) => {
37806
+ into.approved ??= decodeApproval(value);
37807
+ },
37732
37808
  "Gtd-Format": (value, into) => {
37733
37809
  if (FORMAT_RE.test(value)) into.format ??= Number(value);
37734
37810
  },
@@ -37750,6 +37826,7 @@ const parseCommitMessage = (message) => {
37750
37826
  branch: void 0,
37751
37827
  version: void 0,
37752
37828
  kept: void 0,
37829
+ approved: void 0,
37753
37830
  judge: [],
37754
37831
  vars: {},
37755
37832
  config: {},
@@ -37775,6 +37852,7 @@ const formatCommitMessage = (spec) => {
37775
37852
  for (const [path, hash] of sortedEntries(spec.config)) lines.push(`Gtd-Config: ${hash} ${encodePath(path)}`);
37776
37853
  if (spec.cost !== void 0) lines.push(`Gtd-Cost: ${spec.cost.cost}${spec.cost.model !== void 0 ? ` ${spec.cost.model}` : ""}`);
37777
37854
  for (const verdict of spec.judge ?? []) lines.push(`Gtd-Judge: ${JSON.stringify(verdict)}`);
37855
+ if (spec.approved !== void 0) lines.push(`Gtd-Approved: ${encodeApproval(spec.approved)}`);
37778
37856
  if (spec.branch !== void 0) lines.push(`Gtd-Branch: ${spec.branch}`);
37779
37857
  if (spec.version !== void 0) lines.push(`Gtd-Version: ${spec.version}`);
37780
37858
  lines.push(`Gtd-Format: 1`);
@@ -37870,7 +37948,8 @@ const KNOWN_OPTIONS = {
37870
37948
  human: /* @__PURE__ */ new Set([
37871
37949
  ...STEERING_OPTIONS,
37872
37950
  "message",
37873
- "acceptClean"
37951
+ "acceptClean",
37952
+ "approve"
37874
37953
  ]),
37875
37954
  run: new Set(STEERING_OPTIONS),
37876
37955
  judge: /* @__PURE__ */ new Set([...STEERING_OPTIONS, "message"])
@@ -37880,6 +37959,13 @@ const unknownOptions = (step) => {
37880
37959
  if (unknown.length === 0) return void 0;
37881
37960
  return `gtd: step "${step.name}": unknown key(s) ${unknown.join(", ")} in ${step.kind}() options`;
37882
37961
  };
37962
+ const approveConflict = (step) => {
37963
+ const request = step.request;
37964
+ if (request.kind !== "human" || request.options.approve !== true) return void 0;
37965
+ const keys = ["mode", "file"].filter((key) => request.options[key] !== void 0);
37966
+ if (keys.length === 0) return void 0;
37967
+ 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`;
37968
+ };
37883
37969
  const replay = async (input) => {
37884
37970
  const commits = input.episode.commits.map((c) => ({
37885
37971
  ...c,
@@ -38039,12 +38125,21 @@ const replay = async (input) => {
38039
38125
  kind: "divergence",
38040
38126
  message: `gtd: commit ${short(expected.hash)} names "${expected.name}" as the next step, but replay reached "${step.name}" — ${DIVERGED}`
38041
38127
  };
38042
- const error = unknownOptions(step) ?? checkIdentity(step);
38128
+ const error = unknownOptions(step) ?? approveConflict(step) ?? checkIdentity(step);
38043
38129
  return error === void 0 ? void 0 : {
38044
38130
  kind: "failed",
38045
38131
  message: error
38046
38132
  };
38047
38133
  };
38134
+ const missingApproval = (step, commit, expected) => {
38135
+ if (step.request.kind !== "human" || step.request.options.approve !== true) return void 0;
38136
+ const approved = commit.parsed.approved;
38137
+ if (approved !== void 0 && formatStepId(approved.step) === expected) return void 0;
38138
+ return {
38139
+ kind: "divergence",
38140
+ message: `gtd: commit ${short(commit.hash)} completes approval gate "${expected}" but records no Gtd-Approved for it — ${DIVERGED}`
38141
+ };
38142
+ };
38048
38143
  const consumeCommit = (step, commit) => {
38049
38144
  const recorded = commit.parsed.step;
38050
38145
  const expected = formatStepId(step.id);
@@ -38055,6 +38150,8 @@ const replay = async (input) => {
38055
38150
  message: `gtd: commit ${short(commit.hash)} ${what}, but replay expected "${expected}" — ${DIVERGED}`
38056
38151
  });
38057
38152
  }
38153
+ const unapproved = missingApproval(step, commit, expected);
38154
+ if (unapproved !== void 0) return finish(unapproved);
38058
38155
  cursor++;
38059
38156
  advance(commit);
38060
38157
  if (commit.parsed.kept !== void 0) if (commit.kept === void 0) keptTips.delete(step.name);
@@ -38284,7 +38381,10 @@ const compileConfig = (value, origin) => {
38284
38381
  };
38285
38382
  //#endregion
38286
38383
  //#region src/workflow/vars.ts
38384
+ init_esm();
38287
38385
  const PREFIX = "GTD_";
38386
+ /** The live release flags as `flag_<name>` settings, provided by the CLI; absent means no flags. */
38387
+ var StartFlags = class extends Reference()("StartFlags", { defaultValue: () => ({}) }) {};
38288
38388
  /**
38289
38389
  * One kind of setting, later wins: the workflow's own defaults, `--var`
38290
38390
  * overrides (process settings only; environment settings pass `{}`), then
@@ -38304,6 +38404,18 @@ const resolveVars = (workflowVars, entryVars, env) => {
38304
38404
  }
38305
38405
  return merged;
38306
38406
  };
38407
+ /**
38408
+ * `resolveVars` for a process being STARTED: `flags` land between the workflow
38409
+ * defaults and `--var`, for already-declared names only. A running process never
38410
+ * calls this — it replays its pinned record.
38411
+ */
38412
+ const resolveStartVars = (workflowVars, flags, entryVars, env) => {
38413
+ const declared = Object.fromEntries(Object.entries(flags).filter(([name]) => Object.hasOwn(workflowVars, name)));
38414
+ return resolveVars({
38415
+ ...workflowVars,
38416
+ ...declared
38417
+ }, entryVars, env);
38418
+ };
38307
38419
  /** The first process setting whose value spans lines — a `Gtd-Var:` trailer cannot carry it. */
38308
38420
  const multilineSetting = (vars) => Object.keys(vars).sort().find((name) => /[\r\n]/.test(vars[name]));
38309
38421
  const SETTING_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -39169,6 +39281,9 @@ const planStep = (snapshot, opts = {}) => {
39169
39281
  kind: "keepTurn",
39170
39282
  ref: landing.keepTurn,
39171
39283
  message
39284
+ } : landing.spec.approved !== void 0 ? {
39285
+ kind: "commitClean",
39286
+ message
39172
39287
  } : {
39173
39288
  kind: "commitAll",
39174
39289
  message
@@ -39234,7 +39349,7 @@ const planStart = (current, actor, start) => gen(function* () {
39234
39349
  const overrideRefusal = yield* checkOverrides(commandLabel, varOverrides, Object.keys(workflow.vars), Object.keys(workflow.env));
39235
39350
  if (overrideRefusal !== void 0) return overrideRefusal;
39236
39351
  const baseOf = workflow.def.base;
39237
- const vars = resolveVars(workflow.vars, varOverrides, (yield* Host).env);
39352
+ const vars = resolveStartVars(workflow.vars, yield* StartFlags, varOverrides, (yield* Host).env);
39238
39353
  const multiline = multilineSetting(vars);
39239
39354
  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`);
39240
39355
  const template = yield* try_({
@@ -39376,6 +39491,12 @@ const pinnedConfigOf = (history) => {
39376
39491
  };
39377
39492
  }
39378
39493
  };
39494
+ const approvalEntryOf = (hash, message) => message.approved === void 0 ? [] : [{
39495
+ step: formatStepId(message.approved.step),
39496
+ name: message.approved.name,
39497
+ email: message.approved.email,
39498
+ commit: hash
39499
+ }];
39379
39500
  const buildRun = (history, location, closingHash) => {
39380
39501
  const processCommits = history.slice(location.processStart);
39381
39502
  const parsed = processCommits.map((c) => parseCommitMessage(c.message));
@@ -39391,6 +39512,7 @@ const buildRun = (history, location, closingHash) => {
39391
39512
  trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i])),
39392
39513
  costEntries: parsed.flatMap(costEntriesOf),
39393
39514
  judgeVerdicts: parsed.flatMap((m) => m.judge),
39515
+ approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i])),
39394
39516
  pinnedVars: { ...first?.vars },
39395
39517
  pinnedConfig: pinnedConfigOf(history.slice(Math.max(location.baseIndex, 0))),
39396
39518
  headTurn: headTurnOf(head),
@@ -39579,9 +39701,9 @@ const pendingTree = (workspace, entries, rewrite) => {
39579
39701
  * are always live. `startOverrides` are `--var` values, passed only while a
39580
39702
  * process is being started.
39581
39703
  */
39582
- const settingsFor = (workflow, pinnedVars, hostEnv, startOverrides = {}) => ({
39704
+ const settingsFor = (workflow, pinnedVars, hostEnv, startOverrides = {}, startFlags) => ({
39583
39705
  vars: {
39584
- ...resolveVars(workflow.vars, startOverrides, hostEnv),
39706
+ ...resolveStartVars(workflow.vars, startFlags, startOverrides, hostEnv),
39585
39707
  ...pinnedVars
39586
39708
  },
39587
39709
  env: resolveVars(workflow.env, {}, hostEnv)
@@ -39676,7 +39798,8 @@ const messageDef = (common, request, step) => ({
39676
39798
  ...common,
39677
39799
  kind: "message",
39678
39800
  content: request.options.message ?? request.options.label ?? step.name,
39679
- ...optional$1("acceptClean", request.options.acceptClean)
39801
+ ...optional$1("acceptClean", request.options.approve === true || request.options.acceptClean),
39802
+ ...optional$1("approve", request.options.approve)
39680
39803
  });
39681
39804
  const stepDefOf = (step) => {
39682
39805
  const request = step.request;
@@ -39754,7 +39877,8 @@ const costByModel = (entries) => {
39754
39877
  const setupFor = (config, run, workspace, host, startOverrides = {}) => gen(function* () {
39755
39878
  const resolved = yield* workflowOf(config, run);
39756
39879
  const def = resolved.def;
39757
- const { vars, env } = settingsFor(resolved, run.pinnedVars, host.env, startOverrides);
39880
+ const startFlags = run.trace.length === 0 ? yield* StartFlags : {};
39881
+ const { vars, env } = settingsFor(resolved, run.pinnedVars, host.env, startOverrides, startFlags);
39758
39882
  const skills = yield* resolveScopeSkills(def, vars);
39759
39883
  const access = yield* resolveScopeAccess(def, skills, vars);
39760
39884
  const budget = yield* try_({
@@ -39805,6 +39929,12 @@ const setupFor = (config, run, workspace, host, startOverrides = {}) => gen(func
39805
39929
  }
39806
39930
  };
39807
39931
  });
39932
+ const pendingChanges = (git, withIndex) => gen(function* () {
39933
+ const worktree = yield* git.changedPaths();
39934
+ if (!withIndex) return worktree;
39935
+ const seen = new Set(worktree.map((e) => e.path));
39936
+ return [...worktree, ...(yield* git.stagedPaths()).filter((e) => !seen.has(e.path))];
39937
+ });
39808
39938
  /** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
39809
39939
  const restAt = (ref, startOverrides = {}) => gen(function* () {
39810
39940
  const git = yield* GitService;
@@ -39820,7 +39950,7 @@ const restAt = (ref, startOverrides = {}) => gen(function* () {
39820
39950
  yield* (yield* Narrator).narrate(`rest resolved: ${step.name} (awaits ${step.actor})`);
39821
39951
  const stepDef = stepDefOf(step);
39822
39952
  if (stepDef.mode !== void 0 && def.modes[stepDef.mode] === void 0) return yield* fail$7(/* @__PURE__ */ new Error(`gtd config: step "${step.name}": mode "${stepDef.mode}" is not a mode this workflow knows (${knownModes(def).join(", ")})`));
39823
- const changes = ref === void 0 ? (yield* git.changedPaths()).map((e) => ({
39953
+ const changes = ref === void 0 ? (yield* pendingChanges(git, stepDef.approve === true)).map((e) => ({
39824
39954
  status: normalizeStatus(e.status),
39825
39955
  path: e.path
39826
39956
  })) : [];
@@ -39858,7 +39988,7 @@ const currentRest = restAt(void 0);
39858
39988
  */
39859
39989
  const startRefusal = (rest, resolved, startVars) => gen(function* () {
39860
39990
  const host = yield* Host;
39861
- const { vars, env } = settingsFor(resolved, {}, host.env, startVars);
39991
+ const { vars, env } = settingsFor(resolved, {}, host.env, startVars, yield* StartFlags);
39862
39992
  const skills = yield* resolveScopeSkills(resolved.def, vars);
39863
39993
  const access = yield* resolveScopeAccess(resolved.def, skills, vars);
39864
39994
  const workspace = rest.setup.workspace;
@@ -39906,6 +40036,25 @@ const noProcessUnderway = (rest) => rest.run.workflow === void 0 && rest.state =
39906
40036
  /** `idle` means exactly one thing: no process underway, clean tree. */
39907
40037
  const restIsIdle = (rest) => noProcessUnderway(rest) && rest.changes.length === 0;
39908
40038
  const isHumanReviewGate = (def) => def.actor === "human" && def.mode === "review";
40039
+ const approvalRefusal = (rest, approver) => {
40040
+ if (rest.stepDef.approve !== true) return void 0;
40041
+ const id = rest.step.id;
40042
+ if (rest.changes.length > 0) return {
40043
+ kind: "refusal",
40044
+ 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\``
40045
+ };
40046
+ if (isNone(approver)) return {
40047
+ kind: "refusal",
40048
+ message: `gtd land: approval-gate: ${formatStepId(id)} records who approved it, but git has no author identity — set user.name and user.email`
40049
+ };
40050
+ if (!isRecordableApproval({
40051
+ step: id,
40052
+ ...approver.value
40053
+ })) return {
40054
+ kind: "refusal",
40055
+ 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)`
40056
+ };
40057
+ };
39909
40058
  /** The landing a clean tree decides before any replay, if it decides one. */
39910
40059
  const cleanLanding = (rest) => {
39911
40060
  if (rest.changes.length > 0) return void 0;
@@ -39960,7 +40109,11 @@ const closingTrailers = (branch) => ({
39960
40109
  });
39961
40110
  const isSettledNoop = (rest, to) => rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state;
39962
40111
  const keptTurn = (rest) => rest.stepDef.keep === true && rest.changes.length > 0 ? { keepTurn: keptRef(rest.run.startHash, rest.state) } : {};
39963
- const landingTo = (rest, to, branch) => {
40112
+ const approvedTrailer = (rest, approver) => isSome(approver) ? { approved: {
40113
+ step: rest.step.id,
40114
+ ...approver.value
40115
+ } } : {};
40116
+ const landingTo = (rest, to, branch, approver) => {
39964
40117
  if (isSettledNoop(rest, to)) return {
39965
40118
  kind: "noop",
39966
40119
  settled: true
@@ -39978,6 +40131,7 @@ const landingTo = (rest, to, branch) => {
39978
40131
  from: rest.state,
39979
40132
  to,
39980
40133
  step: rest.step.id,
40134
+ ...approvedTrailer(rest, approver),
39981
40135
  ...starts ? {
39982
40136
  vars: rest.vars,
39983
40137
  config: rest.config
@@ -39993,7 +40147,9 @@ const landingTo = (rest, to, branch) => {
39993
40147
  * replay with the working tree as the pending turn. The target step is
39994
40148
  * computed by replaying, never matched.
39995
40149
  */
39996
- const decideLanding = (rest, verdicts, branch) => gen(function* () {
40150
+ const decideLanding = (rest, verdicts, branch, approver) => gen(function* () {
40151
+ const unapproved = approvalRefusal(rest, approver);
40152
+ if (unapproved !== void 0) return unapproved;
39997
40153
  const early = cleanLanding(rest);
39998
40154
  if (early !== void 0) return early;
39999
40155
  if (rest.stepDef.kind === "prompt" && rest.stepDef.access?.write != null) {
@@ -40009,14 +40165,16 @@ const decideLanding = (rest, verdicts, branch) => gen(function* () {
40009
40165
  }));
40010
40166
  const to = yield* landingTarget(outcome, rest);
40011
40167
  if (typeof to !== "string") return to;
40012
- return landingTo(rest, to, branch);
40168
+ return landingTo(rest, to, branch, approver);
40013
40169
  });
40014
40170
  /** Where landing the pending turn would leave the process, or `undefined` when it would land nothing. */
40015
- const previewLanding = (rest) => decideLanding(rest, void 0, none$4()).pipe(map$4((landing) => landing.kind === "commit" ? landing.to : void 0), catchAll(() => succeed$3(void 0)));
40171
+ const previewLanding = (rest) => decideLanding(rest, void 0, none$4(), none$4()).pipe(map$4((landing) => landing.kind === "commit" ? landing.to : void 0), catchAll(() => succeed$3(void 0)));
40016
40172
  /** Every fact the pure landing planner needs, read once: the rest, its steering file, and the landing decision. */
40017
40173
  const snapshotFromRest = (rest, verdicts) => gen(function* () {
40018
- const branch = yield* (yield* GitService).currentBranch();
40019
- const landing = yield* decideLanding(rest, verdicts, branch);
40174
+ const git = yield* GitService;
40175
+ const branch = yield* git.currentBranch();
40176
+ const approver = rest.stepDef.approve === true ? yield* git.authorIdent() : none$4();
40177
+ const landing = yield* decideLanding(rest, verdicts, branch, approver);
40020
40178
  return {
40021
40179
  stepDef: rest.stepDef,
40022
40180
  state: rest.state,
@@ -40044,7 +40202,7 @@ const summaryFor = (run) => gen(function* () {
40044
40202
  })),
40045
40203
  processCost: run.costEntries.reduce((sum, entry) => sum + entry.cost, 0),
40046
40204
  processCostByModel: costByModel(run.costEntries),
40047
- ...settingsFor(resolved, run.pinnedVars, host.env)
40205
+ ...settingsFor(resolved, run.pinnedVars, host.env, {}, {})
40048
40206
  });
40049
40207
  });
40050
40208
  /** 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. */
@@ -40070,7 +40228,8 @@ const historySteps = (run) => {
40070
40228
  actor: entry.actor,
40071
40229
  from: message.parsed?.from,
40072
40230
  to: entry.state,
40073
- verdicts: message.judge
40231
+ verdicts: message.judge,
40232
+ ...optional$1("approval", approvalEntryOf(entry.hash, message)[0])
40074
40233
  };
40075
40234
  });
40076
40235
  };
@@ -40128,8 +40287,26 @@ const commitAllowEmpty = (message) => {
40128
40287
  `fi`
40129
40288
  ].join("\n");
40130
40289
  };
40290
+ const CHANGED_SINCE_LAND = "gtd: the working tree changed since `gtd land` — revert the edits and run `gtd land` again to approve";
40131
40291
  /** `git add -A || exit 1` (an `&&` left side would not abort under `set -e`) then `commitAllowEmpty`. */
40132
40292
  const commitAll = (message) => `git add -A || exit 1\n${commitAllowEmpty(message)}`;
40293
+ /**
40294
+ * An approval records exactly the judged tree: HEAD is pinned before the
40295
+ * clean check and the commit is built over its tree with `commit-tree`, so no
40296
+ * hook runs and none can add to it. A HEAD that moved fails the guarded
40297
+ * `update-ref`. `commit-tree` ignores `commit.gpgSign`, so it is read here.
40298
+ */
40299
+ const commitClean = (message) => [
40300
+ `gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
40301
+ `if [ -n "$(git status --porcelain --untracked-files=all --ignore-submodules=none)" ]; then`,
40302
+ ` printf '%s\\n' ${shellQuote$1(CHANGED_SINCE_LAND)} >&2`,
40303
+ ` exit 1`,
40304
+ `fi`,
40305
+ `gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
40306
+ `if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
40307
+ `gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m ${shellQuote$1(message)}) || exit 1`,
40308
+ `git update-ref -m ${shellQuote$1(`commit: ${message}`)} HEAD "$gtd_tip" "$gtd_head" || exit 1`
40309
+ ].join("\n");
40133
40310
  const mixedResetTo = (ref) => `git reset --mixed ${shellQuote$1(ref)}`;
40134
40311
  const hardResetTo = (ref) => `git reset --hard ${shellQuote$1(ref)}`;
40135
40312
  /**
@@ -40155,7 +40332,7 @@ const keepTurn = (refPrefix, message) => {
40155
40332
  `gtd_kept=$(git commit-tree "$gtd_tree" -p "$gtd_head" -m ${shellQuote$1(message)}) || exit 1`,
40156
40333
  `git update-ref ${keptRef} "$gtd_kept" || exit 1`,
40157
40334
  `gtd_marker=$(printf '%s\\nGtd-Kept: %s' ${shellQuote$1(message)} "$gtd_kept")`,
40158
- `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`,
40335
+ `if ! gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m "$gtd_marker") || ! git update-ref -m ${shellQuote$1(`commit: ${message}`)} HEAD "$gtd_tip" "$gtd_head"; then`,
40159
40336
  ` git reset -q && git update-ref -d ${keptRef}`,
40160
40337
  ` exit 1`,
40161
40338
  `fi`,
@@ -40193,6 +40370,7 @@ const renderOutcome = (outcome) => {
40193
40370
  const renderGitWrite = (write) => {
40194
40371
  switch (write.kind) {
40195
40372
  case "commitAll": return commitAll(write.message);
40373
+ case "commitClean": return commitClean(write.message);
40196
40374
  case "updateRef": return updateRef(write.ref, write.target);
40197
40375
  case "keepTurn": return keepTurn(write.ref, write.message);
40198
40376
  }
@@ -40676,6 +40854,7 @@ const runHistoryShowCommand = (ref, json, out) => gen(function* () {
40676
40854
  for (const step of trail.steps) {
40677
40855
  lines.push(`${seven(step.hash)} ${step.subject}`);
40678
40856
  for (const v of step.verdicts) lines.push(` judge ${v.id} = ${v.answer} (p=${v.p})`);
40857
+ if (step.approval !== void 0) lines.push(` approved ${step.approval.step} by ${step.approval.name} <${step.approval.email}>`);
40679
40858
  }
40680
40859
  lines.push(`ended (${trail.ended})`);
40681
40860
  out.write(`${lines.join("\n")}\n`);
@@ -41215,7 +41394,8 @@ const gatherBeatDocument = (rest, rendered) => gen(function* () {
41215
41394
  changes: computeStatusChanges(rest.changes),
41216
41395
  next: yield* computeNextMatch(rest),
41217
41396
  cost: rest.cost.total,
41218
- costByModel: rest.cost.byModel
41397
+ costByModel: rest.cost.byModel,
41398
+ approvals: rest.run.approvals
41219
41399
  }));
41220
41400
  });
41221
41401
  /**
@@ -41500,6 +41680,12 @@ const FLAGS = [
41500
41680
  if (/[\r\n]/.test(value)) return left(`gtd: --var ${name} must be a single-line value`);
41501
41681
  if (seen.has(name)) return left(`gtd: --var ${name} specified more than once`);
41502
41682
  seen.add(name);
41683
+ if (name.startsWith("flag_")) {
41684
+ const pinned = flagSettingValue(value);
41685
+ if (pinned === void 0) return left(`gtd: --var ${name} must be 1, 0, true or false`);
41686
+ vars[name] = pinned;
41687
+ continue;
41688
+ }
41503
41689
  vars[name] = value;
41504
41690
  }
41505
41691
  return right(vars);
@@ -41906,11 +42092,11 @@ const COMMAND_ROWS = [
41906
42092
  arity: { name: "ref" },
41907
42093
  details: [
41908
42094
  "Replay one kept process under today's config and print its",
41909
- "steps and judge verdicts. <ref> is a full ref, an entry hash",
41910
- "(or unique prefix) or a branch name. Refuses (exit 1) when a",
41911
- "config file differs from its content at the ref's tip. A",
41912
- "version mismatch only warns on stderr. --json prints the",
41913
- "trail as one document; writes nothing"
42095
+ "steps, judge verdicts and approvals. <ref> is a full ref, an",
42096
+ "entry hash (or unique prefix) or a branch name. Refuses",
42097
+ "(exit 1) when a config file differs from its content at the",
42098
+ "ref's tip. A version mismatch only warns on stderr. --json",
42099
+ "prints the trail as one document; writes nothing"
41914
42100
  ]
41915
42101
  },
41916
42102
  {
@@ -42337,7 +42523,7 @@ const runCli = (argv, io) => {
42337
42523
  return flatMap$4(Host, ({ env }) => try_({
42338
42524
  try: () => resolveFlags(env, io.flags),
42339
42525
  catch: (e) => new GtdError(e.message)
42340
- })).pipe(flatMap$4(() => runCommand(plan.command, plan.json, out)), map$4((code) => {
42526
+ })).pipe(flatMap$4((flags) => runCommand(plan.command, plan.json, out).pipe(provideService(StartFlags, flagSettings(flags)))), map$4((code) => {
42341
42527
  out.flush();
42342
42528
  io.exit(code);
42343
42529
  }), provide$1(io.layers(plan.verbose)), sandbox, catchAll((cause) => isInterruptedOnly(cause) ? failCause$2(cause) : report(io, plan.json.kind !== "off")(cause)));