@pmelab/gtd 29.2.7 → 29.3.1
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 +17 -12
- package/claude/hooks/ship.ts +36 -25
- package/dist/chunks/{tail-BOJYJctg.mjs → tail-CLyE5VFR.mjs} +32 -11
- package/dist/workflows/cleanup.mjs +1 -1
- package/dist/workflows/feature.mjs +4 -6
- package/dist/workflows/fix.mjs +1 -1
- package/dist/workflows/review.mjs +1 -1
- package/dist/workflows.mjs +34 -15
- package/package.json +1 -1
- package/src/workflows/feature/feature.ts +2 -6
- package/src/workflows/feature/index.ts +7 -1
- package/src/workflows/feature/review.test.ts +26 -5
- package/src/workflows/feature/review.ts +8 -39
- package/src/workflows/index.ts +8 -2
- package/src/workflows/shared/tail.test.ts +131 -16
- package/src/workflows/shared/tail.ts +76 -10
package/README.md
CHANGED
|
@@ -341,16 +341,20 @@ wait for the next rest. When the process finishes, Claude offers to squash it
|
|
|
341
341
|
and open its pull request(s) (or run `/gtd ship`; `GTD_SHIP_MODE` picks one
|
|
342
342
|
stacked pull request per slice or one commit per slice, see the
|
|
343
343
|
[driver doc](docs/driver.md)); a process started for an issue ships with
|
|
344
|
-
`Closes #N` on its top pull request; a
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
344
|
+
`Closes #N` on its top pull request; a later process on an already-stacked
|
|
345
|
+
branch, such as review fixes, lands one commit per slice on the top pull request
|
|
346
|
+
and keeps the stack; a closed pull request whose branch was deleted from origin
|
|
347
|
+
is left behind and a new one opens; re-running `/gtd ship` after a lower stacked
|
|
348
|
+
pull request merged restacks the rest (it refuses, with nothing changed, when
|
|
349
|
+
that pull request merged anywhere but the default branch (unless into a lower
|
|
350
|
+
slice branch before that one merged into it), a slice branch is checked out in
|
|
351
|
+
another worktree, an unrelated local branch sits inside the rebased commits, or
|
|
352
|
+
a slice's change is already upstream; a failed rebase is rolled back; to tear a
|
|
353
|
+
stack down from its worktree, delete the top branch with
|
|
354
|
+
`git push origin --delete`, since `gh pr close --delete-branch` cannot). A
|
|
355
|
+
process can change hands at any gate: ask Claude to hand it off (or run
|
|
356
|
+
`/gtd throw @dev`) and it opens a draft pull request assigned to them, and
|
|
357
|
+
`/gtd catch <pr>` picks it up exactly where it waits, so whoever wrote the
|
|
354
358
|
requirements can hand the architecture to someone else. Ship, hand-off and throw
|
|
355
359
|
answer at once and run in the background, with progress on the status line; they
|
|
356
360
|
report by waking Claude, a toast, a transcript line and a push notification, and
|
|
@@ -607,8 +611,9 @@ means `high`. Each lap lands a `Gtd-Mark: risk-tier=<low|high>` trailer and a
|
|
|
607
611
|
lap's only. Its "needs a human reviewer" line for `high` is advisory, not a
|
|
608
612
|
gate.
|
|
609
613
|
|
|
610
|
-
A `gtd.config.ts` call to `buildTail` that fits neither call form is
|
|
611
|
-
|
|
614
|
+
A `gtd.config.ts` call to `buildTail` or `tail` that fits neither call form is
|
|
615
|
+
rejected; `tail`'s positional form is deprecated for `tail(finish, options?)`.
|
|
616
|
+
See [docs/configuration.md](docs/configuration.md).
|
|
612
617
|
|
|
613
618
|
### Rolling out and cleaning up release flags
|
|
614
619
|
|
package/claude/hooks/ship.ts
CHANGED
|
@@ -495,8 +495,7 @@ async function squash(
|
|
|
495
495
|
if (!msgs.ok) return msgs
|
|
496
496
|
const { fulls, history } = msgs
|
|
497
497
|
const warn = () => (mode === "commits" && many ? rebaseWarning(io) : undefined)
|
|
498
|
-
if (resume?.links)
|
|
499
|
-
return squashOntoStack(io, git, ok, groups[0]!, fulls[0]!, history, resume, isDry, tip)
|
|
498
|
+
if (resume?.links) return squashOntoStack(io, git, ok, groups, fulls, history, resume, isDry, tip)
|
|
500
499
|
if (isDry)
|
|
501
500
|
return dryRun(
|
|
502
501
|
groups,
|
|
@@ -552,18 +551,21 @@ async function stackReads(
|
|
|
552
551
|
mode: ShipMode,
|
|
553
552
|
): Promise<Resumed | string | undefined> {
|
|
554
553
|
if (mode !== "stack") return undefined
|
|
555
|
-
if (groups.length > 1)
|
|
554
|
+
if (groups.length > 1)
|
|
555
|
+
return await stackPrecheck(io, git, ok, branch, baseBranch, groups, base, isDry)
|
|
556
556
|
return resumable(io, git, ok, branch, baseBranch, isDry, base)
|
|
557
557
|
}
|
|
558
558
|
|
|
559
|
-
//
|
|
560
|
-
// the
|
|
559
|
+
// A process on a recorded stack — a re-ship, or a follow-up such as review fixes —
|
|
560
|
+
// lands its slice commits on the top branch, so they join the top pull request.
|
|
561
|
+
// The record, including the recorded top the missing-branch fallbacks and the
|
|
562
|
+
// restack read, stays.
|
|
561
563
|
async function squashOntoStack(
|
|
562
564
|
io: ShipIo,
|
|
563
565
|
git: Git,
|
|
564
566
|
ok: Ok,
|
|
565
|
-
|
|
566
|
-
|
|
567
|
+
groups: Group[],
|
|
568
|
+
fulls: string[],
|
|
567
569
|
history: string | undefined,
|
|
568
570
|
resume: Resumed,
|
|
569
571
|
isDry: boolean,
|
|
@@ -572,16 +574,16 @@ async function squashOntoStack(
|
|
|
572
574
|
const links = resume.links!
|
|
573
575
|
const top = links.length - 1
|
|
574
576
|
if (isDry) {
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
messages,
|
|
579
|
-
restack: resume.restack,
|
|
580
|
-
}
|
|
577
|
+
// the follow-up's slice names are not stack slices: no [name] headers
|
|
578
|
+
const messages = links.map((_, i) => (i === top ? fulls.join("\n\n") : ""))
|
|
579
|
+
return { ok: true as const, text: "", history, stack: links, messages, restack: resume.restack }
|
|
581
580
|
}
|
|
582
581
|
const pushed = await pushHistory(io, history)
|
|
583
582
|
if (pushed) return fail(pushed)
|
|
584
|
-
const one =
|
|
583
|
+
const one =
|
|
584
|
+
groups.length === 1
|
|
585
|
+
? await commitSquash(io, git, ok, groups[0]!.base, fulls[0]!)
|
|
586
|
+
: await commitChain(io, git, ok, groups, fulls)
|
|
585
587
|
if (!one.ok) return one
|
|
586
588
|
const failed = await resume.revive?.()
|
|
587
589
|
if (failed) return fail(failed)
|
|
@@ -709,24 +711,30 @@ async function takenSlice(git: Git, ok: Ok, branch: string, groups: Group[], dea
|
|
|
709
711
|
return undefined
|
|
710
712
|
}
|
|
711
713
|
|
|
712
|
-
|
|
714
|
+
// A live record takes the process onto its top branch; a dead one (no links)
|
|
715
|
+
// leaves the way open for a fresh stack.
|
|
716
|
+
async function liveRecord(
|
|
713
717
|
io: ShipIo,
|
|
714
718
|
git: Git,
|
|
715
719
|
ok: Ok,
|
|
716
720
|
branch: string,
|
|
717
721
|
baseBranch: string,
|
|
718
722
|
recorded: string[],
|
|
719
|
-
|
|
720
|
-
|
|
723
|
+
base: string,
|
|
724
|
+
isDry: boolean,
|
|
725
|
+
): Promise<Resumed | string> {
|
|
726
|
+
// The staleness test a resume uses, without the restack's origin and worktree
|
|
727
|
+
// reads: a dead record is not a live stack, and its dropped-record line is
|
|
728
|
+
// silenced since a fresh stack replaces it.
|
|
721
729
|
const quiet = { ...io, log: () => {} }
|
|
722
730
|
const found = await resumable(quiet, git, ok, branch, baseBranch, true, "HEAD", false)
|
|
723
731
|
// resumable refuses (a missing branch, a closed slice PR) before it tests the
|
|
724
732
|
// chain; only a record that still chains to HEAD may refuse a fresh stack.
|
|
725
|
-
if (typeof found === "string")
|
|
726
|
-
|
|
727
|
-
if (!found.
|
|
728
|
-
|
|
729
|
-
return
|
|
733
|
+
if (typeof found === "string") return (await chainsToHead(git, ok, branch, recorded)) ? found : {}
|
|
734
|
+
// `forget` with links is a live stack a restack ends, not a dead record.
|
|
735
|
+
if (!found.links) return {}
|
|
736
|
+
// Live: any refusal of the full resume (origin, worktrees) stands.
|
|
737
|
+
return resumable(io, git, ok, branch, baseBranch, isDry, base)
|
|
730
738
|
}
|
|
731
739
|
|
|
732
740
|
async function chainsToHead(git: Git, ok: Ok, branch: string, recorded: string[]) {
|
|
@@ -760,11 +768,14 @@ async function stackPrecheck(
|
|
|
760
768
|
branch: string,
|
|
761
769
|
baseBranch: string,
|
|
762
770
|
groups: Group[],
|
|
763
|
-
|
|
771
|
+
base: string,
|
|
772
|
+
isDry: boolean,
|
|
773
|
+
): Promise<Resumed | string | undefined> {
|
|
764
774
|
const recorded = names(await git("config", "--local", "--get", STACK_KEY(branch)))
|
|
765
775
|
if (recorded.length) {
|
|
766
|
-
const
|
|
767
|
-
|
|
776
|
+
const live = await liveRecord(io, git, ok, branch, baseBranch, recorded, base, isDry)
|
|
777
|
+
// Without links the squash builds a fresh stack, so the collision checks still run.
|
|
778
|
+
if (typeof live === "string" || live?.links) return live
|
|
768
779
|
}
|
|
769
780
|
const retired = names(await git("config", "--local", "--get", RETIRED_KEY(branch)))
|
|
770
781
|
const taken = await takenSlice(git, ok, branch, groups, [...recorded, ...retired])
|
|
@@ -26,13 +26,31 @@ const closeReview = (round) => run("review.closing", removeScript([REVIEW, REVIE
|
|
|
26
26
|
const refusePendingHandOff = () => {
|
|
27
27
|
if (read(".gtd/REVIEW.orig.md") !== void 0) refuse(`gtd: ${REVIEW_ORIG} is a pending hand-off — run \`gtd door fix\`, or remove ${REVIEW} and ${REVIEW_ORIG}.`);
|
|
28
28
|
};
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
29
|
+
function tail(first, second, ...rest) {
|
|
30
|
+
const form = tailForm(first, second, rest);
|
|
31
|
+
if (form === void 0) return Promise.reject(/* @__PURE__ */ new Error("tail takes tail(finish, options?) or the deprecated tail(fixFirst, finish, green?, guard?, lenses?, guardRests?, lensBase?, rules?); this call fits neither"));
|
|
32
|
+
const [finish, options] = form;
|
|
33
|
+
return runTail(finish, options);
|
|
34
|
+
}
|
|
35
|
+
const isOptionalObject = (value) => value == null || typeof value === "object" && !Array.isArray(value) && typeof value.then !== "function";
|
|
36
|
+
const tailForm = (first, second, rest) => {
|
|
37
|
+
if (typeof second === "function" && typeof first !== "function") {
|
|
38
|
+
const [green, guard, lenses, guardRests, lensBase, rules] = rest;
|
|
39
|
+
return [second, {
|
|
40
|
+
fixFirst: first,
|
|
41
|
+
green,
|
|
42
|
+
guard,
|
|
43
|
+
lenses,
|
|
44
|
+
guardRests,
|
|
45
|
+
lensBase,
|
|
46
|
+
rules
|
|
47
|
+
}];
|
|
48
|
+
}
|
|
49
|
+
if (typeof first === "function" && isOptionalObject(second) && rest.every((v) => v == null)) return [first, second ?? {}];
|
|
50
|
+
};
|
|
51
|
+
const runTail = (finish, options) => scope("build", async () => {
|
|
52
|
+
const { guard, lenses, lensBase, rules } = options;
|
|
53
|
+
const guardRests = options.guardRests === void 0 ? true : options.guardRests;
|
|
36
54
|
const escalations = { rounds: 0 };
|
|
37
55
|
const fix = () => fixCheck(rules);
|
|
38
56
|
const repair = async () => {
|
|
@@ -45,8 +63,8 @@ const tail = (fixFirst, finish, green = false, guard, lenses, guardRests = true,
|
|
|
45
63
|
rules
|
|
46
64
|
});
|
|
47
65
|
};
|
|
48
|
-
if (fixFirst) await repair();
|
|
49
|
-
else if (!green) await healthy(fix, {
|
|
66
|
+
if (options.fixFirst) await repair();
|
|
67
|
+
else if (!options.green) await healthy(fix, {
|
|
50
68
|
escalations,
|
|
51
69
|
guard,
|
|
52
70
|
guardRests,
|
|
@@ -83,6 +101,9 @@ const handOffReview = (base, escalations, guard) => reviewToHandOff(base, {
|
|
|
83
101
|
deleted: "gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note to hand to fix."
|
|
84
102
|
});
|
|
85
103
|
/** The tail the review and fix workflows run: ends at the hand-off review since `base`. */
|
|
86
|
-
const handOffTail = (fixFirst, base, guard) => tail(
|
|
104
|
+
const handOffTail = (fixFirst, base, guard) => tail((escalations) => handOffReview(base, escalations, guard), {
|
|
105
|
+
fixFirst,
|
|
106
|
+
guard
|
|
107
|
+
});
|
|
87
108
|
//#endregion
|
|
88
|
-
export {
|
|
109
|
+
export { refusePendingHandOff as a, isOptionalObject as i, closeReview as n, tail as o, handOffTail as r, REVIEW_ORIG as t };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { agent, changes, glob, read, refuse, scope, start, vars } from "../flows.mjs";
|
|
2
2
|
import { _t as registryEntry, a as flagShipped, dt as defaults, ft as envDefaults, ht as flagReferences, i as skills, n as summary, r as access, t as steering, vt as requireFlag, y as coder } from "../chunks/reserved-Dmpg6dAp.mjs";
|
|
3
|
-
import {
|
|
3
|
+
import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-CLyE5VFR.mjs";
|
|
4
4
|
//#region src/workflows/cleanup/cleanup.ts
|
|
5
5
|
const removingPrompt = (name, registry) => `Clean up the release flag "${name}", which is default-on: delete its entry in ${registry}, and remove every \`GTD_FLAG_${name.toUpperCase()}\` check from the shipped sources. Keep the flag-on path; delete code only the off path reached, including tests that pin the off path.`;
|
|
6
6
|
const cleanupFlow = async () => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { answered, changes, changesSince, glob, hasThreadFor, head, human, judge, judgeDiffShape, kept, mark, matches, numeric, quote, read, refuse, removeScript, requireAnswers, requireProgress, requireReplies, requireRevert, requireThreadsClosed, restoreScript, revertScript, run, scope, slice, start, vars } from "../flows.mjs";
|
|
2
2
|
import { lensesOf, review as review$1 } from "../workflow-kit.mjs";
|
|
3
3
|
import { $ as refactorFixPrompt, A as architectureDecomposePrompt, B as designSystem, C as planner, D as agentWithSkills, E as reviewing, F as buildReviewFixNitsPrompt, G as idleMessage, I as buildReviewTriageMessage, J as naivePassPrompt, K as implementBuildingPrompt, L as builderSystem, M as buildReviewAnswerQuestionsPrompt, N as buildReviewAwaitAfterDeferMessage, O as architectSystem, P as buildReviewCollectingPrompt, Q as refactorDroppedRefusal, R as deferNotesScript, S as fixRisks, V as designTriagePrompt, X as packagesItemFixSuitePrompt, Y as packagesItemBuildingPrompt, Z as refactorBuildingPrompt, _ as REVIEW, at as reviewerSystem, b as fixCheck, ct as startGateBlockedMessage, d as healthy, dt as defaults, et as refactorRules, f as ARCHITECTURE, ft as envDefaults, g as REQUIREMENTS, h as REFACTOR, i as skills, it as reviewNotesCapture, j as architectureGateAnswerMessage, k as architectureAuthorPrompt, l as gate, lt as unwindFailedMessage, n as summary, nt as reviewEditsCapture, o as shipped, ot as seamByHandMessage, p as FEEDBACK, pt as testFileGlobs, q as implementFixPrompt, r as access, st as seamSteerMessage, t as steering, tt as reviewEditNotesCapture, u as guarded, ut as unwindFailure, v as awaitReview, w as reviewMissing, y as coder, z as designGateAnswerMessage } from "../chunks/reserved-Dmpg6dAp.mjs";
|
|
4
|
-
import { a as
|
|
4
|
+
import { a as refusePendingHandOff, i as isOptionalObject, n as closeReview, o as tail } from "../chunks/tail-CLyE5VFR.mjs";
|
|
5
5
|
//#region src/workflows/shared/fences.ts
|
|
6
6
|
const markFenced = (text) => {
|
|
7
7
|
let fence;
|
|
@@ -479,10 +479,8 @@ function buildTail(first, second, ...rest) {
|
|
|
479
479
|
const options = callForm(first, second, rest);
|
|
480
480
|
if (options === void 0) return Promise.reject(/* @__PURE__ */ new Error("buildTail takes buildTail(base, options?) or the deprecated buildTail(fixFirst, base, green?, options?); this call fits neither"));
|
|
481
481
|
const [base, form] = options;
|
|
482
|
-
|
|
483
|
-
return tail(fixFirst ?? false, (escalations) => review(base, escalations, reviewOptions), green ?? false, form.guard, form.lenses, form.guardRests, form.lensBase, form.rules);
|
|
482
|
+
return tail((escalations) => review(base, escalations, form), form);
|
|
484
483
|
}
|
|
485
|
-
const isOptionalObject = (value) => value == null || typeof value === "object" && !Array.isArray(value) && typeof value.then !== "function";
|
|
486
484
|
const isOptionalBoolean = (value) => value == null || typeof value === "boolean";
|
|
487
485
|
const legacyForm = (fixFirst, base, green, options) => isOptionalBoolean(fixFirst) && isOptionalBoolean(green) && isOptionalObject(options) ? [base, {
|
|
488
486
|
...options,
|
|
@@ -866,7 +864,7 @@ const planAndBuild = async (firstBase, firstLap = "first") => {
|
|
|
866
864
|
await packages();
|
|
867
865
|
const outcome = await buildTail(base, {
|
|
868
866
|
green: true,
|
|
869
|
-
|
|
867
|
+
lap
|
|
870
868
|
});
|
|
871
869
|
if (outcome.verdict === "signoff") return;
|
|
872
870
|
await reUnwind(outcome);
|
|
@@ -903,7 +901,7 @@ const featureFeedback = async (first, lensBase) => {
|
|
|
903
901
|
outcome = await buildTail(outcome.base, {
|
|
904
902
|
green: true,
|
|
905
903
|
lap: "feedback",
|
|
906
|
-
|
|
904
|
+
lensBase
|
|
907
905
|
});
|
|
908
906
|
}
|
|
909
907
|
};
|
package/dist/workflows/fix.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { read, refuse, removeScript, run, scope, start } from "../flows.mjs";
|
|
2
2
|
import { d as reviewNotes, f as reviewRisks } from "../chunks/steering-DVILanVf.mjs";
|
|
3
3
|
import { D as agentWithSkills, H as finisherSystem, U as fixReviewNotesPrompt, _ as REVIEW, dt as defaults, ft as envDefaults, i as skills, n as summary, o as shipped, r as access, s as baseline, t as steering, y as coder } from "../chunks/reserved-Dmpg6dAp.mjs";
|
|
4
|
-
import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-
|
|
4
|
+
import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-CLyE5VFR.mjs";
|
|
5
5
|
//#region src/workflows/fix/fix.ts
|
|
6
6
|
/**
|
|
7
7
|
* Fix every note a human left in a handed-off `.gtd/REVIEW.md` and every
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { start } from "../flows.mjs";
|
|
2
2
|
import { dt as defaults, ft as envDefaults, i as skills, l as gate, n as summary, o as shipped, r as access, rt as reviewGateBlockedMessage, t as steering } from "../chunks/reserved-Dmpg6dAp.mjs";
|
|
3
|
-
import {
|
|
3
|
+
import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-CLyE5VFR.mjs";
|
|
4
4
|
//#region src/workflows/review/review.ts
|
|
5
5
|
/**
|
|
6
6
|
* Pure review of everything since `reviewBase`: a green gate, the quality
|
package/dist/workflows.mjs
CHANGED
|
@@ -37791,13 +37791,31 @@ const closeReview = (round) => run("review.closing", removeScript([REVIEW, REVIE
|
|
|
37791
37791
|
const refusePendingHandOff = () => {
|
|
37792
37792
|
if (read(".gtd/REVIEW.orig.md") !== void 0) refuse(`gtd: ${REVIEW_ORIG} is a pending hand-off — run \`gtd door fix\`, or remove ${REVIEW} and ${REVIEW_ORIG}.`);
|
|
37793
37793
|
};
|
|
37794
|
-
|
|
37795
|
-
|
|
37796
|
-
|
|
37797
|
-
|
|
37798
|
-
|
|
37799
|
-
|
|
37800
|
-
const
|
|
37794
|
+
function tail(first, second, ...rest) {
|
|
37795
|
+
const form = tailForm(first, second, rest);
|
|
37796
|
+
if (form === void 0) return Promise.reject(/* @__PURE__ */ new Error("tail takes tail(finish, options?) or the deprecated tail(fixFirst, finish, green?, guard?, lenses?, guardRests?, lensBase?, rules?); this call fits neither"));
|
|
37797
|
+
const [finish, options] = form;
|
|
37798
|
+
return runTail(finish, options);
|
|
37799
|
+
}
|
|
37800
|
+
const isOptionalObject = (value) => value == null || typeof value === "object" && !Array.isArray(value) && typeof value.then !== "function";
|
|
37801
|
+
const tailForm = (first, second, rest) => {
|
|
37802
|
+
if (typeof second === "function" && typeof first !== "function") {
|
|
37803
|
+
const [green, guard, lenses, guardRests, lensBase, rules] = rest;
|
|
37804
|
+
return [second, {
|
|
37805
|
+
fixFirst: first,
|
|
37806
|
+
green,
|
|
37807
|
+
guard,
|
|
37808
|
+
lenses,
|
|
37809
|
+
guardRests,
|
|
37810
|
+
lensBase,
|
|
37811
|
+
rules
|
|
37812
|
+
}];
|
|
37813
|
+
}
|
|
37814
|
+
if (typeof first === "function" && isOptionalObject(second) && rest.every((v) => v == null)) return [first, second ?? {}];
|
|
37815
|
+
};
|
|
37816
|
+
const runTail = (finish, options) => scope("build", async () => {
|
|
37817
|
+
const { guard, lenses, lensBase, rules } = options;
|
|
37818
|
+
const guardRests = options.guardRests === void 0 ? true : options.guardRests;
|
|
37801
37819
|
const escalations = { rounds: 0 };
|
|
37802
37820
|
const fix = () => fixCheck(rules);
|
|
37803
37821
|
const repair = async () => {
|
|
@@ -37810,8 +37828,8 @@ const tail = (fixFirst, finish, green = false, guard, lenses, guardRests = true,
|
|
|
37810
37828
|
rules
|
|
37811
37829
|
});
|
|
37812
37830
|
};
|
|
37813
|
-
if (fixFirst) await repair();
|
|
37814
|
-
else if (!green) await healthy(fix, {
|
|
37831
|
+
if (options.fixFirst) await repair();
|
|
37832
|
+
else if (!options.green) await healthy(fix, {
|
|
37815
37833
|
escalations,
|
|
37816
37834
|
guard,
|
|
37817
37835
|
guardRests,
|
|
@@ -37848,7 +37866,10 @@ const handOffReview = (base, escalations, guard) => reviewToHandOff(base, {
|
|
|
37848
37866
|
deleted: "gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note to hand to fix."
|
|
37849
37867
|
});
|
|
37850
37868
|
/** The tail the review and fix workflows run: ends at the hand-off review since `base`. */
|
|
37851
|
-
const handOffTail = (fixFirst, base, guard) => tail(
|
|
37869
|
+
const handOffTail = (fixFirst, base, guard) => tail((escalations) => handOffReview(base, escalations, guard), {
|
|
37870
|
+
fixFirst,
|
|
37871
|
+
guard
|
|
37872
|
+
});
|
|
37852
37873
|
//#endregion
|
|
37853
37874
|
//#region src/workflows/shared/fences.ts
|
|
37854
37875
|
const markFenced = (text) => {
|
|
@@ -38327,10 +38348,8 @@ function buildTail(first, second, ...rest) {
|
|
|
38327
38348
|
const options = callForm(first, second, rest);
|
|
38328
38349
|
if (options === void 0) return Promise.reject(/* @__PURE__ */ new Error("buildTail takes buildTail(base, options?) or the deprecated buildTail(fixFirst, base, green?, options?); this call fits neither"));
|
|
38329
38350
|
const [base, form] = options;
|
|
38330
|
-
|
|
38331
|
-
return tail(fixFirst ?? false, (escalations) => review$1(base, escalations, reviewOptions), green ?? false, form.guard, form.lenses, form.guardRests, form.lensBase, form.rules);
|
|
38351
|
+
return tail((escalations) => review$1(base, escalations, form), form);
|
|
38332
38352
|
}
|
|
38333
|
-
const isOptionalObject = (value) => value == null || typeof value === "object" && !Array.isArray(value) && typeof value.then !== "function";
|
|
38334
38353
|
const isOptionalBoolean = (value) => value == null || typeof value === "boolean";
|
|
38335
38354
|
const legacyForm = (fixFirst, base, green, options) => isOptionalBoolean(fixFirst) && isOptionalBoolean(green) && isOptionalObject(options) ? [base, {
|
|
38336
38355
|
...options,
|
|
@@ -38714,7 +38733,7 @@ const planAndBuild = async (firstBase, firstLap = "first") => {
|
|
|
38714
38733
|
await packages();
|
|
38715
38734
|
const outcome = await buildTail(base, {
|
|
38716
38735
|
green: true,
|
|
38717
|
-
|
|
38736
|
+
lap
|
|
38718
38737
|
});
|
|
38719
38738
|
if (outcome.verdict === "signoff") return;
|
|
38720
38739
|
await reUnwind(outcome);
|
|
@@ -38751,7 +38770,7 @@ const featureFeedback = async (first, lensBase) => {
|
|
|
38751
38770
|
outcome = await buildTail(outcome.base, {
|
|
38752
38771
|
green: true,
|
|
38753
38772
|
lap: "feedback",
|
|
38754
|
-
|
|
38773
|
+
lensBase
|
|
38755
38774
|
});
|
|
38756
38775
|
}
|
|
38757
38776
|
};
|
package/package.json
CHANGED
|
@@ -49,7 +49,7 @@ export const planAndBuild = async (
|
|
|
49
49
|
await design(base)
|
|
50
50
|
await architecture()
|
|
51
51
|
await packages()
|
|
52
|
-
const outcome = await buildTail(base, { green: true,
|
|
52
|
+
const outcome = await buildTail(base, { green: true, lap })
|
|
53
53
|
if (outcome.verdict === "signoff") return
|
|
54
54
|
await reUnwind(outcome)
|
|
55
55
|
base = outcome.base
|
|
@@ -81,11 +81,7 @@ const featureFeedback = async (first: ReviewOutcome, lensBase?: string): Promise
|
|
|
81
81
|
await design(outcome.base)
|
|
82
82
|
const tier = riskTierOn() ? recordRisk() : undefined
|
|
83
83
|
await implement({ tier, amend: true })
|
|
84
|
-
outcome = await buildTail(outcome.base, {
|
|
85
|
-
green: true,
|
|
86
|
-
lap: "feedback",
|
|
87
|
-
...(lensBase === undefined ? {} : { lensBase }),
|
|
88
|
-
})
|
|
84
|
+
outcome = await buildTail(outcome.base, { green: true, lap: "feedback", lensBase })
|
|
89
85
|
}
|
|
90
86
|
}
|
|
91
87
|
|
|
@@ -5,7 +5,13 @@ export * from "./planning.js"
|
|
|
5
5
|
export * from "./packages.js"
|
|
6
6
|
export * from "./refactor.js"
|
|
7
7
|
export * from "./seam.js"
|
|
8
|
-
export {
|
|
8
|
+
export {
|
|
9
|
+
buildTail,
|
|
10
|
+
type BuildTailOptions,
|
|
11
|
+
type FeedbackTarget,
|
|
12
|
+
type ReviewOutcome,
|
|
13
|
+
} from "./review.js"
|
|
14
|
+
export type { ReviewRange } from "../shared/text.js"
|
|
9
15
|
export * from "./feature.js"
|
|
10
16
|
export * from "../shared/reserved.js"
|
|
11
17
|
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from "./review.js"
|
|
18
18
|
import type { ReviewNote } from "../../steering/index.js"
|
|
19
19
|
import * as bundled from "../index.js"
|
|
20
|
-
import type
|
|
20
|
+
import type * as featureEntry from "./index.js"
|
|
21
21
|
import { fixtureContext } from "../shared/text.fixture.js"
|
|
22
22
|
import { toRequest } from "../../judges/index.js"
|
|
23
23
|
|
|
@@ -999,17 +999,38 @@ describe("BuildTailOptions.lensBase", () => {
|
|
|
999
999
|
expect(names).not.toEqual([])
|
|
1000
1000
|
})
|
|
1001
1001
|
|
|
1002
|
-
// pins `| undefined` on BuildTailOptions under exactOptionalPropertyTypes (typecheck)
|
|
1003
|
-
it("explicitly undefined
|
|
1002
|
+
// pins `| undefined` on every BuildTailOptions field under exactOptionalPropertyTypes (typecheck)
|
|
1003
|
+
it("explicitly undefined options default like omitted ones", async () => {
|
|
1004
1004
|
expect(
|
|
1005
|
-
await requestNames(() =>
|
|
1005
|
+
await requestNames(() =>
|
|
1006
|
+
buildTail("base", {
|
|
1007
|
+
fixFirst: undefined,
|
|
1008
|
+
green: undefined,
|
|
1009
|
+
guard: undefined,
|
|
1010
|
+
guardRests: undefined,
|
|
1011
|
+
lenses: undefined,
|
|
1012
|
+
feedback: undefined,
|
|
1013
|
+
fixAccess: undefined,
|
|
1014
|
+
lensBase: undefined,
|
|
1015
|
+
rules: undefined,
|
|
1016
|
+
lap: undefined,
|
|
1017
|
+
}),
|
|
1018
|
+
),
|
|
1006
1019
|
).toEqual(await requestNames(() => buildTail("base")))
|
|
1007
1020
|
})
|
|
1008
1021
|
})
|
|
1009
1022
|
})
|
|
1010
1023
|
|
|
1011
1024
|
// pins the public re-exports from the feature entry point and the published workflows entry
|
|
1012
|
-
export type _PublicBuildTailOptions = [
|
|
1025
|
+
export type _PublicBuildTailOptions = [
|
|
1026
|
+
featureEntry.BuildTailOptions,
|
|
1027
|
+
featureEntry.FeedbackTarget,
|
|
1028
|
+
featureEntry.ReviewRange,
|
|
1029
|
+
bundled.BuildTailOptions,
|
|
1030
|
+
bundled.FeedbackTarget,
|
|
1031
|
+
bundled.ReviewRange,
|
|
1032
|
+
bundled.TailOptions,
|
|
1033
|
+
]
|
|
1013
1034
|
|
|
1014
1035
|
describe("buildTail without rules", () => {
|
|
1015
1036
|
const RULES = "Change only these paths"
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "../../flows/index.js"
|
|
14
14
|
import type { ReviewNote } from "../../steering/index.js"
|
|
15
15
|
import * as kit from "../../workflow-kit/index.js"
|
|
16
|
-
import { guarded, healthy, type EscalationCount
|
|
16
|
+
import { guarded, healthy, type EscalationCount } from "../shared/health.js"
|
|
17
17
|
import {
|
|
18
18
|
awaitReview,
|
|
19
19
|
fixCheck,
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
reviewing,
|
|
24
24
|
reviewMissing,
|
|
25
25
|
} from "../shared/steps.js"
|
|
26
|
-
import { closeReview, tail } from "../shared/tail.js"
|
|
26
|
+
import { closeReview, isOptionalObject, tail, type TailOptions } from "../shared/tail.js"
|
|
27
27
|
import * as t from "../shared/text.js"
|
|
28
28
|
import { answerReviewQuestions, collecting, deferNotes, fixNits } from "./steps.js"
|
|
29
29
|
|
|
@@ -169,7 +169,7 @@ export const isCode = (path: string): boolean => path !== ".gtd" && !path.starts
|
|
|
169
169
|
export const review = (
|
|
170
170
|
base: string,
|
|
171
171
|
escalations: EscalationCount = { rounds: 0 },
|
|
172
|
-
options: BuildTailOptions = {},
|
|
172
|
+
options: Omit<BuildTailOptions, "fixFirst" | "green"> = {},
|
|
173
173
|
): Promise<kit.ReviewOutcome> => {
|
|
174
174
|
const { guard, guardRests, fixAccess, rules } = options
|
|
175
175
|
const feedback: FeedbackTarget = options.feedback ?? {
|
|
@@ -255,26 +255,12 @@ export interface FeedbackTarget {
|
|
|
255
255
|
readonly dropped?: (requested: kit.Requested) => string
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
-
export interface BuildTailOptions {
|
|
259
|
-
|
|
260
|
-
readonly fixFirst?: boolean | undefined
|
|
261
|
-
/** Asserts the tree is already green, so the tail skips keep-green; default `false`. */
|
|
262
|
-
readonly green?: boolean | undefined
|
|
263
|
-
/** Runs after every code-changing turn: fix-check, quality fix, risk fix, nit fix, escalation describe. */
|
|
264
|
-
readonly guard?: Guard
|
|
265
|
-
/** `false`: `guard` skips the escalation's human rests. */
|
|
266
|
-
readonly guardRests?: boolean
|
|
267
|
-
/** Quality-lap lenses; default `lensesOf(vars.qualityReviews)`. */
|
|
268
|
-
readonly lenses?: readonly string[]
|
|
269
|
-
readonly feedback?: FeedbackTarget
|
|
258
|
+
export interface BuildTailOptions extends TailOptions {
|
|
259
|
+
readonly feedback?: FeedbackTarget | undefined
|
|
270
260
|
/** Write access for the nit and risk fix turns; absent: the bundled `access` export decides. */
|
|
271
|
-
readonly fixAccess?: ScopeAccess
|
|
272
|
-
/** The commit the quality lap reviews from; absent: `start()`. */
|
|
273
|
-
readonly lensBase?: string
|
|
274
|
-
/** Appended to every fix and describe prompt of the tail. */
|
|
275
|
-
readonly rules?: string
|
|
261
|
+
readonly fixAccess?: ScopeAccess | undefined
|
|
276
262
|
/** Absent: "first". */
|
|
277
|
-
readonly lap?: t.ReviewRange
|
|
263
|
+
readonly lap?: t.ReviewRange | undefined
|
|
278
264
|
}
|
|
279
265
|
|
|
280
266
|
/** The build tail: fix (when entered red), keep green unless `green`, the quality lap, then human review since `base`. */
|
|
@@ -301,26 +287,9 @@ export function buildTail(
|
|
|
301
287
|
)
|
|
302
288
|
}
|
|
303
289
|
const [base, form] = options
|
|
304
|
-
|
|
305
|
-
return tail(
|
|
306
|
-
fixFirst ?? false,
|
|
307
|
-
(escalations) => review(base, escalations, reviewOptions),
|
|
308
|
-
green ?? false,
|
|
309
|
-
form.guard,
|
|
310
|
-
form.lenses,
|
|
311
|
-
form.guardRests,
|
|
312
|
-
form.lensBase,
|
|
313
|
-
form.rules,
|
|
314
|
-
)
|
|
290
|
+
return tail((escalations) => review(base, escalations, form), form)
|
|
315
291
|
}
|
|
316
292
|
|
|
317
|
-
// null reads as absent; an un-awaited Promise is a mistake, not options.
|
|
318
|
-
const isOptionalObject = (value: unknown): value is BuildTailOptions | null | undefined =>
|
|
319
|
-
value == null ||
|
|
320
|
-
(typeof value === "object" &&
|
|
321
|
-
!Array.isArray(value) &&
|
|
322
|
-
typeof (value as { then?: unknown }).then !== "function")
|
|
323
|
-
|
|
324
293
|
const isOptionalBoolean = (value: unknown): value is boolean | null | undefined =>
|
|
325
294
|
value == null || typeof value === "boolean"
|
|
326
295
|
|
package/src/workflows/index.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { feature } from "./feature/feature.js"
|
|
|
6
6
|
export { threads, type ThreadInfo } from "../flows/index.js"
|
|
7
7
|
export * from "./shared/reserved.js"
|
|
8
8
|
export { FlagOptions, ShippedOptions } from "./shared/options.js"
|
|
9
|
-
export { agentWithSkills } from "./shared/text.js"
|
|
9
|
+
export { agentWithSkills, type ReviewRange } from "./shared/text.js"
|
|
10
10
|
export * from "./shared/steps.js"
|
|
11
11
|
export * from "./shared/health.js"
|
|
12
12
|
export {
|
|
@@ -17,6 +17,7 @@ export {
|
|
|
17
17
|
qualityLap,
|
|
18
18
|
REVIEW_ORIG,
|
|
19
19
|
tail,
|
|
20
|
+
type TailOptions,
|
|
20
21
|
} from "./shared/tail.js"
|
|
21
22
|
export { lensesOf } from "../workflow-kit/index.js"
|
|
22
23
|
export * from "./feature/steps.js"
|
|
@@ -24,7 +25,12 @@ export * from "./feature/planning.js"
|
|
|
24
25
|
export * from "./feature/packages.js"
|
|
25
26
|
export * from "./feature/refactor.js"
|
|
26
27
|
export * from "./feature/seam.js"
|
|
27
|
-
export {
|
|
28
|
+
export {
|
|
29
|
+
buildTail,
|
|
30
|
+
type BuildTailOptions,
|
|
31
|
+
type FeedbackTarget,
|
|
32
|
+
type ReviewOutcome,
|
|
33
|
+
} from "./feature/review.js"
|
|
28
34
|
export * from "./feature/feature.js"
|
|
29
35
|
export { base, review, reviewFlow } from "./review/review.js"
|
|
30
36
|
export { fix, fixFlow, fixReviewNotes } from "./fix/fix.js"
|
|
@@ -260,32 +260,147 @@ describe("the quality lap's lenses", () => {
|
|
|
260
260
|
|
|
261
261
|
it("tail threads lensBase to every lens turn", async () => {
|
|
262
262
|
await lap(() =>
|
|
263
|
-
tail(
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
["conventions", "correctness"],
|
|
269
|
-
true,
|
|
270
|
-
"BASE-SHA",
|
|
271
|
-
),
|
|
263
|
+
tail(() => Promise.resolve(), {
|
|
264
|
+
green: true,
|
|
265
|
+
lenses: ["conventions", "correctness"],
|
|
266
|
+
lensBase: "BASE-SHA",
|
|
267
|
+
}),
|
|
272
268
|
)
|
|
273
269
|
expect(prompts).toHaveLength(2)
|
|
274
270
|
for (const prompt of prompts) expect(prompt).toContain("from `BASE-SHA`")
|
|
275
271
|
})
|
|
276
272
|
|
|
273
|
+
it("the deprecated form threads lensBase from its seventh slot", async () => {
|
|
274
|
+
await lap(() =>
|
|
275
|
+
tail(false, () => Promise.resolve(), true, undefined, ["conventions"], true, "BASE-SHA", "R"),
|
|
276
|
+
)
|
|
277
|
+
expect(prompts).toHaveLength(1)
|
|
278
|
+
expect(prompts[0]).toContain("from `BASE-SHA`")
|
|
279
|
+
expect(prompts[0]).not.toContain("from `R`")
|
|
280
|
+
})
|
|
281
|
+
|
|
277
282
|
it("tail passes lenses to its lap and a guard after each lens turn", async () => {
|
|
278
283
|
let guards = 0
|
|
279
284
|
const log = await lap(() =>
|
|
280
|
-
tail(
|
|
281
|
-
|
|
282
|
-
() =>
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
["conventions"],
|
|
286
|
-
),
|
|
285
|
+
tail(() => Promise.resolve(), {
|
|
286
|
+
green: true,
|
|
287
|
+
guard: () => void guards++,
|
|
288
|
+
lenses: ["conventions"],
|
|
289
|
+
}),
|
|
287
290
|
)
|
|
288
291
|
expect(log).toEqual(["quality.conventions.reviewing"])
|
|
289
292
|
expect(guards).toBe(1)
|
|
290
293
|
})
|
|
291
294
|
})
|
|
295
|
+
|
|
296
|
+
describe("tail's call forms", () => {
|
|
297
|
+
class Stop extends Error {}
|
|
298
|
+
const requestNames = async (
|
|
299
|
+
call: () => Promise<unknown>,
|
|
300
|
+
names: string[] = [],
|
|
301
|
+
): Promise<string[]> => {
|
|
302
|
+
installContext(
|
|
303
|
+
fixtureContext(
|
|
304
|
+
{ vars: { qualityReviews: "correctness" } },
|
|
305
|
+
{
|
|
306
|
+
step: (request: StepRequest) => {
|
|
307
|
+
if (request.kind === "restart") return Promise.resolve()
|
|
308
|
+
names.push(`${request.kind}:${request.name}`)
|
|
309
|
+
if (request.kind === "agent") prompts.push(request.prompt)
|
|
310
|
+
if (names.length >= 4) throw new Stop()
|
|
311
|
+
return Promise.resolve()
|
|
312
|
+
},
|
|
313
|
+
pushScope: () => undefined,
|
|
314
|
+
popScope: () => undefined,
|
|
315
|
+
start: () => "START-SHA",
|
|
316
|
+
},
|
|
317
|
+
),
|
|
318
|
+
)
|
|
319
|
+
await call().catch((error: unknown) => {
|
|
320
|
+
if (!(error instanceof Stop)) throw error
|
|
321
|
+
})
|
|
322
|
+
return names
|
|
323
|
+
}
|
|
324
|
+
const finish = () => Promise.resolve()
|
|
325
|
+
|
|
326
|
+
for (const [fixFirst, green] of [
|
|
327
|
+
[true, false],
|
|
328
|
+
[true, true],
|
|
329
|
+
[false, true],
|
|
330
|
+
[false, false],
|
|
331
|
+
] as const) {
|
|
332
|
+
it(`the deprecated positional form matches the options form for fixFirst ${fixFirst}, green ${green}`, async () => {
|
|
333
|
+
const modern = await requestNames(() => tail(finish, { fixFirst, green }))
|
|
334
|
+
const legacy = await requestNames(() => tail(fixFirst, finish, green))
|
|
335
|
+
expect(legacy).toEqual(modern)
|
|
336
|
+
})
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
it("the deprecated form passes every positional on", async () => {
|
|
340
|
+
let guards = 0
|
|
341
|
+
const guard = () => void guards++
|
|
342
|
+
const modern = await requestNames(() =>
|
|
343
|
+
tail(finish, { green: true, guard, lenses: ["conventions"], guardRests: false }),
|
|
344
|
+
)
|
|
345
|
+
const modernGuards = guards
|
|
346
|
+
guards = 0
|
|
347
|
+
const legacy = await requestNames(() =>
|
|
348
|
+
tail(false, finish, true, guard, ["conventions"], false),
|
|
349
|
+
)
|
|
350
|
+
expect(legacy).toEqual(modern)
|
|
351
|
+
expect(guards).toBe(modernGuards)
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it("the deprecated form threads rules from its eighth slot", async () => {
|
|
355
|
+
prompts.length = 0
|
|
356
|
+
await requestNames(() =>
|
|
357
|
+
tail(true, finish, false, undefined, [], true, "BASE-SHA", "RULES-TEXT"),
|
|
358
|
+
)
|
|
359
|
+
expect(prompts.some((p) => p.includes("RULES-TEXT"))).toBe(true)
|
|
360
|
+
expect(prompts.some((p) => p.includes("BASE-SHA"))).toBe(false)
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it("the deprecated form still runs untyped flags as truthy or falsy", async () => {
|
|
364
|
+
expect(await requestNames(() => tail(1 as never, finish, 0 as never))).toEqual(
|
|
365
|
+
await requestNames(() => tail(finish, { fixFirst: true })),
|
|
366
|
+
)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it("reads an explicit undefined in every option as absent", async () => {
|
|
370
|
+
const absent = await requestNames(() => tail(finish))
|
|
371
|
+
const explicit = await requestNames(() =>
|
|
372
|
+
tail(finish, {
|
|
373
|
+
fixFirst: undefined,
|
|
374
|
+
green: undefined,
|
|
375
|
+
guard: undefined,
|
|
376
|
+
lenses: undefined,
|
|
377
|
+
guardRests: undefined,
|
|
378
|
+
lensBase: undefined,
|
|
379
|
+
rules: undefined,
|
|
380
|
+
}),
|
|
381
|
+
)
|
|
382
|
+
expect(explicit).toEqual(absent)
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
const fitsNeither: [string, () => Promise<unknown>][] = [
|
|
386
|
+
["no finish", () => (tail as (...args: unknown[]) => Promise<unknown>)(false)],
|
|
387
|
+
["a finish followed by a boolean", () => tail(finish, true as never)],
|
|
388
|
+
["a finish followed by a function", () => tail(finish, finish as never)],
|
|
389
|
+
["a finish followed by a Promise", () => tail(finish, Promise.resolve({}) as never)],
|
|
390
|
+
[
|
|
391
|
+
"a finish with a third argument",
|
|
392
|
+
() => (tail as (...args: unknown[]) => Promise<unknown>)(finish, {}, true),
|
|
393
|
+
],
|
|
394
|
+
]
|
|
395
|
+
for (const [label, call] of fitsNeither) {
|
|
396
|
+
it(`rejects ${label} before any step runs`, async () => {
|
|
397
|
+
const names: string[] = []
|
|
398
|
+
const rejected = expect(requestNames(call, names)).rejects
|
|
399
|
+
await rejected.toThrow("tail(finish, options?)")
|
|
400
|
+
await rejected.toThrow(
|
|
401
|
+
"tail(fixFirst, finish, green?, guard?, lenses?, guardRests?, lensBase?, rules?)",
|
|
402
|
+
)
|
|
403
|
+
expect(names).toEqual([])
|
|
404
|
+
})
|
|
405
|
+
}
|
|
406
|
+
})
|
|
@@ -70,23 +70,88 @@ export const refusePendingHandOff = (): void => {
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
export interface TailOptions {
|
|
74
|
+
/** Fix first when entered red; default `false`. */
|
|
75
|
+
readonly fixFirst?: boolean | undefined
|
|
76
|
+
/** Asserts the tree is already green, so the tail skips keep-green; default `false`. */
|
|
77
|
+
readonly green?: boolean | undefined
|
|
78
|
+
/** Runs after every code-changing turn: fix-check, quality fix, risk fix, nit fix, escalation describe. */
|
|
79
|
+
readonly guard?: Guard | undefined
|
|
80
|
+
/** Quality-lap lenses; default `lensesOf(vars.qualityReviews)`. */
|
|
81
|
+
readonly lenses?: readonly string[] | undefined
|
|
82
|
+
/** `false` leaves the escalation's human rests unguarded; default `true`. */
|
|
83
|
+
readonly guardRests?: boolean | undefined
|
|
84
|
+
/** The commit the quality lap reviews from; absent: `start()`. */
|
|
85
|
+
readonly lensBase?: string | undefined
|
|
86
|
+
/** Appended to every fix and describe prompt of the tail. */
|
|
87
|
+
readonly rules?: string | undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
73
90
|
/**
|
|
74
91
|
* The build tail: fix (when entered red), keep green, the quality lap, then
|
|
75
|
-
* `finish` — the human review, as the calling workflow runs it.
|
|
76
|
-
* caller just left the full suite green (the package loop), so no run opens it.
|
|
77
|
-
* `guardRests: false` leaves the escalation's human rests unguarded.
|
|
92
|
+
* `finish` — the human review, as the calling workflow runs it.
|
|
78
93
|
*/
|
|
79
|
-
export
|
|
94
|
+
export function tail<T>(
|
|
95
|
+
finish: (escalations: EscalationCount) => Promise<T>,
|
|
96
|
+
options?: TailOptions,
|
|
97
|
+
): Promise<T>
|
|
98
|
+
/** @deprecated Pass `tail(finish, { fixFirst, green, guard, lenses, guardRests, lensBase, rules })`. */
|
|
99
|
+
export function tail<T>(
|
|
80
100
|
fixFirst: boolean,
|
|
81
101
|
finish: (escalations: EscalationCount) => Promise<T>,
|
|
82
|
-
green
|
|
102
|
+
green?: boolean,
|
|
83
103
|
guard?: Guard,
|
|
84
104
|
lenses?: readonly string[],
|
|
85
|
-
guardRests
|
|
105
|
+
guardRests?: boolean,
|
|
86
106
|
lensBase?: string,
|
|
87
107
|
rules?: string,
|
|
88
|
-
): Promise<T>
|
|
108
|
+
): Promise<T>
|
|
109
|
+
export function tail<T>(first: unknown, second?: unknown, ...rest: unknown[]): Promise<T> {
|
|
110
|
+
const form = tailForm<T>(first, second, rest)
|
|
111
|
+
// Before any step runs, so a bad call never leaves a half-walked flow.
|
|
112
|
+
if (form === undefined) {
|
|
113
|
+
return Promise.reject(
|
|
114
|
+
new Error(
|
|
115
|
+
"tail takes tail(finish, options?) or the deprecated tail(fixFirst, finish, green?, guard?, lenses?, guardRests?, lensBase?, rules?); this call fits neither",
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
const [finish, options] = form
|
|
120
|
+
return runTail(finish, options)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
type Finish<T> = (escalations: EscalationCount) => Promise<T>
|
|
124
|
+
|
|
125
|
+
// null reads as absent; an un-awaited Promise is a mistake, not options.
|
|
126
|
+
export const isOptionalObject = (value: unknown): value is object | null | undefined =>
|
|
127
|
+
value == null ||
|
|
128
|
+
(typeof value === "object" &&
|
|
129
|
+
!Array.isArray(value) &&
|
|
130
|
+
typeof (value as { then?: unknown }).then !== "function")
|
|
131
|
+
|
|
132
|
+
// Picks the form by shape only: the deprecated form never validated its
|
|
133
|
+
// positionals, and still runs whatever it ran before.
|
|
134
|
+
const tailForm = <T>(
|
|
135
|
+
first: unknown,
|
|
136
|
+
second: unknown,
|
|
137
|
+
rest: readonly unknown[],
|
|
138
|
+
): [Finish<T>, TailOptions] | undefined => {
|
|
139
|
+
// A function first is a misplaced finish, never a fixFirst.
|
|
140
|
+
if (typeof second === "function" && typeof first !== "function") {
|
|
141
|
+
const [green, guard, lenses, guardRests, lensBase, rules] = rest
|
|
142
|
+
const options = { fixFirst: first, green, guard, lenses, guardRests, lensBase, rules }
|
|
143
|
+
return [second as Finish<T>, options as TailOptions]
|
|
144
|
+
}
|
|
145
|
+
if (typeof first === "function" && isOptionalObject(second) && rest.every((v) => v == null))
|
|
146
|
+
return [first as Finish<T>, (second as TailOptions | null | undefined) ?? {}]
|
|
147
|
+
return undefined
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const runTail = <T>(finish: Finish<T>, options: TailOptions): Promise<T> =>
|
|
89
151
|
scope("build", async () => {
|
|
152
|
+
const { guard, lenses, lensBase, rules } = options
|
|
153
|
+
// Only undefined defaults: a null reaches every escalation as falsy, as it reaches review's.
|
|
154
|
+
const guardRests = options.guardRests === undefined ? true : options.guardRests
|
|
90
155
|
const escalations: EscalationCount = { rounds: 0 }
|
|
91
156
|
const fix = () => fixCheck(rules)
|
|
92
157
|
const repair = async () => {
|
|
@@ -94,8 +159,9 @@ export const tail = <T>(
|
|
|
94
159
|
await healthy(fix, { fixesSoFar: 1, escalations, guard, guardRests, rules })
|
|
95
160
|
}
|
|
96
161
|
// fixFirst's own fix + healthy below already is the full run.
|
|
97
|
-
|
|
98
|
-
|
|
162
|
+
// Truthy, not `true`: an untyped config's flags read as they always did.
|
|
163
|
+
if (options.fixFirst) await repair()
|
|
164
|
+
else if (!options.green) await healthy(fix, { escalations, guard, guardRests, rules })
|
|
99
165
|
// The lap runs once a tail: after its findings are fixed, review follows.
|
|
100
166
|
if ((await qualityLap(guard, lenses, lensBase)) === "findings") {
|
|
101
167
|
if (await fixQualityFindings(escalations, guard, guardRests, rules)) {
|
|
@@ -138,4 +204,4 @@ export const handOffReview = (
|
|
|
138
204
|
|
|
139
205
|
/** The tail the review and fix workflows run: ends at the hand-off review since `base`. */
|
|
140
206
|
export const handOffTail = (fixFirst: boolean, base: string, guard?: Guard): Promise<void> =>
|
|
141
|
-
tail(
|
|
207
|
+
tail((escalations) => handOffReview(base, escalations, guard), { fixFirst, guard })
|