@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +22 -1
- package/dist/chunks/{Schema-CyImoHTj.mjs → Schema-DO5ttJaI.mjs} +1 -1
- package/dist/chunks/{reserved-BPdVZkgv.mjs → reserved-DM5W_LAt.mjs} +64 -3
- package/dist/chunks/{tail-iLAMoigl.mjs → tail-6gvLXXcg.mjs} +1 -1
- package/dist/chunks/{wire-BY1S-obW.mjs → wire-C12dahrH.mjs} +6 -4
- package/dist/flows.mjs +27 -2
- package/dist/gtd.bundle.mjs +218 -32
- package/dist/testkit.mjs +358 -37
- package/dist/wire.mjs +1 -1
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +140 -64
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +2 -2
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +346 -107
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +49 -1
- package/src/cli/Cli.ts +25 -8
- package/src/cli/History.ts +5 -0
- package/src/cli/Program.ts +1 -0
- package/src/edge/Edge.test.ts +205 -1
- package/src/edge/Edge.ts +105 -12
- package/src/emit/GitScript.test.ts +39 -3
- package/src/emit/GitScript.ts +25 -1
- package/src/emit/index.ts +2 -0
- package/src/flags/index.test.ts +14 -1
- package/src/flags/index.ts +14 -3
- package/src/flows/helpers.ts +34 -2
- 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/judgeDiffShape.test.ts +43 -0
- package/src/replay/seamGate.test.ts +62 -0
- package/src/step/LandStep.ts +1 -0
- package/src/step/planStart.ts +3 -2
- 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/testkit/TestRepo.ts +3 -0
- package/src/testkit/steps.ts +18 -0
- 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/src/workflow/index.ts +8 -1
- package/src/workflow/vars.test.ts +23 -1
- package/src/workflow/vars.ts +24 -0
- package/src/workflows/feature/feature.ts +9 -2
- package/src/workflows/feature/index.ts +1 -0
- package/src/workflows/feature/seam-pin.feature +52 -0
- package/src/workflows/feature/seam.feature +387 -0
- package/src/workflows/feature/seam.test.ts +88 -0
- package/src/workflows/feature/seam.ts +110 -0
- package/src/workflows/index.ts +1 -0
- package/src/workflows/shared/options.test.ts +12 -1
- package/src/workflows/shared/options.ts +1 -0
- package/src/workflows/shared/skills.test.ts +20 -2
- package/src/workflows/shared/skills.ts +6 -0
- package/src/workflows/shared/text.test.ts +59 -0
- package/src/workflows/shared/text.ts +86 -0
- package/src/workflows/shared/vars.ts +4 -0
- 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-
|
|
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
|
|
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 =
|
|
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
|
-
...
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
9199
|
-
const
|
|
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
|
|
11084
|
-
"(or unique prefix) or a branch name. Refuses
|
|
11085
|
-
"config file differs from its content at the
|
|
11086
|
-
"version mismatch only warns on stderr. --json
|
|
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
|
-
|
|
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.
|
|
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");
|