@pmelab/gtd 27.3.0 → 27.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +14 -1
- package/dist/chunks/{wire-BY1S-obW.mjs → wire-C12dahrH.mjs} +6 -4
- package/dist/flows.mjs +24 -1
- package/dist/gtd.bundle.mjs +174 -23
- package/dist/testkit.mjs +315 -27
- package/dist/wire.mjs +1 -1
- package/package.json +1 -1
- package/src/cli/Cli.ts +5 -5
- package/src/cli/History.ts +5 -0
- package/src/cli/Program.ts +1 -0
- package/src/edge/Edge.test.ts +160 -0
- package/src/edge/Edge.ts +90 -8
- package/src/emit/GitScript.test.ts +39 -3
- package/src/emit/GitScript.ts +25 -1
- package/src/emit/index.ts +2 -0
- package/src/flows/helpers.ts +28 -0
- package/src/flows/runtime.ts +7 -0
- package/src/install/Install.ts +2 -1
- package/src/platform/Git.test.ts +25 -1
- package/src/platform/Git.ts +30 -0
- package/src/platform/index.ts +1 -0
- package/src/replay/Format.test.ts +16 -1
- package/src/replay/Replay.test.ts +91 -0
- package/src/replay/Replay.ts +22 -2
- package/src/replay/Trailers.test.ts +110 -1
- package/src/replay/Trailers.ts +37 -1
- package/src/replay/index.ts +2 -0
- package/src/replay/seamGate.test.ts +62 -0
- package/src/step/LandStep.ts +1 -0
- package/src/step/planStep.test.ts +32 -0
- package/src/step/planStep.ts +3 -1
- package/src/testkit/EmittedScriptRecognizer.test.ts +27 -0
- package/src/testkit/EmittedScriptRecognizer.ts +12 -0
- package/src/testkit/FakeGitOperations.ts +5 -0
- package/src/testkit/GitTiers.ts +158 -0
- package/src/testkit/InMemRepo.ts +38 -3
- package/src/wire/BeatDocument.test.ts +25 -0
- package/src/wire/BeatDocument.ts +4 -2
- package/src/wire/BeatPlain.test.ts +1 -0
- package/src/wire/BeatStatus.test.ts +2 -0
- package/src/wire/BeatStatus.ts +13 -2
- package/src/wire/WireSchema.test.ts +10 -0
- package/src/wire/types.ts +8 -0
- package/src/workflow/Workflow.ts +1 -0
package/README.md
CHANGED
|
@@ -581,7 +581,7 @@ kept before it squashes.
|
|
|
581
581
|
|
|
582
582
|
```sh
|
|
583
583
|
gtd history list # one line per kept process
|
|
584
|
-
gtd history show <ref> # replay its steps
|
|
584
|
+
gtd history show <ref> # replay its steps, judge verdicts and approvals
|
|
585
585
|
gtd history fetch | sh # pull every kept ref from origin
|
|
586
586
|
```
|
|
587
587
|
|
|
@@ -601,6 +601,19 @@ of on the branch (see [Kept tips](docs/configuration.md#kept-tips)). Those refs
|
|
|
601
601
|
are not process history: `gtd history` does not list them, and the
|
|
602
602
|
`refs/gtd/history/*` refspecs above neither push nor fetch them.
|
|
603
603
|
|
|
604
|
+
A workflow can rest at an approval gate (`human(name, { approve: true })`; the
|
|
605
|
+
`seamGate` helper does this after a `refactor-first` diff-shape judgment). Only
|
|
606
|
+
a clean `gtd land` approves it, and the commit records who in a
|
|
607
|
+
`Gtd-Approved: {"step":"<step>#<n>","name":"<name>","email":"<email>"}` trailer;
|
|
608
|
+
a landing with any edit is refused (as is a script run after the tree changed —
|
|
609
|
+
revert and land again), and `gtd abandon` is the only other way out. `approve`
|
|
610
|
+
cannot be combined with `mode` or `file` (refused when replay reaches the step).
|
|
611
|
+
Git hooks do not run on the approval commit; `commit.gpgSign` still applies, so
|
|
612
|
+
a failing signer refuses the approval. `gtd next --json` lists the process's
|
|
613
|
+
approvals as `approvals` (`[]` when none), and `gtd history show` prints each as
|
|
614
|
+
`approved <step>#<n> by <name> <<email>>`. See
|
|
615
|
+
[Step options](docs/configuration.md#step-options).
|
|
616
|
+
|
|
604
617
|
## Entrypoints
|
|
605
618
|
|
|
606
619
|
gtd is one package, `@pmelab/gtd`, and every entrypoint shares its version and
|
|
@@ -110,7 +110,7 @@ const demandOf = (input) => {
|
|
|
110
110
|
* instead of failing loudly.
|
|
111
111
|
*/
|
|
112
112
|
const statusOf = (input) => {
|
|
113
|
-
const { rendered, idle, initial, workflow, log, changes, next, cost, costByModel } = input;
|
|
113
|
+
const { rendered, idle, initial, workflow, log, changes, next, cost, costByModel, approvals } = input;
|
|
114
114
|
return {
|
|
115
115
|
idle,
|
|
116
116
|
initial,
|
|
@@ -128,7 +128,8 @@ const statusOf = (input) => {
|
|
|
128
128
|
next,
|
|
129
129
|
cost,
|
|
130
130
|
costByModel,
|
|
131
|
-
judge: rendered.judge
|
|
131
|
+
judge: rendered.judge,
|
|
132
|
+
approvals
|
|
132
133
|
};
|
|
133
134
|
};
|
|
134
135
|
//#endregion
|
|
@@ -168,7 +169,7 @@ const dispatchFieldsOf = (demand) => {
|
|
|
168
169
|
}
|
|
169
170
|
};
|
|
170
171
|
/**
|
|
171
|
-
* Flatten a `Demand` plus its `BeatStatus` into the single
|
|
172
|
+
* Flatten a `Demand` plus its `BeatStatus` into the single 25-key document
|
|
172
173
|
* `gtd next --json` emits — the ONLY place the two are joined. `cost`/
|
|
173
174
|
* `costByModel` are omitted together, exactly when no cost was recorded
|
|
174
175
|
* (`cost <= 0`).
|
|
@@ -200,7 +201,8 @@ const beatDocument = (demand, status) => {
|
|
|
200
201
|
costByModel: hasCost ? status.costByModel : void 0,
|
|
201
202
|
judge: status.judge,
|
|
202
203
|
skills,
|
|
203
|
-
access
|
|
204
|
+
access,
|
|
205
|
+
approvals: status.approvals
|
|
204
206
|
};
|
|
205
207
|
};
|
|
206
208
|
const renderBeatJson = (document) => JSON.stringify(document) + "\n";
|
package/dist/flows.mjs
CHANGED
|
@@ -400,6 +400,28 @@ const judgeDiffShape = async (name, options) => {
|
|
|
400
400
|
judgment
|
|
401
401
|
};
|
|
402
402
|
};
|
|
403
|
+
const displayPath = (path) => /[\x00-\x1f\x7f-\x9f]/.test(path) ? JSON.stringify(path) : path;
|
|
404
|
+
const seamReport = (shape) => {
|
|
405
|
+
const unreadable = new Set(shape.unreadable);
|
|
406
|
+
return [
|
|
407
|
+
"Seam report: the judge found the diff's shape needs a refactor first. These paths stray outside the allowlist:",
|
|
408
|
+
"",
|
|
409
|
+
...shape.strays.map((path) => `- ${displayPath(path)}${unreadable.has(path) ? " (unreadable — not judged)" : ""}`),
|
|
410
|
+
"",
|
|
411
|
+
"Landing a clean tree with `gtd land` approves this diff exactly as judged; edits are refused. To stop instead, run `gtd abandon`."
|
|
412
|
+
].join("\n");
|
|
413
|
+
};
|
|
414
|
+
/**
|
|
415
|
+
* Rest at an approval gate `name` showing the seam report — only when
|
|
416
|
+
* `shape.choice` is `refactor-first`. `land` and `reject` reach no step.
|
|
417
|
+
*/
|
|
418
|
+
const seamGate = async (name, shape) => {
|
|
419
|
+
if (shape.choice !== "refactor-first") return;
|
|
420
|
+
await human(name, {
|
|
421
|
+
message: seamReport(shape),
|
|
422
|
+
approve: true
|
|
423
|
+
});
|
|
424
|
+
};
|
|
403
425
|
//#endregion
|
|
404
426
|
//#region src/flows/factory.ts
|
|
405
427
|
const FACTORY = Symbol.for("@pmelab/gtd/workflow-factory");
|
|
@@ -607,6 +629,7 @@ var flows_exports = /* @__PURE__ */ __exportAll({
|
|
|
607
629
|
revertScript: () => revertScript,
|
|
608
630
|
run: () => run,
|
|
609
631
|
scope: () => scope,
|
|
632
|
+
seamGate: () => seamGate,
|
|
610
633
|
sectionBodies: () => sectionBodies,
|
|
611
634
|
sections: () => sections,
|
|
612
635
|
showScript: () => showScript,
|
|
@@ -617,4 +640,4 @@ var flows_exports = /* @__PURE__ */ __exportAll({
|
|
|
617
640
|
wrote: () => wrote
|
|
618
641
|
});
|
|
619
642
|
//#endregion
|
|
620
|
-
export { FLOWS_PROTOCOL, FlowsProtocolError, accessFor, agent, answered, changes, changesSince, check, checkScript, codeChanges, codeThreads, configProblems, configure, defineConfig, defineWorkflow, env, glob, hasThreadFor, head, human, installContext, isFactoryWorkflow, judge, judgeDiffShape, kept, matches, moveScript, numeric, openQuestions, protocolMismatch, quote, read, refuse, removeScript, requireAnswers, requireProgress, requireReplies, requireRevert, requireThreadsClosed, restart, restoreScript, revertScript, run, scope, sectionBodies, sections, showScript, skillsFor, start, flows_exports as t, threads, vars, wrote };
|
|
643
|
+
export { FLOWS_PROTOCOL, FlowsProtocolError, accessFor, agent, answered, changes, changesSince, check, checkScript, codeChanges, codeThreads, configProblems, configure, defineConfig, defineWorkflow, env, glob, hasThreadFor, head, human, installContext, isFactoryWorkflow, judge, judgeDiffShape, kept, matches, moveScript, numeric, openQuestions, protocolMismatch, quote, read, refuse, removeScript, requireAnswers, requireProgress, requireReplies, requireRevert, requireThreadsClosed, restart, restoreScript, revertScript, run, scope, seamGate, sectionBodies, sections, showScript, skillsFor, start, flows_exports as t, threads, vars, wrote };
|
package/dist/gtd.bundle.mjs
CHANGED
|
@@ -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))),
|
|
@@ -21231,7 +21239,8 @@ skills, \`skills\` (an array of the skill names, already trimmed and split —
|
|
|
21231
21239
|
(\`{read, write}\`, each an array of globs or \`null\` for unrestricted). When set: \`model\`, \`memory\`,
|
|
21232
21240
|
\`label\`, \`file\`, \`mode\`, \`edges\`, \`judge\` (the pending judgment's rendered
|
|
21233
21241
|
document). When a cost has been recorded (a prior \`gtd land --cost=<n>\`):
|
|
21234
|
-
\`cost\`, \`costByModel\`.
|
|
21242
|
+
\`cost\`, \`costByModel\`. Always \`approvals\` (an array of
|
|
21243
|
+
\`{step, name, email, commit}\`, \`[]\` when none).
|
|
21235
21244
|
|
|
21236
21245
|
\`gtd land\` fields, in fixed order: \`schema\`, \`script\` (the POSIX sh to run), \`settled\`
|
|
21237
21246
|
(true for a no-op at a \`script\` rest — stop immediately, nothing more to
|
|
@@ -22013,6 +22022,28 @@ const judgeDiffShape = async (name, options) => {
|
|
|
22013
22022
|
judgment
|
|
22014
22023
|
};
|
|
22015
22024
|
};
|
|
22025
|
+
const displayPath = (path) => /[\x00-\x1f\x7f-\x9f]/.test(path) ? JSON.stringify(path) : path;
|
|
22026
|
+
const seamReport = (shape) => {
|
|
22027
|
+
const unreadable = new Set(shape.unreadable);
|
|
22028
|
+
return [
|
|
22029
|
+
"Seam report: the judge found the diff's shape needs a refactor first. These paths stray outside the allowlist:",
|
|
22030
|
+
"",
|
|
22031
|
+
...shape.strays.map((path) => `- ${displayPath(path)}${unreadable.has(path) ? " (unreadable — not judged)" : ""}`),
|
|
22032
|
+
"",
|
|
22033
|
+
"Landing a clean tree with `gtd land` approves this diff exactly as judged; edits are refused. To stop instead, run `gtd abandon`."
|
|
22034
|
+
].join("\n");
|
|
22035
|
+
};
|
|
22036
|
+
/**
|
|
22037
|
+
* Rest at an approval gate `name` showing the seam report — only when
|
|
22038
|
+
* `shape.choice` is `refactor-first`. `land` and `reject` reach no step.
|
|
22039
|
+
*/
|
|
22040
|
+
const seamGate = async (name, shape) => {
|
|
22041
|
+
if (shape.choice !== "refactor-first") return;
|
|
22042
|
+
await human(name, {
|
|
22043
|
+
message: seamReport(shape),
|
|
22044
|
+
approve: true
|
|
22045
|
+
});
|
|
22046
|
+
};
|
|
22016
22047
|
//#endregion
|
|
22017
22048
|
//#region src/flows/factory.ts
|
|
22018
22049
|
const FACTORY = Symbol.for("@pmelab/gtd/workflow-factory");
|
|
@@ -22220,6 +22251,7 @@ var flows_exports = /* @__PURE__ */ __exportAll({
|
|
|
22220
22251
|
revertScript: () => revertScript,
|
|
22221
22252
|
run: () => run$3,
|
|
22222
22253
|
scope: () => scope,
|
|
22254
|
+
seamGate: () => seamGate,
|
|
22223
22255
|
sectionBodies: () => sectionBodies,
|
|
22224
22256
|
sections: () => sections,
|
|
22225
22257
|
showScript: () => showScript,
|
|
@@ -37282,7 +37314,7 @@ var init_Demand = __esmMin((() => {
|
|
|
37282
37314
|
var statusOf;
|
|
37283
37315
|
var init_BeatStatus = __esmMin((() => {
|
|
37284
37316
|
statusOf = (input) => {
|
|
37285
|
-
const { rendered, idle, initial, workflow, log, changes, next, cost, costByModel } = input;
|
|
37317
|
+
const { rendered, idle, initial, workflow, log, changes, next, cost, costByModel, approvals } = input;
|
|
37286
37318
|
return {
|
|
37287
37319
|
idle,
|
|
37288
37320
|
initial,
|
|
@@ -37300,7 +37332,8 @@ var init_BeatStatus = __esmMin((() => {
|
|
|
37300
37332
|
next,
|
|
37301
37333
|
cost,
|
|
37302
37334
|
costByModel,
|
|
37303
|
-
judge: rendered.judge
|
|
37335
|
+
judge: rendered.judge,
|
|
37336
|
+
approvals
|
|
37304
37337
|
};
|
|
37305
37338
|
};
|
|
37306
37339
|
}));
|
|
@@ -37360,7 +37393,8 @@ var init_BeatDocument = __esmMin((() => {
|
|
|
37360
37393
|
costByModel: hasCost ? status.costByModel : void 0,
|
|
37361
37394
|
judge: status.judge,
|
|
37362
37395
|
skills,
|
|
37363
|
-
access
|
|
37396
|
+
access,
|
|
37397
|
+
approvals: status.approvals
|
|
37364
37398
|
};
|
|
37365
37399
|
};
|
|
37366
37400
|
renderBeatJson = (document) => JSON.stringify(document) + "\n";
|
|
@@ -37689,12 +37723,38 @@ const TRAILER_RE = /^(Gtd-[A-Za-z-]+):[ \t]*(.*?)[ \t]*$/gm;
|
|
|
37689
37723
|
const COST_RE = /^([0-9]+(?:\.[0-9]+)?)(?:[ \t]+(.+))?$/;
|
|
37690
37724
|
const VAR_RE = /^([^=\s]+)=(.*)$/;
|
|
37691
37725
|
const CONFIG_RE = /^([0-9a-f]{16}) (.+)$/;
|
|
37692
|
-
const CONTROL_CHARACTER$1 = /[\x00-\x1f\x7f-\x9f]/;
|
|
37726
|
+
const CONTROL_CHARACTER$1 = /[\x00-\x1f\x7f-\x9f\u2028\u2029]/;
|
|
37693
37727
|
const encodePath = (path) => path.trim() !== path || path.startsWith("\"") || CONTROL_CHARACTER$1.test(path) ? JSON.stringify(path) : path;
|
|
37694
37728
|
const decodePath = (raw) => {
|
|
37695
37729
|
const path = raw.startsWith("\"") ? parseJson$1(raw) : raw;
|
|
37696
37730
|
return typeof path === "string" && path !== "" && !CONTROL_CHARACTER$1.test(path) ? path : void 0;
|
|
37697
37731
|
};
|
|
37732
|
+
const decodeApproval = (raw) => {
|
|
37733
|
+
const parsed = parseJson$1(raw);
|
|
37734
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
37735
|
+
const { step, name, email } = parsed;
|
|
37736
|
+
if (typeof step !== "string" || typeof name !== "string" || typeof email !== "string") return void 0;
|
|
37737
|
+
if ([
|
|
37738
|
+
step,
|
|
37739
|
+
name,
|
|
37740
|
+
email
|
|
37741
|
+
].some((field) => CONTROL_CHARACTER$1.test(field))) return void 0;
|
|
37742
|
+
const id = parseStepId(step);
|
|
37743
|
+
return id === void 0 ? void 0 : {
|
|
37744
|
+
step: id,
|
|
37745
|
+
name,
|
|
37746
|
+
email
|
|
37747
|
+
};
|
|
37748
|
+
};
|
|
37749
|
+
const encodeApproval = ({ step, name, email }) => JSON.stringify({
|
|
37750
|
+
step: formatStepId(step),
|
|
37751
|
+
name,
|
|
37752
|
+
email
|
|
37753
|
+
});
|
|
37754
|
+
const isRecordableApproval = (approval) => {
|
|
37755
|
+
const back = decodeApproval(encodeApproval(approval));
|
|
37756
|
+
return back !== void 0 && encodeApproval(back) === encodeApproval(approval);
|
|
37757
|
+
};
|
|
37698
37758
|
const FORMAT_RE = /^[1-9][0-9]*$/;
|
|
37699
37759
|
const HASH_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
37700
37760
|
const TRAILER_READERS = {
|
|
@@ -37729,6 +37789,9 @@ const TRAILER_READERS = {
|
|
|
37729
37789
|
"Gtd-Kept": (value, into) => {
|
|
37730
37790
|
if (HASH_RE.test(value)) into.kept ??= value;
|
|
37731
37791
|
},
|
|
37792
|
+
"Gtd-Approved": (value, into) => {
|
|
37793
|
+
into.approved ??= decodeApproval(value);
|
|
37794
|
+
},
|
|
37732
37795
|
"Gtd-Format": (value, into) => {
|
|
37733
37796
|
if (FORMAT_RE.test(value)) into.format ??= Number(value);
|
|
37734
37797
|
},
|
|
@@ -37750,6 +37813,7 @@ const parseCommitMessage = (message) => {
|
|
|
37750
37813
|
branch: void 0,
|
|
37751
37814
|
version: void 0,
|
|
37752
37815
|
kept: void 0,
|
|
37816
|
+
approved: void 0,
|
|
37753
37817
|
judge: [],
|
|
37754
37818
|
vars: {},
|
|
37755
37819
|
config: {},
|
|
@@ -37775,6 +37839,7 @@ const formatCommitMessage = (spec) => {
|
|
|
37775
37839
|
for (const [path, hash] of sortedEntries(spec.config)) lines.push(`Gtd-Config: ${hash} ${encodePath(path)}`);
|
|
37776
37840
|
if (spec.cost !== void 0) lines.push(`Gtd-Cost: ${spec.cost.cost}${spec.cost.model !== void 0 ? ` ${spec.cost.model}` : ""}`);
|
|
37777
37841
|
for (const verdict of spec.judge ?? []) lines.push(`Gtd-Judge: ${JSON.stringify(verdict)}`);
|
|
37842
|
+
if (spec.approved !== void 0) lines.push(`Gtd-Approved: ${encodeApproval(spec.approved)}`);
|
|
37778
37843
|
if (spec.branch !== void 0) lines.push(`Gtd-Branch: ${spec.branch}`);
|
|
37779
37844
|
if (spec.version !== void 0) lines.push(`Gtd-Version: ${spec.version}`);
|
|
37780
37845
|
lines.push(`Gtd-Format: 1`);
|
|
@@ -37870,7 +37935,8 @@ const KNOWN_OPTIONS = {
|
|
|
37870
37935
|
human: /* @__PURE__ */ new Set([
|
|
37871
37936
|
...STEERING_OPTIONS,
|
|
37872
37937
|
"message",
|
|
37873
|
-
"acceptClean"
|
|
37938
|
+
"acceptClean",
|
|
37939
|
+
"approve"
|
|
37874
37940
|
]),
|
|
37875
37941
|
run: new Set(STEERING_OPTIONS),
|
|
37876
37942
|
judge: /* @__PURE__ */ new Set([...STEERING_OPTIONS, "message"])
|
|
@@ -37880,6 +37946,13 @@ const unknownOptions = (step) => {
|
|
|
37880
37946
|
if (unknown.length === 0) return void 0;
|
|
37881
37947
|
return `gtd: step "${step.name}": unknown key(s) ${unknown.join(", ")} in ${step.kind}() options`;
|
|
37882
37948
|
};
|
|
37949
|
+
const approveConflict = (step) => {
|
|
37950
|
+
const request = step.request;
|
|
37951
|
+
if (request.kind !== "human" || request.options.approve !== true) return void 0;
|
|
37952
|
+
const keys = ["mode", "file"].filter((key) => request.options[key] !== void 0);
|
|
37953
|
+
if (keys.length === 0) return void 0;
|
|
37954
|
+
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`;
|
|
37955
|
+
};
|
|
37883
37956
|
const replay = async (input) => {
|
|
37884
37957
|
const commits = input.episode.commits.map((c) => ({
|
|
37885
37958
|
...c,
|
|
@@ -38039,12 +38112,21 @@ const replay = async (input) => {
|
|
|
38039
38112
|
kind: "divergence",
|
|
38040
38113
|
message: `gtd: commit ${short(expected.hash)} names "${expected.name}" as the next step, but replay reached "${step.name}" — ${DIVERGED}`
|
|
38041
38114
|
};
|
|
38042
|
-
const error = unknownOptions(step) ?? checkIdentity(step);
|
|
38115
|
+
const error = unknownOptions(step) ?? approveConflict(step) ?? checkIdentity(step);
|
|
38043
38116
|
return error === void 0 ? void 0 : {
|
|
38044
38117
|
kind: "failed",
|
|
38045
38118
|
message: error
|
|
38046
38119
|
};
|
|
38047
38120
|
};
|
|
38121
|
+
const missingApproval = (step, commit, expected) => {
|
|
38122
|
+
if (step.request.kind !== "human" || step.request.options.approve !== true) return void 0;
|
|
38123
|
+
const approved = commit.parsed.approved;
|
|
38124
|
+
if (approved !== void 0 && formatStepId(approved.step) === expected) return void 0;
|
|
38125
|
+
return {
|
|
38126
|
+
kind: "divergence",
|
|
38127
|
+
message: `gtd: commit ${short(commit.hash)} completes approval gate "${expected}" but records no Gtd-Approved for it — ${DIVERGED}`
|
|
38128
|
+
};
|
|
38129
|
+
};
|
|
38048
38130
|
const consumeCommit = (step, commit) => {
|
|
38049
38131
|
const recorded = commit.parsed.step;
|
|
38050
38132
|
const expected = formatStepId(step.id);
|
|
@@ -38055,6 +38137,8 @@ const replay = async (input) => {
|
|
|
38055
38137
|
message: `gtd: commit ${short(commit.hash)} ${what}, but replay expected "${expected}" — ${DIVERGED}`
|
|
38056
38138
|
});
|
|
38057
38139
|
}
|
|
38140
|
+
const unapproved = missingApproval(step, commit, expected);
|
|
38141
|
+
if (unapproved !== void 0) return finish(unapproved);
|
|
38058
38142
|
cursor++;
|
|
38059
38143
|
advance(commit);
|
|
38060
38144
|
if (commit.parsed.kept !== void 0) if (commit.kept === void 0) keptTips.delete(step.name);
|
|
@@ -39169,6 +39253,9 @@ const planStep = (snapshot, opts = {}) => {
|
|
|
39169
39253
|
kind: "keepTurn",
|
|
39170
39254
|
ref: landing.keepTurn,
|
|
39171
39255
|
message
|
|
39256
|
+
} : landing.spec.approved !== void 0 ? {
|
|
39257
|
+
kind: "commitClean",
|
|
39258
|
+
message
|
|
39172
39259
|
} : {
|
|
39173
39260
|
kind: "commitAll",
|
|
39174
39261
|
message
|
|
@@ -39376,6 +39463,12 @@ const pinnedConfigOf = (history) => {
|
|
|
39376
39463
|
};
|
|
39377
39464
|
}
|
|
39378
39465
|
};
|
|
39466
|
+
const approvalEntryOf = (hash, message) => message.approved === void 0 ? [] : [{
|
|
39467
|
+
step: formatStepId(message.approved.step),
|
|
39468
|
+
name: message.approved.name,
|
|
39469
|
+
email: message.approved.email,
|
|
39470
|
+
commit: hash
|
|
39471
|
+
}];
|
|
39379
39472
|
const buildRun = (history, location, closingHash) => {
|
|
39380
39473
|
const processCommits = history.slice(location.processStart);
|
|
39381
39474
|
const parsed = processCommits.map((c) => parseCommitMessage(c.message));
|
|
@@ -39391,6 +39484,7 @@ const buildRun = (history, location, closingHash) => {
|
|
|
39391
39484
|
trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i])),
|
|
39392
39485
|
costEntries: parsed.flatMap(costEntriesOf),
|
|
39393
39486
|
judgeVerdicts: parsed.flatMap((m) => m.judge),
|
|
39487
|
+
approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i])),
|
|
39394
39488
|
pinnedVars: { ...first?.vars },
|
|
39395
39489
|
pinnedConfig: pinnedConfigOf(history.slice(Math.max(location.baseIndex, 0))),
|
|
39396
39490
|
headTurn: headTurnOf(head),
|
|
@@ -39676,7 +39770,8 @@ const messageDef = (common, request, step) => ({
|
|
|
39676
39770
|
...common,
|
|
39677
39771
|
kind: "message",
|
|
39678
39772
|
content: request.options.message ?? request.options.label ?? step.name,
|
|
39679
|
-
...optional$1("acceptClean", request.options.acceptClean)
|
|
39773
|
+
...optional$1("acceptClean", request.options.approve === true || request.options.acceptClean),
|
|
39774
|
+
...optional$1("approve", request.options.approve)
|
|
39680
39775
|
});
|
|
39681
39776
|
const stepDefOf = (step) => {
|
|
39682
39777
|
const request = step.request;
|
|
@@ -39805,6 +39900,12 @@ const setupFor = (config, run, workspace, host, startOverrides = {}) => gen(func
|
|
|
39805
39900
|
}
|
|
39806
39901
|
};
|
|
39807
39902
|
});
|
|
39903
|
+
const pendingChanges = (git, withIndex) => gen(function* () {
|
|
39904
|
+
const worktree = yield* git.changedPaths();
|
|
39905
|
+
if (!withIndex) return worktree;
|
|
39906
|
+
const seen = new Set(worktree.map((e) => e.path));
|
|
39907
|
+
return [...worktree, ...(yield* git.stagedPaths()).filter((e) => !seen.has(e.path))];
|
|
39908
|
+
});
|
|
39808
39909
|
/** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
|
|
39809
39910
|
const restAt = (ref, startOverrides = {}) => gen(function* () {
|
|
39810
39911
|
const git = yield* GitService;
|
|
@@ -39820,7 +39921,7 @@ const restAt = (ref, startOverrides = {}) => gen(function* () {
|
|
|
39820
39921
|
yield* (yield* Narrator).narrate(`rest resolved: ${step.name} (awaits ${step.actor})`);
|
|
39821
39922
|
const stepDef = stepDefOf(step);
|
|
39822
39923
|
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.
|
|
39924
|
+
const changes = ref === void 0 ? (yield* pendingChanges(git, stepDef.approve === true)).map((e) => ({
|
|
39824
39925
|
status: normalizeStatus(e.status),
|
|
39825
39926
|
path: e.path
|
|
39826
39927
|
})) : [];
|
|
@@ -39906,6 +40007,25 @@ const noProcessUnderway = (rest) => rest.run.workflow === void 0 && rest.state =
|
|
|
39906
40007
|
/** `idle` means exactly one thing: no process underway, clean tree. */
|
|
39907
40008
|
const restIsIdle = (rest) => noProcessUnderway(rest) && rest.changes.length === 0;
|
|
39908
40009
|
const isHumanReviewGate = (def) => def.actor === "human" && def.mode === "review";
|
|
40010
|
+
const approvalRefusal = (rest, approver) => {
|
|
40011
|
+
if (rest.stepDef.approve !== true) return void 0;
|
|
40012
|
+
const id = rest.step.id;
|
|
40013
|
+
if (rest.changes.length > 0) return {
|
|
40014
|
+
kind: "refusal",
|
|
40015
|
+
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\``
|
|
40016
|
+
};
|
|
40017
|
+
if (isNone(approver)) return {
|
|
40018
|
+
kind: "refusal",
|
|
40019
|
+
message: `gtd land: approval-gate: ${formatStepId(id)} records who approved it, but git has no author identity — set user.name and user.email`
|
|
40020
|
+
};
|
|
40021
|
+
if (!isRecordableApproval({
|
|
40022
|
+
step: id,
|
|
40023
|
+
...approver.value
|
|
40024
|
+
})) return {
|
|
40025
|
+
kind: "refusal",
|
|
40026
|
+
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)`
|
|
40027
|
+
};
|
|
40028
|
+
};
|
|
39909
40029
|
/** The landing a clean tree decides before any replay, if it decides one. */
|
|
39910
40030
|
const cleanLanding = (rest) => {
|
|
39911
40031
|
if (rest.changes.length > 0) return void 0;
|
|
@@ -39960,7 +40080,11 @@ const closingTrailers = (branch) => ({
|
|
|
39960
40080
|
});
|
|
39961
40081
|
const isSettledNoop = (rest, to) => rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state;
|
|
39962
40082
|
const keptTurn = (rest) => rest.stepDef.keep === true && rest.changes.length > 0 ? { keepTurn: keptRef(rest.run.startHash, rest.state) } : {};
|
|
39963
|
-
const
|
|
40083
|
+
const approvedTrailer = (rest, approver) => isSome(approver) ? { approved: {
|
|
40084
|
+
step: rest.step.id,
|
|
40085
|
+
...approver.value
|
|
40086
|
+
} } : {};
|
|
40087
|
+
const landingTo = (rest, to, branch, approver) => {
|
|
39964
40088
|
if (isSettledNoop(rest, to)) return {
|
|
39965
40089
|
kind: "noop",
|
|
39966
40090
|
settled: true
|
|
@@ -39978,6 +40102,7 @@ const landingTo = (rest, to, branch) => {
|
|
|
39978
40102
|
from: rest.state,
|
|
39979
40103
|
to,
|
|
39980
40104
|
step: rest.step.id,
|
|
40105
|
+
...approvedTrailer(rest, approver),
|
|
39981
40106
|
...starts ? {
|
|
39982
40107
|
vars: rest.vars,
|
|
39983
40108
|
config: rest.config
|
|
@@ -39993,7 +40118,9 @@ const landingTo = (rest, to, branch) => {
|
|
|
39993
40118
|
* replay with the working tree as the pending turn. The target step is
|
|
39994
40119
|
* computed by replaying, never matched.
|
|
39995
40120
|
*/
|
|
39996
|
-
const decideLanding = (rest, verdicts, branch) => gen(function* () {
|
|
40121
|
+
const decideLanding = (rest, verdicts, branch, approver) => gen(function* () {
|
|
40122
|
+
const unapproved = approvalRefusal(rest, approver);
|
|
40123
|
+
if (unapproved !== void 0) return unapproved;
|
|
39997
40124
|
const early = cleanLanding(rest);
|
|
39998
40125
|
if (early !== void 0) return early;
|
|
39999
40126
|
if (rest.stepDef.kind === "prompt" && rest.stepDef.access?.write != null) {
|
|
@@ -40009,14 +40136,16 @@ const decideLanding = (rest, verdicts, branch) => gen(function* () {
|
|
|
40009
40136
|
}));
|
|
40010
40137
|
const to = yield* landingTarget(outcome, rest);
|
|
40011
40138
|
if (typeof to !== "string") return to;
|
|
40012
|
-
return landingTo(rest, to, branch);
|
|
40139
|
+
return landingTo(rest, to, branch, approver);
|
|
40013
40140
|
});
|
|
40014
40141
|
/** 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)));
|
|
40142
|
+
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
40143
|
/** Every fact the pure landing planner needs, read once: the rest, its steering file, and the landing decision. */
|
|
40017
40144
|
const snapshotFromRest = (rest, verdicts) => gen(function* () {
|
|
40018
|
-
const
|
|
40019
|
-
const
|
|
40145
|
+
const git = yield* GitService;
|
|
40146
|
+
const branch = yield* git.currentBranch();
|
|
40147
|
+
const approver = rest.stepDef.approve === true ? yield* git.authorIdent() : none$4();
|
|
40148
|
+
const landing = yield* decideLanding(rest, verdicts, branch, approver);
|
|
40020
40149
|
return {
|
|
40021
40150
|
stepDef: rest.stepDef,
|
|
40022
40151
|
state: rest.state,
|
|
@@ -40070,7 +40199,8 @@ const historySteps = (run) => {
|
|
|
40070
40199
|
actor: entry.actor,
|
|
40071
40200
|
from: message.parsed?.from,
|
|
40072
40201
|
to: entry.state,
|
|
40073
|
-
verdicts: message.judge
|
|
40202
|
+
verdicts: message.judge,
|
|
40203
|
+
...optional$1("approval", approvalEntryOf(entry.hash, message)[0])
|
|
40074
40204
|
};
|
|
40075
40205
|
});
|
|
40076
40206
|
};
|
|
@@ -40128,8 +40258,26 @@ const commitAllowEmpty = (message) => {
|
|
|
40128
40258
|
`fi`
|
|
40129
40259
|
].join("\n");
|
|
40130
40260
|
};
|
|
40261
|
+
const CHANGED_SINCE_LAND = "gtd: the working tree changed since `gtd land` — revert the edits and run `gtd land` again to approve";
|
|
40131
40262
|
/** `git add -A || exit 1` (an `&&` left side would not abort under `set -e`) then `commitAllowEmpty`. */
|
|
40132
40263
|
const commitAll = (message) => `git add -A || exit 1\n${commitAllowEmpty(message)}`;
|
|
40264
|
+
/**
|
|
40265
|
+
* An approval records exactly the judged tree: HEAD is pinned before the
|
|
40266
|
+
* clean check and the commit is built over its tree with `commit-tree`, so no
|
|
40267
|
+
* hook runs and none can add to it. A HEAD that moved fails the guarded
|
|
40268
|
+
* `update-ref`. `commit-tree` ignores `commit.gpgSign`, so it is read here.
|
|
40269
|
+
*/
|
|
40270
|
+
const commitClean = (message) => [
|
|
40271
|
+
`gtd_head=$(git rev-parse --verify HEAD) || exit 1`,
|
|
40272
|
+
`if [ -n "$(git status --porcelain --untracked-files=all --ignore-submodules=none)" ]; then`,
|
|
40273
|
+
` printf '%s\\n' ${shellQuote$1(CHANGED_SINCE_LAND)} >&2`,
|
|
40274
|
+
` exit 1`,
|
|
40275
|
+
`fi`,
|
|
40276
|
+
`gtd_sign_cfg=$(git config --type=bool --default=false commit.gpgSign) || exit 1`,
|
|
40277
|
+
`if [ "$gtd_sign_cfg" = true ]; then gtd_sign=-S; else gtd_sign=--no-gpg-sign; fi`,
|
|
40278
|
+
`gtd_tip=$(git commit-tree "$gtd_sign" "$gtd_head^{tree}" -p "$gtd_head" -m ${shellQuote$1(message)}) || exit 1`,
|
|
40279
|
+
`git update-ref -m ${shellQuote$1(`commit: ${message}`)} HEAD "$gtd_tip" "$gtd_head" || exit 1`
|
|
40280
|
+
].join("\n");
|
|
40133
40281
|
const mixedResetTo = (ref) => `git reset --mixed ${shellQuote$1(ref)}`;
|
|
40134
40282
|
const hardResetTo = (ref) => `git reset --hard ${shellQuote$1(ref)}`;
|
|
40135
40283
|
/**
|
|
@@ -40155,7 +40303,7 @@ const keepTurn = (refPrefix, message) => {
|
|
|
40155
40303
|
`gtd_kept=$(git commit-tree "$gtd_tree" -p "$gtd_head" -m ${shellQuote$1(message)}) || exit 1`,
|
|
40156
40304
|
`git update-ref ${keptRef} "$gtd_kept" || exit 1`,
|
|
40157
40305
|
`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`,
|
|
40306
|
+
`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
40307
|
` git reset -q && git update-ref -d ${keptRef}`,
|
|
40160
40308
|
` exit 1`,
|
|
40161
40309
|
`fi`,
|
|
@@ -40193,6 +40341,7 @@ const renderOutcome = (outcome) => {
|
|
|
40193
40341
|
const renderGitWrite = (write) => {
|
|
40194
40342
|
switch (write.kind) {
|
|
40195
40343
|
case "commitAll": return commitAll(write.message);
|
|
40344
|
+
case "commitClean": return commitClean(write.message);
|
|
40196
40345
|
case "updateRef": return updateRef(write.ref, write.target);
|
|
40197
40346
|
case "keepTurn": return keepTurn(write.ref, write.message);
|
|
40198
40347
|
}
|
|
@@ -40676,6 +40825,7 @@ const runHistoryShowCommand = (ref, json, out) => gen(function* () {
|
|
|
40676
40825
|
for (const step of trail.steps) {
|
|
40677
40826
|
lines.push(`${seven(step.hash)} ${step.subject}`);
|
|
40678
40827
|
for (const v of step.verdicts) lines.push(` judge ${v.id} = ${v.answer} (p=${v.p})`);
|
|
40828
|
+
if (step.approval !== void 0) lines.push(` approved ${step.approval.step} by ${step.approval.name} <${step.approval.email}>`);
|
|
40679
40829
|
}
|
|
40680
40830
|
lines.push(`ended (${trail.ended})`);
|
|
40681
40831
|
out.write(`${lines.join("\n")}\n`);
|
|
@@ -41215,7 +41365,8 @@ const gatherBeatDocument = (rest, rendered) => gen(function* () {
|
|
|
41215
41365
|
changes: computeStatusChanges(rest.changes),
|
|
41216
41366
|
next: yield* computeNextMatch(rest),
|
|
41217
41367
|
cost: rest.cost.total,
|
|
41218
|
-
costByModel: rest.cost.byModel
|
|
41368
|
+
costByModel: rest.cost.byModel,
|
|
41369
|
+
approvals: rest.run.approvals
|
|
41219
41370
|
}));
|
|
41220
41371
|
});
|
|
41221
41372
|
/**
|
|
@@ -41906,11 +42057,11 @@ const COMMAND_ROWS = [
|
|
|
41906
42057
|
arity: { name: "ref" },
|
|
41907
42058
|
details: [
|
|
41908
42059
|
"Replay one kept process under today's config and print its",
|
|
41909
|
-
"steps
|
|
41910
|
-
"(or unique prefix) or a branch name. Refuses
|
|
41911
|
-
"config file differs from its content at the
|
|
41912
|
-
"version mismatch only warns on stderr. --json
|
|
41913
|
-
"trail as one document; writes nothing"
|
|
42060
|
+
"steps, judge verdicts and approvals. <ref> is a full ref, an",
|
|
42061
|
+
"entry hash (or unique prefix) or a branch name. Refuses",
|
|
42062
|
+
"(exit 1) when a config file differs from its content at the",
|
|
42063
|
+
"ref's tip. A version mismatch only warns on stderr. --json",
|
|
42064
|
+
"prints the trail as one document; writes nothing"
|
|
41914
42065
|
]
|
|
41915
42066
|
},
|
|
41916
42067
|
{
|