@pmelab/gtd 29.3.0 → 30.0.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 +65 -71
- package/claude/hooks/ship.ts +36 -25
- package/dist/chunks/{reserved-Dmpg6dAp.mjs → reserved-iG86IR3I.mjs} +19 -233
- package/dist/chunks/{tail-CLyE5VFR.mjs → tail-DA2UuJNP.mjs} +1 -1
- package/dist/gtd.bundle.mjs +0 -5
- package/dist/testkit.mjs +0 -5
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +14 -180
- 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 +29 -409
- package/package.json +1 -1
- package/skills/authoring/SKILL.md +15 -27
- package/src/cli/Cli.test.ts +6 -6
- package/src/edge/Edge.test.ts +7 -7
- package/src/flags/index.test.ts +0 -4
- package/src/flags/index.ts +0 -1
- package/src/workflow/load.test.ts +1 -1
- package/src/workflows/feature/fast-path.feature +1 -2
- package/src/workflows/feature/feature.test.ts +74 -63
- package/src/workflows/feature/feature.ts +13 -35
- package/src/workflows/feature/index.ts +0 -1
- package/src/workflows/feature/one-step-land.feature +4 -6
- package/src/workflows/feature/planning.ts +2 -27
- package/src/workflows/feature/refactor-absorbs.feature +1 -2
- package/src/workflows/feature/refactor-first.feature +1 -3
- package/src/workflows/feature/refactor-review.feature +0 -1
- package/src/workflows/feature/risk-tier.feature +2 -3
- package/src/workflows/feature/seam-rejection.feature +1 -3
- package/src/workflows/feature/seam.feature +4 -6
- package/src/workflows/feature/steps.test.ts +0 -20
- package/src/workflows/feature/steps.ts +1 -49
- package/src/workflows/feature/{seam-pin.feature → tier-pin.feature} +17 -11
- package/src/workflows/feature/tier.test.ts +3 -5
- package/src/workflows/feature/tier.ts +2 -2
- package/src/workflows/index.ts +0 -1
- package/src/workflows/shared/access.test.ts +0 -2
- package/src/workflows/shared/access.ts +0 -2
- package/src/workflows/shared/options.test.ts +2 -3
- package/src/workflows/shared/prose.ts +4 -11
- package/src/workflows/shared/reserved.ts +2 -2
- package/src/workflows/shared/skills.test.ts +0 -11
- package/src/workflows/shared/skills.ts +2 -9
- package/src/workflows/shared/steps.ts +0 -1
- package/src/workflows/shared/text.test.ts +4 -104
- package/src/workflows/shared/text.ts +15 -223
- package/src/workflows/shared/vars.ts +1 -3
- package/src/workflows/feature/packages.test.ts +0 -368
- package/src/workflows/feature/packages.ts +0 -173
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { answered, changes, changesSince,
|
|
1
|
+
import { answered, changes, changesSince, 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
|
-
import { $ as
|
|
4
|
-
import { a as refusePendingHandOff, i as isOptionalObject, n as closeReview, o as tail } from "../chunks/tail-
|
|
3
|
+
import { $ as seamByHandMessage, A as buildReviewFixNitsPrompt, B as idleMessage, C as reviewMissing, D as buildReviewAnswerQuestionsPrompt, E as agentWithSkills, F as designSystem, G as refactorDroppedRefusal, H as implementFixPrompt, I as designTriagePrompt, J as reviewEditNotesCapture, K as refactorFixPrompt, M as builderSystem, N as deferNotesScript, O as buildReviewAwaitAfterDeferMessage, P as designGateAnswerMessage, Q as reviewerSystem, S as planner, T as reviewing, U as naivePassPrompt, V as implementBuildingPrompt, W as refactorBuildingPrompt, Y as reviewEditsCapture, Z as reviewNotesCapture, _ as awaitReview, at as envDefaults, d as healthy, et as seamSteerMessage, f as FEEDBACK, g as REVIEW, h as REQUIREMENTS, i as skills, it as defaults, j as buildReviewTriageMessage, k as buildReviewCollectingPrompt, l as gate, m as REFACTOR, n as summary, nt as unwindFailedMessage, o as shipped, ot as testFileGlobs, q as refactorRules, r as access, rt as unwindFailure, t as steering, tt as startGateBlockedMessage, u as guarded, v as coder, x as fixRisks, y as fixCheck } from "../chunks/reserved-iG86IR3I.mjs";
|
|
4
|
+
import { a as refusePendingHandOff, i as isOptionalObject, n as closeReview, o as tail } from "../chunks/tail-DA2UuJNP.mjs";
|
|
5
5
|
//#region src/workflows/shared/fences.ts
|
|
6
6
|
const markFenced = (text) => {
|
|
7
7
|
let fence;
|
|
@@ -18,8 +18,8 @@ const markFenced = (text) => {
|
|
|
18
18
|
};
|
|
19
19
|
//#endregion
|
|
20
20
|
//#region src/workflows/feature/tier.ts
|
|
21
|
-
/**
|
|
22
|
-
const riskTierOn = () => vars.
|
|
21
|
+
/** GTD_FLAG_RISK_TIER: pinned at process start, so a mid-process flip never moves a process. */
|
|
22
|
+
const riskTierOn = () => vars.flag_risk_tier === "1";
|
|
23
23
|
const sectionLines = (text, title) => {
|
|
24
24
|
if (text === void 0) return [];
|
|
25
25
|
const heading = new RegExp(`^##[ \\t]+${title}[ \\t]*$`, "i");
|
|
@@ -63,18 +63,6 @@ const triage = (base) => agentWithSkills("triage", designTriagePrompt(base, risk
|
|
|
63
63
|
model: planner(),
|
|
64
64
|
system: designSystem()
|
|
65
65
|
});
|
|
66
|
-
const author = () => agentWithSkills("author", architectureAuthorPrompt(), {
|
|
67
|
-
label: "Refining the technical plan",
|
|
68
|
-
file: ARCHITECTURE,
|
|
69
|
-
mode: "qa",
|
|
70
|
-
model: planner(),
|
|
71
|
-
system: architectSystem()
|
|
72
|
-
});
|
|
73
|
-
const decompose = () => agentWithSkills("decompose.decomposing", architectureDecomposePrompt(), {
|
|
74
|
-
label: "Decomposing into packages",
|
|
75
|
-
model: planner(),
|
|
76
|
-
system: architectSystem()
|
|
77
|
-
});
|
|
78
66
|
const answerProductQuestions = () => human("gate.answer", {
|
|
79
67
|
message: designGateAnswerMessage(riskTierOn()),
|
|
80
68
|
label: "Awaiting your product answers",
|
|
@@ -82,24 +70,6 @@ const answerProductQuestions = () => human("gate.answer", {
|
|
|
82
70
|
mode: "qa",
|
|
83
71
|
acceptClean: true
|
|
84
72
|
});
|
|
85
|
-
const answerTechnicalQuestions = () => human("gate.answer", {
|
|
86
|
-
message: architectureGateAnswerMessage(),
|
|
87
|
-
label: "Awaiting your technical answers",
|
|
88
|
-
file: ARCHITECTURE,
|
|
89
|
-
mode: "qa",
|
|
90
|
-
acceptClean: true
|
|
91
|
-
});
|
|
92
|
-
const build = (pkg) => agentWithSkills("building", packagesItemBuildingPrompt(pkg), {
|
|
93
|
-
label: "Building",
|
|
94
|
-
model: coder(),
|
|
95
|
-
system: builderSystem()
|
|
96
|
-
});
|
|
97
|
-
const fixSuite = () => agentWithSkills("fix.suite.fixing", packagesItemFixSuitePrompt(), {
|
|
98
|
-
label: "Fixing the check",
|
|
99
|
-
file: FEEDBACK,
|
|
100
|
-
model: coder(),
|
|
101
|
-
system: builderSystem()
|
|
102
|
-
});
|
|
103
73
|
/** Answer every `question` note inline in `.gtd/REVIEW.md`. */
|
|
104
74
|
const answerReviewQuestions = (notes) => agentWithSkills("review.answer-review-questions", buildReviewAnswerQuestionsPrompt(notes), {
|
|
105
75
|
label: "Answering your questions",
|
|
@@ -147,113 +117,6 @@ const deferNotes = (notes, round) => run("review.defer", deferNotesScript(notes,
|
|
|
147
117
|
file: ".gtd/TODO.md"
|
|
148
118
|
});
|
|
149
119
|
//#endregion
|
|
150
|
-
//#region src/workflows/feature/packages.ts
|
|
151
|
-
const TEST_LINE = /^-\s+(unit|e2e):\s+`([^`]+)`(.*)$/;
|
|
152
|
-
const MARKER = /^[ \t]+\(unchanged\)(?=\s|$)/i;
|
|
153
|
-
const ITEM = /^\s*-\s/;
|
|
154
|
-
/** The `## Tests` entries of a package file; malformed entries are ignored. */
|
|
155
|
-
const declaredTests = (packageText) => {
|
|
156
|
-
const found = [];
|
|
157
|
-
let inTests = false;
|
|
158
|
-
let entry;
|
|
159
|
-
const flush = () => {
|
|
160
|
-
const match = entry === void 0 ? null : TEST_LINE.exec(entry);
|
|
161
|
-
if (match) found.push({
|
|
162
|
-
level: match[1],
|
|
163
|
-
path: match[2],
|
|
164
|
-
unchanged: MARKER.test(match[3])
|
|
165
|
-
});
|
|
166
|
-
entry = void 0;
|
|
167
|
-
};
|
|
168
|
-
for (const line of packageText.replace(/\r\n?/g, "\n").split("\n")) if (/^##\s/.test(line)) {
|
|
169
|
-
flush();
|
|
170
|
-
inTests = /^##\s+Tests\s*$/.test(line);
|
|
171
|
-
} else if (!inTests) continue;
|
|
172
|
-
else if (ITEM.test(line)) {
|
|
173
|
-
flush();
|
|
174
|
-
entry = line.trim();
|
|
175
|
-
} else if (entry !== void 0 && /^\s/.test(line) && line.trim() !== "") entry = `${entry} ${line.trim()}`;
|
|
176
|
-
else flush();
|
|
177
|
-
flush();
|
|
178
|
-
return found;
|
|
179
|
-
};
|
|
180
|
-
const diffBucket = (test, status, turn, satisfied) => {
|
|
181
|
-
if (test.unchanged) {
|
|
182
|
-
if (turn !== "build") return void 0;
|
|
183
|
-
if (status === "modified") return "modifiedMarked";
|
|
184
|
-
return status === "added" ? "addedMarked" : void 0;
|
|
185
|
-
}
|
|
186
|
-
return !satisfied && (status === void 0 || status === "deleted") ? "notInDiff" : void 0;
|
|
187
|
-
};
|
|
188
|
-
const treeBucket = (test, status) => {
|
|
189
|
-
if (read(test.path) !== void 0) return void 0;
|
|
190
|
-
return test.unchanged && status === "deleted" ? "deletedMarked" : "notInTree";
|
|
191
|
-
};
|
|
192
|
-
const unmetBucket = (test, change, turn, satisfied) => diffBucket(test, change?.status, turn, satisfied) ?? treeBucket(test, change?.status);
|
|
193
|
-
/**
|
|
194
|
-
* Refuse a build or fix turn unless every test `pkg` declares exists and is in the
|
|
195
|
-
* diff since `since`. The package text is read as of `since`, so a builder
|
|
196
|
-
* editing its own `## Tests` (or adding `(unchanged)`) cannot dodge the guard;
|
|
197
|
-
* with `.gtd/SATISFIED.md` written, or the line marked, existing suffices. The
|
|
198
|
-
* build turn must neither modify nor add a marked test; a fix turn may edit it.
|
|
199
|
-
*/
|
|
200
|
-
const requireDeclaredTests = (pkg, since, turn = "fix") => {
|
|
201
|
-
const range = changesSince(since);
|
|
202
|
-
const text = range.get(pkg)?.before ?? read(pkg) ?? "";
|
|
203
|
-
const satisfied = read(".gtd/SATISFIED.md") !== void 0;
|
|
204
|
-
const buckets = {
|
|
205
|
-
notInDiff: [],
|
|
206
|
-
modifiedMarked: [],
|
|
207
|
-
addedMarked: [],
|
|
208
|
-
deletedMarked: [],
|
|
209
|
-
notInTree: []
|
|
210
|
-
};
|
|
211
|
-
const entries = declaredTests(text);
|
|
212
|
-
const seen = /* @__PURE__ */ new Set();
|
|
213
|
-
for (const test of entries) {
|
|
214
|
-
if (test.unchanged && entries.some((e) => !e.unchanged && e.path === test.path)) continue;
|
|
215
|
-
const bucket = unmetBucket(test, range.get(test.path), turn, satisfied);
|
|
216
|
-
if (bucket && !seen.has(test.path)) {
|
|
217
|
-
seen.add(test.path);
|
|
218
|
-
buckets[bucket].push(test.path);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
const { notInDiff, modifiedMarked, addedMarked, deletedMarked, notInTree } = buckets;
|
|
222
|
-
const reasons = [
|
|
223
|
-
["not in the package's diff — write them", notInDiff],
|
|
224
|
-
["marked (unchanged) but modified — revert them", modifiedMarked],
|
|
225
|
-
["marked (unchanged) but added by this turn — the marker promises an existing test; stop and leave it to a person", addedMarked],
|
|
226
|
-
["marked (unchanged) but deleted — restore them", deletedMarked],
|
|
227
|
-
["missing from the tree", notInTree]
|
|
228
|
-
].filter(([, paths]) => paths.length > 0);
|
|
229
|
-
if (reasons.length === 0) return;
|
|
230
|
-
refuse(`gtd land: declared-tests: ${pkg} declares tests this turn left unmet:\n${reasons.map(([reason, paths]) => `${reason}:\n${paths.map((path) => ` - ${path}`).join("\n")}`).join("\n")}`);
|
|
231
|
-
};
|
|
232
|
-
/**
|
|
233
|
-
* Build `pkg`, check its declared tests, keep the full suite green, and close
|
|
234
|
-
* it out, which removes it — and `.gtd/ARCHITECTURE.md` with the last one.
|
|
235
|
-
*/
|
|
236
|
-
const packageItem = async (pkg) => {
|
|
237
|
-
const from = head();
|
|
238
|
-
const declared = (turn, kind) => async () => {
|
|
239
|
-
await turn();
|
|
240
|
-
requireDeclaredTests(pkg, from, kind);
|
|
241
|
-
};
|
|
242
|
-
await declared(() => build(pkg), "build")();
|
|
243
|
-
await healthy(declared(fixSuite, "fix"));
|
|
244
|
-
await run("closing", removeScript([
|
|
245
|
-
pkg,
|
|
246
|
-
".gtd/SATISFIED.md",
|
|
247
|
-
...glob(".gtd/packages/*.md").length === 1 ? [ARCHITECTURE] : []
|
|
248
|
-
]), { label: "Closing out the package" });
|
|
249
|
-
};
|
|
250
|
-
/** The first queued package — the one a package step works on. */
|
|
251
|
-
const nextPackage = () => [...glob(".gtd/packages/*.md")].sort()[0];
|
|
252
|
-
/** Build every package file under `.gtd/packages/`, in name order. */
|
|
253
|
-
const packages = () => scope("packages", async () => {
|
|
254
|
-
for (let pkg = nextPackage(); pkg !== void 0; pkg = nextPackage()) await scope("item", () => packageItem(pkg));
|
|
255
|
-
});
|
|
256
|
-
//#endregion
|
|
257
120
|
//#region src/workflows/feature/planning.ts
|
|
258
121
|
/**
|
|
259
122
|
* Always stop for the human. Resolves `true` when the round changed anything
|
|
@@ -276,16 +139,6 @@ const design = (base) => scope("design", async () => {
|
|
|
276
139
|
requireReplies(REQUIREMENTS);
|
|
277
140
|
} while (await noteGate(REQUIREMENTS, answerProductQuestions));
|
|
278
141
|
});
|
|
279
|
-
/** Work out the technical plan, then decompose it into package files. */
|
|
280
|
-
const architecture = () => scope("architecture", async () => {
|
|
281
|
-
do {
|
|
282
|
-
await author();
|
|
283
|
-
requireReplies(ARCHITECTURE);
|
|
284
|
-
} while (await noteGate(ARCHITECTURE, answerTechnicalQuestions));
|
|
285
|
-
await decompose();
|
|
286
|
-
if (changes(".gtd/packages/**").length === 0) refuse("gtd land: decompose: write at least one package under .gtd/packages/");
|
|
287
|
-
if (changes(".gtd/ARCHITECTURE.md").some((c) => c.status === "deleted")) refuse("gtd land: decompose: keep .gtd/ARCHITECTURE.md — the packages are built from it; it is removed with the last package");
|
|
288
|
-
});
|
|
289
142
|
//#endregion
|
|
290
143
|
//#region src/workflows/feature/review.ts
|
|
291
144
|
const VERDICTS = [
|
|
@@ -853,27 +706,8 @@ const unwind = () => {
|
|
|
853
706
|
const commit = head();
|
|
854
707
|
return run("unwind", revertScript(commit, FEEDBACK, unwindFailure(commit)), { label: "Unwinding your input" });
|
|
855
708
|
};
|
|
856
|
-
/**
|
|
857
|
-
const
|
|
858
|
-
if (vars.flag_naive_pass === "1") return naivePlanAndBuild(firstBase);
|
|
859
|
-
let base = firstBase;
|
|
860
|
-
let lap = firstLap;
|
|
861
|
-
for (;;) {
|
|
862
|
-
await design(base);
|
|
863
|
-
await architecture();
|
|
864
|
-
await packages();
|
|
865
|
-
const outcome = await buildTail(base, {
|
|
866
|
-
green: true,
|
|
867
|
-
lap
|
|
868
|
-
});
|
|
869
|
-
if (outcome.verdict === "signoff") return;
|
|
870
|
-
await reUnwind(outcome);
|
|
871
|
-
base = outcome.base;
|
|
872
|
-
lap = "feedback";
|
|
873
|
-
}
|
|
874
|
-
};
|
|
875
|
-
const naivePlanAndBuild = async (firstBase) => {
|
|
876
|
-
await design(firstBase);
|
|
709
|
+
/** A judged naive pass, then implement and review until a review round signs off; design must have run since `firstBase`. */
|
|
710
|
+
const buildFeature = async (firstBase) => {
|
|
877
711
|
const tier = riskTierOn() ? recordRisk() : void 0;
|
|
878
712
|
const seam = await seamBuild(firstBase);
|
|
879
713
|
await slice("feature", async () => {
|
|
@@ -889,6 +723,11 @@ const naivePlanAndBuild = async (firstBase) => {
|
|
|
889
723
|
}), base);
|
|
890
724
|
});
|
|
891
725
|
};
|
|
726
|
+
/** Design, then `buildFeature`. */
|
|
727
|
+
const planAndBuild = async (firstBase) => {
|
|
728
|
+
await design(firstBase);
|
|
729
|
+
await buildFeature(firstBase);
|
|
730
|
+
};
|
|
892
731
|
const featureFeedback = async (first, lensBase) => {
|
|
893
732
|
let outcome = first;
|
|
894
733
|
while (outcome.verdict === "feedback") {
|
|
@@ -906,12 +745,7 @@ const featureFeedback = async (first, lensBase) => {
|
|
|
906
745
|
}
|
|
907
746
|
};
|
|
908
747
|
/** After a tail that skipped planning: stop on sign-off, re-plan on feedback. Opens no slice: slices cannot nest. */
|
|
909
|
-
const afterTail =
|
|
910
|
-
if (outcome.verdict === "signoff") return;
|
|
911
|
-
if (vars.flag_naive_pass === "1") return featureFeedback(outcome);
|
|
912
|
-
await reUnwind(outcome);
|
|
913
|
-
await planAndBuild(outcome.base, "feedback");
|
|
914
|
-
};
|
|
748
|
+
const afterTail = (outcome) => featureFeedback(outcome);
|
|
915
749
|
/** An ordinary start: wait at `idle`, unwind the sketch, check the baseline, then plan and build. */
|
|
916
750
|
const ordinaryStart = async () => {
|
|
917
751
|
await human("idle", {
|
|
@@ -934,4 +768,4 @@ const feature = shipped(ordinaryStart);
|
|
|
934
768
|
//#region src/workflows/feature/index.ts
|
|
935
769
|
var feature_default = feature;
|
|
936
770
|
//#endregion
|
|
937
|
-
export { REFACTOR_PACKAGE, access, afterTail, answerProductQuestions, answerReviewQuestions,
|
|
771
|
+
export { REFACTOR_PACKAGE, access, afterTail, answerProductQuestions, answerReviewQuestions, buildFeature, buildTail, collecting, feature_default as default, defaults, deferNotes, design, envDefaults, feature, fixNits, implement, judgeSeam, naivePass, ordinaryStart, planAndBuild, reUnwind, refactorCollecting, refactorFirst, refactorStrays, requireNoNewFlags, requireNoTestFiles, requireTestCallSitesOnly, requireWithin, reviewedRefactor, seamBuild, skills, steering, summary, triage, unwind };
|
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
|
-
import {
|
|
4
|
-
import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-
|
|
3
|
+
import { E as agentWithSkills, L as finisherSystem, R as fixReviewNotesPrompt, at as envDefaults, g as REVIEW, i as skills, it as defaults, n as summary, o as shipped, r as access, s as baseline, t as steering, v as coder } from "../chunks/reserved-iG86IR3I.mjs";
|
|
4
|
+
import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-DA2UuJNP.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
|
-
import {
|
|
3
|
-
import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-
|
|
2
|
+
import { X as reviewGateBlockedMessage, at as envDefaults, i as skills, it as defaults, l as gate, n as summary, o as shipped, r as access, t as steering } from "../chunks/reserved-iG86IR3I.mjs";
|
|
3
|
+
import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-DA2UuJNP.mjs";
|
|
4
4
|
//#region src/workflows/review/review.ts
|
|
5
5
|
/**
|
|
6
6
|
* Pure review of everything since `reviewBase`: a green gate, the quality
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { agent, changesSince, glob, head, mark, read, refuse, removeScript, run, scope, vars } from "../flows.mjs";
|
|
2
|
-
import { a as flagShipped,
|
|
2
|
+
import { a as flagShipped, at as envDefaults, dt as requireFlag, f as FEEDBACK, i as skills, it as defaults, l as gate, lt as flipProblems, n as summary, r as access, st as flagMentions, t as steering, v as coder } from "../chunks/reserved-iG86IR3I.mjs";
|
|
3
3
|
//#region src/workflows/rollout/rollout.ts
|
|
4
4
|
const flippingPrompt = (name, registry) => `Roll out the release flag "${name}": in ${registry}, set \`defaultOn: true\` on its entry. Change nothing else — no other entry, no other field, no other file, no comments.`;
|
|
5
5
|
const suiteBlockedMessage = () => "The suite is red after the rollout. Fix it by hand — a rollout may only touch the flag's registry entry — then land.";
|