@pmelab/gtd 27.0.0 → 27.2.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 +16 -9
- package/dist/chunks/registry-CP3YuV9j.mjs +101 -0
- package/dist/chunks/{reserved-BL9krPao.mjs → reserved-B2QaImnN.mjs} +41 -73
- package/dist/chunks/tail-BnhIxCYv.mjs +81 -0
- package/dist/flows.mjs +74 -1
- package/dist/gtd.bundle.mjs +79 -3
- package/dist/testkit.mjs +5 -2
- package/dist/workflows/cleanup.mjs +37 -0
- package/dist/workflows/feature.mjs +2 -1
- package/dist/workflows/fix.mjs +2 -1
- package/dist/workflows/review.mjs +2 -1
- package/dist/workflows/rollout.mjs +33 -0
- package/dist/workflows.mjs +225 -21
- package/package.json +11 -1
- package/src/flags/index.test.ts +28 -0
- package/src/flags/index.ts +9 -4
- package/src/flows/helpers.ts +111 -0
- package/src/flows/runtime.ts +5 -2
- package/src/replay/judgeDiffShape.test.ts +208 -0
- package/src/testkit/steps.ts +26 -0
- package/src/testkit/testkit.feature +8 -1
- package/src/workflows/cleanup/cleanup.feature +323 -0
- package/src/workflows/cleanup/cleanup.test.ts +272 -0
- package/src/workflows/cleanup/cleanup.ts +52 -0
- package/src/workflows/cleanup/index.ts +6 -0
- package/src/workflows/index.ts +3 -1
- package/src/workflows/rollout/index.ts +6 -0
- package/src/workflows/rollout/rollout.feature +214 -0
- package/src/workflows/rollout/rollout.test.ts +167 -0
- package/src/workflows/rollout/rollout.ts +44 -0
- package/src/workflows/shared/health.test.ts +39 -0
- package/src/workflows/shared/health.ts +30 -7
- package/src/workflows/shared/options.test.ts +22 -0
- package/src/workflows/shared/options.ts +19 -0
- package/src/workflows/shared/registry.test.ts +305 -0
- package/src/workflows/shared/registry.ts +158 -0
- package/src/workflows/shared/tail.test.ts +36 -2
- package/src/workflows/shared/tail.ts +30 -18
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { agent, changes, glob, read, refuse, scope, start, vars } from "../flows.mjs";
|
|
2
|
+
import { a as flagShipped, b as coder, c as envDefaults, i as skills, n as summary, r as access, s as defaults, t as steering } from "../chunks/reserved-B2QaImnN.mjs";
|
|
3
|
+
import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-BnhIxCYv.mjs";
|
|
4
|
+
import { i as requireFlag, r as registryEntry, t as flagReferences } from "../chunks/registry-CP3YuV9j.mjs";
|
|
5
|
+
//#region src/workflows/cleanup/cleanup.ts
|
|
6
|
+
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.`;
|
|
7
|
+
const cleanupFlow = async () => {
|
|
8
|
+
const registry = vars.flagRegistry ?? "";
|
|
9
|
+
const name = vars.flag ?? "";
|
|
10
|
+
refusePendingHandOff();
|
|
11
|
+
requireFlag("cleanup", registry, name, true);
|
|
12
|
+
let scanned = false;
|
|
13
|
+
const guard = () => {
|
|
14
|
+
const turn = scanned ? changes() : void 0;
|
|
15
|
+
if ((turn === void 0 || turn.some((c) => c.path === registry)) && registryEntry(read(registry) ?? "", name) !== void 0) refuse(`gtd land: cleanup: ${registry} still declares flag "${name}"`);
|
|
16
|
+
const hits = flagReferences((turn === void 0 ? [...glob("src/**/*.ts"), ...glob("src/**/*.tsx")] : turn.filter((c) => c.status !== "deleted").map((c) => c.path).filter((path) => path.startsWith("src/") && /\.tsx?$/.test(path))).map((path) => ({
|
|
17
|
+
path,
|
|
18
|
+
text: read(path) ?? ""
|
|
19
|
+
})), name);
|
|
20
|
+
if (hits.length > 0) refuse(`gtd land: cleanup: ${hits.join(", ")} still names GTD_FLAG_${name.toUpperCase()}`);
|
|
21
|
+
};
|
|
22
|
+
await scope("cleanup", async () => {
|
|
23
|
+
await agent("removing", removingPrompt(name, registry), {
|
|
24
|
+
label: `Removing ${name}`,
|
|
25
|
+
model: coder()
|
|
26
|
+
});
|
|
27
|
+
guard();
|
|
28
|
+
scanned = true;
|
|
29
|
+
});
|
|
30
|
+
await handOffTail(false, start(), guard);
|
|
31
|
+
};
|
|
32
|
+
const cleanup = flagShipped(cleanupFlow);
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region src/workflows/cleanup/index.ts
|
|
35
|
+
var cleanup_default = cleanup;
|
|
36
|
+
//#endregion
|
|
37
|
+
export { access, cleanup, cleanupFlow, cleanup_default as default, defaults, envDefaults, skills, steering, summary };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { answered, changes, changesSince, glob, hasThreadFor, head, human, judge, numeric, read, refuse, removeScript, requireAnswers, requireProgress, requireReplies, requireRevert, requireThreadsClosed, restoreScript, revertScript, run, scope, start, vars } from "../flows.mjs";
|
|
2
2
|
import { review as review$1 } from "../workflow-kit.mjs";
|
|
3
|
-
import { $ as
|
|
3
|
+
import { $ as reviewerSystem, A as architectureAuthorPrompt, B as designGateAnswerMessage, C as fixRisks, D as reviewing, F as buildReviewCollectingPrompt, H as designTriagePrompt, I as buildReviewFixNitsPrompt, J as packagesItemFixSuitePrompt, K as idleMessage, L as buildReviewTriageMessage, M as architectureGateAnswerMessage, N as buildReviewAnswerQuestionsPrompt, O as agentWithSkills, P as buildReviewAwaitAfterDeferMessage, Q as reviewNotesCapture, R as builderSystem, T as reviewMissing, V as designSystem, X as reviewEditsCapture, Y as reviewEditNotesCapture, _ as REQUIREMENTS, b as coder, c as envDefaults, d as gate, et as startGateBlockedMessage, h as FEEDBACK, i as skills, j as architectureDecomposePrompt, k as architectSystem, m as ARCHITECTURE, n as summary, nt as unwindFailure, o as shipped, p as healthy, q as packagesItemBuildingPrompt, r as access, s as defaults, t as steering, tt as unwindFailedMessage, v as REVIEW, w as planner, x as fixCheck, y as awaitReview, z as deferNotesScript } from "../chunks/reserved-B2QaImnN.mjs";
|
|
4
|
+
import { a as tail, i as refusePendingHandOff, n as closeReview } from "../chunks/tail-BnhIxCYv.mjs";
|
|
4
5
|
//#region src/workflows/feature/steps.ts
|
|
5
6
|
/** Turn the sketch since `base` into `.gtd/REQUIREMENTS.md`. */
|
|
6
7
|
const triage = (base) => agentWithSkills("triage", designTriagePrompt(base), {
|
package/dist/workflows/fix.mjs
CHANGED
|
@@ -1,6 +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 {
|
|
3
|
+
import { O as agentWithSkills, U as finisherSystem, W as fixReviewNotesPrompt, b as coder, c as envDefaults, i as skills, l as baseline, n as summary, o as shipped, r as access, s as defaults, t as steering, v as REVIEW } from "../chunks/reserved-B2QaImnN.mjs";
|
|
4
|
+
import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-BnhIxCYv.mjs";
|
|
4
5
|
//#region src/workflows/fix/fix.ts
|
|
5
6
|
/**
|
|
6
7
|
* Fix every note a human left in a handed-off `.gtd/REVIEW.md` and every
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { start } from "../flows.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { Z as reviewGateBlockedMessage, c as envDefaults, d as gate, i as skills, n as summary, o as shipped, r as access, s as defaults, t as steering } from "../chunks/reserved-B2QaImnN.mjs";
|
|
3
|
+
import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-BnhIxCYv.mjs";
|
|
3
4
|
//#region src/workflows/review/review.ts
|
|
4
5
|
/**
|
|
5
6
|
* Pure review of everything since `reviewBase`: a green gate, the quality
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { agent, changesSince, head, read, refuse, scope, vars } from "../flows.mjs";
|
|
2
|
+
import { a as flagShipped, b as coder, c as envDefaults, d as gate, i as skills, n as summary, r as access, s as defaults, t as steering } from "../chunks/reserved-B2QaImnN.mjs";
|
|
3
|
+
import { i as requireFlag, n as flipProblems } from "../chunks/registry-CP3YuV9j.mjs";
|
|
4
|
+
//#region src/workflows/rollout/rollout.ts
|
|
5
|
+
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.`;
|
|
6
|
+
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.";
|
|
7
|
+
const rolloutFlow = async () => {
|
|
8
|
+
const registry = vars.flagRegistry ?? "";
|
|
9
|
+
const name = vars.flag ?? "";
|
|
10
|
+
requireFlag("rollout", registry, name, false);
|
|
11
|
+
const base = head();
|
|
12
|
+
const before = read(registry) ?? "";
|
|
13
|
+
const guard = () => {
|
|
14
|
+
const strays = changesSince(base).paths.filter((path) => path !== registry && path !== ".gtd/FEEDBACK.md");
|
|
15
|
+
if (strays.length > 0) refuse(`gtd land: rollout-only: ${strays.join(", ")} changed — rollout may only set defaultOn: true on "${name}" in ${registry}`);
|
|
16
|
+
const problems = flipProblems(before, read(registry) ?? "", name);
|
|
17
|
+
if (problems.length > 0) refuse(`gtd land: rollout-only: other edits in ${registry}: ${problems.join("; ")}`);
|
|
18
|
+
};
|
|
19
|
+
await scope("rollout", async () => {
|
|
20
|
+
await agent("flipping", flippingPrompt(name, registry), {
|
|
21
|
+
label: `Rolling out ${name}`,
|
|
22
|
+
model: coder()
|
|
23
|
+
});
|
|
24
|
+
guard();
|
|
25
|
+
await gate("suite", suiteBlockedMessage(), guard);
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
const rollout = flagShipped(rolloutFlow);
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/workflows/rollout/index.ts
|
|
31
|
+
var rollout_default = rollout;
|
|
32
|
+
//#endregion
|
|
33
|
+
export { access, rollout_default as default, defaults, envDefaults, rollout, rolloutFlow, skills, steering, summary };
|
package/dist/workflows.mjs
CHANGED
|
@@ -295,6 +295,11 @@ const requireRevert = (edited, base) => {
|
|
|
295
295
|
const quoted = residue.map((path) => `'${path.replace(/'/g, "'\\''")}'`).join(" ");
|
|
296
296
|
refuse(`gtd land: require-revert: ${residue.join(", ")} still differ from ${base}~1 — the revert did not take. Run \`git checkout ${base}~1 -- ${quoted}\`, then \`gtd land\` again.`);
|
|
297
297
|
};
|
|
298
|
+
[
|
|
299
|
+
"land: every stray is incidental (a needed import, a test fixture, a renamed call site).",
|
|
300
|
+
"refactor-first: the strays are a seam change the task needs; land them first as a behaviour-neutral refactor.",
|
|
301
|
+
"reject: the naive pass solved the task the wrong way; discard it and re-implement, no human; strays a refactor could absorb never count as reject."
|
|
302
|
+
].join("\n");
|
|
298
303
|
//#endregion
|
|
299
304
|
//#region src/flows/factory.ts
|
|
300
305
|
const FACTORY = Symbol.for("@pmelab/gtd/workflow-factory");
|
|
@@ -17800,12 +17805,22 @@ const escalationSteps = {
|
|
|
17800
17805
|
stop: escalate,
|
|
17801
17806
|
exhausted: escalationExhausted
|
|
17802
17807
|
};
|
|
17808
|
+
const guarded = (turn, guard) => async (...a) => {
|
|
17809
|
+
await turn(...a);
|
|
17810
|
+
guard?.();
|
|
17811
|
+
};
|
|
17812
|
+
const guardedSteps = (guard) => ({
|
|
17813
|
+
limit: escalationSteps.limit,
|
|
17814
|
+
describe: guarded(escalationSteps.describe, guard),
|
|
17815
|
+
stop: guarded(escalationSteps.stop, guard),
|
|
17816
|
+
exhausted: guarded(escalationSteps.exhausted, guard)
|
|
17817
|
+
});
|
|
17803
17818
|
/**
|
|
17804
17819
|
* Hand a red suite that repeated attempts could not fix to a person: an
|
|
17805
17820
|
* agent writes `.gtd/ESCALATION.md`, a human narrows it. From the third round
|
|
17806
17821
|
* on, the human is told the budget is spent instead.
|
|
17807
17822
|
*/
|
|
17808
|
-
const escalation = (count) => escalation$1(count,
|
|
17823
|
+
const escalation = (count, guard) => escalation$1(count, guardedSteps(guard));
|
|
17809
17824
|
/** "Is this red round the same failure as the last one?" — asked of two reports, or of nothing when one is empty. */
|
|
17810
17825
|
const retryQuestion = (comparable) => comparable ? {
|
|
17811
17826
|
id: "verdict",
|
|
@@ -17841,21 +17856,25 @@ const runSuite = (options) => check("health.check", env.testCommand ?? "", {
|
|
|
17841
17856
|
* first once `FIX_CAP` fixes are spent, or a judge calls the failure
|
|
17842
17857
|
* identical to the previous round's.
|
|
17843
17858
|
*/
|
|
17844
|
-
const healthy = (fix, options = {}) => healthy$1(fix, {
|
|
17859
|
+
const healthy = (fix, options = {}) => healthy$1(guarded(fix, options.guard), {
|
|
17845
17860
|
check: () => runSuite(options),
|
|
17846
17861
|
report: FEEDBACK,
|
|
17847
17862
|
fixCap: 3,
|
|
17848
17863
|
fixesSoFar: options.fixesSoFar ?? 0,
|
|
17849
17864
|
sameFailure,
|
|
17850
17865
|
escalations: options.escalations ?? { rounds: 0 },
|
|
17851
|
-
escalation:
|
|
17866
|
+
escalation: guardedSteps(options.guard)
|
|
17852
17867
|
});
|
|
17853
|
-
/**
|
|
17854
|
-
|
|
17868
|
+
/**
|
|
17869
|
+
* Hold the process at `<name>.blocked` until the suite is green. `guard` runs
|
|
17870
|
+
* after each `blocked` landing only — never after a check, whose refusal would
|
|
17871
|
+
* re-run the same script forever.
|
|
17872
|
+
*/
|
|
17873
|
+
const gate = (name, message, guard) => gate$1(name, () => baseline("check"), guarded(() => human("blocked", {
|
|
17855
17874
|
message,
|
|
17856
17875
|
label: "Baseline is red",
|
|
17857
17876
|
file: FEEDBACK
|
|
17858
|
-
}));
|
|
17877
|
+
}), guard));
|
|
17859
17878
|
//#endregion
|
|
17860
17879
|
//#region node_modules/effect/dist/esm/Function.js
|
|
17861
17880
|
/**
|
|
@@ -37216,6 +37235,27 @@ const shipped = (flow, doors, base) => defineWorkflow(standardSchemaV1(ShippedOp
|
|
|
37216
37235
|
doors,
|
|
37217
37236
|
base
|
|
37218
37237
|
}));
|
|
37238
|
+
const FlagOptions = Struct({
|
|
37239
|
+
...ShippedOptions.fields,
|
|
37240
|
+
flagRegistry: setting
|
|
37241
|
+
});
|
|
37242
|
+
const flagShipped = (flow) => defineWorkflow(standardSchemaV1(FlagOptions, {
|
|
37243
|
+
onExcessProperty: "error",
|
|
37244
|
+
errors: "all"
|
|
37245
|
+
}), (options) => {
|
|
37246
|
+
const flagDefaults = {
|
|
37247
|
+
...defaults,
|
|
37248
|
+
flag: "",
|
|
37249
|
+
flagRegistry: "src/flags/index.ts"
|
|
37250
|
+
};
|
|
37251
|
+
return {
|
|
37252
|
+
flow,
|
|
37253
|
+
defaults: over(flagDefaults, options),
|
|
37254
|
+
envDefaults: over(envDefaults, options),
|
|
37255
|
+
doors: {},
|
|
37256
|
+
base: void 0
|
|
37257
|
+
};
|
|
37258
|
+
});
|
|
37219
37259
|
//#endregion
|
|
37220
37260
|
//#region src/workflows/shared/tail.ts
|
|
37221
37261
|
/** The reviewer's own `.gtd/REVIEW.md`, kept beside the human's notes so `fix` can tell them apart. */
|
|
@@ -37225,13 +37265,13 @@ const REVIEW_ORIG = ".gtd/REVIEW.orig.md";
|
|
|
37225
37265
|
* finding to `.gtd/QUALITY.md`. Resolves `"findings"` when that file
|
|
37226
37266
|
* has any.
|
|
37227
37267
|
*/
|
|
37228
|
-
const qualityLap = () => qualityLap$1(lensesOf(vars.qualityReviews ?? ""), reviewQuality, QUALITY);
|
|
37268
|
+
const qualityLap = (guard) => qualityLap$1(lensesOf(vars.qualityReviews ?? ""), guarded(reviewQuality, guard), QUALITY);
|
|
37229
37269
|
/** Resolves `true` once `.gtd/QUALITY.md` is resolved, `false` when the fix cap escalated instead. */
|
|
37230
|
-
const fixQualityFindings = (escalations) => fixFindings({
|
|
37231
|
-
fix: fixQuality,
|
|
37270
|
+
const fixQualityFindings = (escalations, guard) => fixFindings({
|
|
37271
|
+
fix: guarded(fixQuality, guard),
|
|
37232
37272
|
findings: QUALITY,
|
|
37233
37273
|
cap: 3,
|
|
37234
|
-
escalate: () => escalation(escalations)
|
|
37274
|
+
escalate: () => escalation(escalations, guard)
|
|
37235
37275
|
});
|
|
37236
37276
|
/** `.gtd/REVIEW.md` removed at the end of a round at commit `round`. */
|
|
37237
37277
|
const closeReview = (round) => run("review.closing", removeScript([REVIEW, REVIEW_ORIG]), {
|
|
@@ -37247,19 +37287,26 @@ const refusePendingHandOff = () => {
|
|
|
37247
37287
|
* `finish` — the human review, as the calling workflow runs it. `green`: the
|
|
37248
37288
|
* caller just left the full suite green (the package loop), so no run opens it.
|
|
37249
37289
|
*/
|
|
37250
|
-
const tail = (fixFirst, finish, green = false) => scope("build", async () => {
|
|
37290
|
+
const tail = (fixFirst, finish, green = false, guard) => scope("build", async () => {
|
|
37251
37291
|
const escalations = { rounds: 0 };
|
|
37252
37292
|
const fix = fixCheck;
|
|
37253
37293
|
const repair = async () => {
|
|
37254
|
-
await fix();
|
|
37294
|
+
await guarded(fix, guard)();
|
|
37255
37295
|
await healthy(fix, {
|
|
37256
37296
|
fixesSoFar: 1,
|
|
37257
|
-
escalations
|
|
37297
|
+
escalations,
|
|
37298
|
+
guard
|
|
37258
37299
|
});
|
|
37259
37300
|
};
|
|
37260
37301
|
if (fixFirst) await repair();
|
|
37261
|
-
else if (!green) await healthy(fix, {
|
|
37262
|
-
|
|
37302
|
+
else if (!green) await healthy(fix, {
|
|
37303
|
+
escalations,
|
|
37304
|
+
guard
|
|
37305
|
+
});
|
|
37306
|
+
if (await qualityLap(guard) === "findings") if (await fixQualityFindings(escalations, guard)) await healthy(fix, {
|
|
37307
|
+
escalations,
|
|
37308
|
+
guard
|
|
37309
|
+
});
|
|
37263
37310
|
else await repair();
|
|
37264
37311
|
return finish(escalations);
|
|
37265
37312
|
});
|
|
@@ -37268,11 +37315,14 @@ const tail = (fixFirst, finish, green = false) => scope("build", async () => {
|
|
|
37268
37315
|
* `.gtd/REVIEW.md`; a note leaves it, with the reviewer's own version as
|
|
37269
37316
|
* `REVIEW_ORIG`, for the next `fix` to read — never a re-plan.
|
|
37270
37317
|
*/
|
|
37271
|
-
const handOffReview = (base, escalations) => reviewToHandOff(base, {
|
|
37318
|
+
const handOffReview = (base, escalations, guard) => reviewToHandOff(base, {
|
|
37272
37319
|
file: REVIEW,
|
|
37273
|
-
reviewing,
|
|
37274
|
-
fixRisks,
|
|
37275
|
-
keepGreen: () => healthy(fixCheck, {
|
|
37320
|
+
reviewing: guarded(reviewing, guard),
|
|
37321
|
+
fixRisks: guarded(fixRisks, guard),
|
|
37322
|
+
keepGreen: () => healthy(fixCheck, {
|
|
37323
|
+
escalations,
|
|
37324
|
+
guard
|
|
37325
|
+
}),
|
|
37276
37326
|
awaitReview: (b) => awaitReview(b, handOffAwaitReviewMessage(b)),
|
|
37277
37327
|
reviewMissing,
|
|
37278
37328
|
close: closeReview,
|
|
@@ -37282,7 +37332,7 @@ const handOffReview = (base, escalations) => reviewToHandOff(base, {
|
|
|
37282
37332
|
deleted: "gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note to hand to fix."
|
|
37283
37333
|
});
|
|
37284
37334
|
/** The tail the review and fix workflows run: ends at the hand-off review since `base`. */
|
|
37285
|
-
const handOffTail = (fixFirst, base) => tail(fixFirst, (escalations) => handOffReview(base, escalations));
|
|
37335
|
+
const handOffTail = (fixFirst, base, guard) => tail(fixFirst, (escalations) => handOffReview(base, escalations, guard), false, guard);
|
|
37286
37336
|
//#endregion
|
|
37287
37337
|
//#region src/workflows/feature/steps.ts
|
|
37288
37338
|
/** Turn the sketch since `base` into `.gtd/REQUIREMENTS.md`. */
|
|
@@ -37784,6 +37834,160 @@ const fixFlow = async () => {
|
|
|
37784
37834
|
};
|
|
37785
37835
|
const fix = shipped(fixFlow, { fix: {} });
|
|
37786
37836
|
//#endregion
|
|
37837
|
+
//#region src/workflows/shared/registry.ts
|
|
37838
|
+
const FILL = "";
|
|
37839
|
+
const commentLength = (rest) => {
|
|
37840
|
+
const end = rest.startsWith("//") ? rest.indexOf("\n") : rest.indexOf("*/") + 2;
|
|
37841
|
+
return end <= 0 ? rest.length : end;
|
|
37842
|
+
};
|
|
37843
|
+
const stringEnd = (text, i, quote) => {
|
|
37844
|
+
let j = i + 1;
|
|
37845
|
+
while (j < text.length && text[j] !== quote) j += text[j] === "\\" ? 2 : 1;
|
|
37846
|
+
return Math.min(j, text.length);
|
|
37847
|
+
};
|
|
37848
|
+
const mask = (text) => {
|
|
37849
|
+
let out = "";
|
|
37850
|
+
let i = 0;
|
|
37851
|
+
while (i < text.length) {
|
|
37852
|
+
const c = text[i];
|
|
37853
|
+
const rest = text.slice(i);
|
|
37854
|
+
if (rest.startsWith("//") || rest.startsWith("/*")) {
|
|
37855
|
+
const len = commentLength(rest);
|
|
37856
|
+
out += FILL.repeat(len);
|
|
37857
|
+
i += len;
|
|
37858
|
+
} else if (c === "\"" || c === "'" || c === "`") {
|
|
37859
|
+
const j = stringEnd(text, i, c);
|
|
37860
|
+
out += c + FILL.repeat(j - i - 1) + (j < text.length ? c : "");
|
|
37861
|
+
i = j + 1;
|
|
37862
|
+
} else {
|
|
37863
|
+
out += c;
|
|
37864
|
+
i += 1;
|
|
37865
|
+
}
|
|
37866
|
+
}
|
|
37867
|
+
return out;
|
|
37868
|
+
};
|
|
37869
|
+
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
37870
|
+
const entrySpan = (text, name) => {
|
|
37871
|
+
const quoted = escapeRegExp(name);
|
|
37872
|
+
const re = new RegExp(`\\bname\\s*:\\s*(["'\`])${quoted}\\1`, "g");
|
|
37873
|
+
const masked = mask(text);
|
|
37874
|
+
for (const hit of text.matchAll(re)) {
|
|
37875
|
+
if (masked[hit.index] === FILL) continue;
|
|
37876
|
+
const start = masked.lastIndexOf("{", hit.index);
|
|
37877
|
+
const close = masked.indexOf("}", hit.index);
|
|
37878
|
+
return start < 0 || close < 0 ? void 0 : {
|
|
37879
|
+
start,
|
|
37880
|
+
end: close + 1
|
|
37881
|
+
};
|
|
37882
|
+
}
|
|
37883
|
+
};
|
|
37884
|
+
const DEFAULT_ON = /\bdefaultOn\s*:\s*(true|false)\s*,?/;
|
|
37885
|
+
const defaultOnHit = (body) => DEFAULT_ON.exec(mask(body));
|
|
37886
|
+
const defaultOnValue = (body) => defaultOnHit(body)?.[1];
|
|
37887
|
+
const KIND = /\bkind\s*:\s*["'`]/;
|
|
37888
|
+
const registryEntry = (text, name) => {
|
|
37889
|
+
const span = entrySpan(text, name);
|
|
37890
|
+
if (span === void 0) return void 0;
|
|
37891
|
+
const body = text.slice(span.start, span.end);
|
|
37892
|
+
const hit = KIND.exec(mask(body));
|
|
37893
|
+
return {
|
|
37894
|
+
kind: (hit === null ? null : /^(\w+)["'`]/.exec(body.slice(hit.index + hit[0].length)))?.[1] ?? "",
|
|
37895
|
+
defaultOn: defaultOnValue(body) === "true"
|
|
37896
|
+
};
|
|
37897
|
+
};
|
|
37898
|
+
const canonical = (body) => {
|
|
37899
|
+
const hit = defaultOnHit(body);
|
|
37900
|
+
const kept = hit === null ? body : body.slice(0, hit.index) + body.slice(hit.index + hit[0].length);
|
|
37901
|
+
const masked = mask(kept);
|
|
37902
|
+
return kept.split("").filter((c, i) => masked[i] === FILL || !(/\s/.test(c) || c === "," && /^\s*}/.test(masked.slice(i + 1)))).join("");
|
|
37903
|
+
};
|
|
37904
|
+
/**
|
|
37905
|
+
* [] when `after` differs from `before` only by the named entry's `defaultOn`
|
|
37906
|
+
* going absent/false → true (whitespace and trailing commas inside that entry
|
|
37907
|
+
* ignored, so oxfmt re-wrapping it passes); else one line per fault.
|
|
37908
|
+
*/
|
|
37909
|
+
const flipProblems = (before, after, name) => {
|
|
37910
|
+
const was = entrySpan(before, name);
|
|
37911
|
+
const now = entrySpan(after, name);
|
|
37912
|
+
if (was === void 0) return [`no entry "${name}" before the turn`];
|
|
37913
|
+
if (now === void 0) return [`entry "${name}" is gone`];
|
|
37914
|
+
const faults = [];
|
|
37915
|
+
if (before.slice(0, was.start) !== after.slice(0, now.start)) faults.push("text before the entry changed");
|
|
37916
|
+
if (before.slice(was.end) !== after.slice(now.end)) faults.push("text after the entry changed");
|
|
37917
|
+
const oldBody = before.slice(was.start, was.end);
|
|
37918
|
+
const newBody = after.slice(now.start, now.end);
|
|
37919
|
+
if (canonical(oldBody) !== canonical(newBody)) faults.push(`entry "${name}" changed beyond defaultOn`);
|
|
37920
|
+
if (defaultOnValue(newBody) !== "true") faults.push(`"${name}" is not set to defaultOn: true`);
|
|
37921
|
+
return faults;
|
|
37922
|
+
};
|
|
37923
|
+
/** `path:line` for every `GTD_FLAG_<NAME>` in shipped sources; re-states scripts/lint-flags.ts' rule by hand. */
|
|
37924
|
+
const flagReferences = (files, name) => {
|
|
37925
|
+
const pattern = new RegExp(`\\bGTD_FLAG_${escapeRegExp(name.toUpperCase())}\\b`);
|
|
37926
|
+
return files.filter(({ path }) => !/\.(test|fixture|stories)\.tsx?$/.test(path)).flatMap(({ path, text }) => text.split("\n").flatMap((line, i) => pattern.test(line) ? [`${path}:${i + 1}`] : []));
|
|
37927
|
+
};
|
|
37928
|
+
const requireFlag = (workflow, registryPath, name, defaultOn) => {
|
|
37929
|
+
if (name === "") return refuse(`gtd: ${workflow} needs --var flag=<name>`);
|
|
37930
|
+
const entry = registryEntry(read(registryPath) ?? "", name);
|
|
37931
|
+
if (entry === void 0) return refuse(`gtd: ${workflow}: no flag "${name}" in ${registryPath}`);
|
|
37932
|
+
if (entry.kind !== "release") return refuse(`gtd: ${workflow}: flag "${name}" is ${entry.kind}, not release`);
|
|
37933
|
+
if (entry.defaultOn !== defaultOn) return refuse(defaultOn ? `gtd: ${workflow}: flag "${name}" is not default-on — roll it out first` : `gtd: ${workflow}: flag "${name}" is already default-on`);
|
|
37934
|
+
};
|
|
37935
|
+
//#endregion
|
|
37936
|
+
//#region src/workflows/rollout/rollout.ts
|
|
37937
|
+
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.`;
|
|
37938
|
+
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.";
|
|
37939
|
+
const rolloutFlow = async () => {
|
|
37940
|
+
const registry = vars.flagRegistry ?? "";
|
|
37941
|
+
const name = vars.flag ?? "";
|
|
37942
|
+
requireFlag("rollout", registry, name, false);
|
|
37943
|
+
const base = head$1();
|
|
37944
|
+
const before = read(registry) ?? "";
|
|
37945
|
+
const guard = () => {
|
|
37946
|
+
const strays = changesSince(base).paths.filter((path) => path !== registry && path !== ".gtd/FEEDBACK.md");
|
|
37947
|
+
if (strays.length > 0) refuse(`gtd land: rollout-only: ${strays.join(", ")} changed — rollout may only set defaultOn: true on "${name}" in ${registry}`);
|
|
37948
|
+
const problems = flipProblems(before, read(registry) ?? "", name);
|
|
37949
|
+
if (problems.length > 0) refuse(`gtd land: rollout-only: other edits in ${registry}: ${problems.join("; ")}`);
|
|
37950
|
+
};
|
|
37951
|
+
await scope("rollout", async () => {
|
|
37952
|
+
await agent("flipping", flippingPrompt(name, registry), {
|
|
37953
|
+
label: `Rolling out ${name}`,
|
|
37954
|
+
model: coder()
|
|
37955
|
+
});
|
|
37956
|
+
guard();
|
|
37957
|
+
await gate("suite", suiteBlockedMessage(), guard);
|
|
37958
|
+
});
|
|
37959
|
+
};
|
|
37960
|
+
const rollout = flagShipped(rolloutFlow);
|
|
37961
|
+
//#endregion
|
|
37962
|
+
//#region src/workflows/cleanup/cleanup.ts
|
|
37963
|
+
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.`;
|
|
37964
|
+
const cleanupFlow = async () => {
|
|
37965
|
+
const registry = vars.flagRegistry ?? "";
|
|
37966
|
+
const name = vars.flag ?? "";
|
|
37967
|
+
refusePendingHandOff();
|
|
37968
|
+
requireFlag("cleanup", registry, name, true);
|
|
37969
|
+
let scanned = false;
|
|
37970
|
+
const guard = () => {
|
|
37971
|
+
const turn = scanned ? changes() : void 0;
|
|
37972
|
+
if ((turn === void 0 || turn.some((c) => c.path === registry)) && registryEntry(read(registry) ?? "", name) !== void 0) refuse(`gtd land: cleanup: ${registry} still declares flag "${name}"`);
|
|
37973
|
+
const hits = flagReferences((turn === void 0 ? [...glob("src/**/*.ts"), ...glob("src/**/*.tsx")] : turn.filter((c) => c.status !== "deleted").map((c) => c.path).filter((path) => path.startsWith("src/") && /\.tsx?$/.test(path))).map((path) => ({
|
|
37974
|
+
path,
|
|
37975
|
+
text: read(path) ?? ""
|
|
37976
|
+
})), name);
|
|
37977
|
+
if (hits.length > 0) refuse(`gtd land: cleanup: ${hits.join(", ")} still names GTD_FLAG_${name.toUpperCase()}`);
|
|
37978
|
+
};
|
|
37979
|
+
await scope("cleanup", async () => {
|
|
37980
|
+
await agent("removing", removingPrompt(name, registry), {
|
|
37981
|
+
label: `Removing ${name}`,
|
|
37982
|
+
model: coder()
|
|
37983
|
+
});
|
|
37984
|
+
guard();
|
|
37985
|
+
scanned = true;
|
|
37986
|
+
});
|
|
37987
|
+
await handOffTail(false, start(), guard);
|
|
37988
|
+
};
|
|
37989
|
+
const cleanup = flagShipped(cleanupFlow);
|
|
37990
|
+
//#endregion
|
|
37787
37991
|
//#region src/workflows/index.ts
|
|
37788
37992
|
var workflows_default = feature;
|
|
37789
37993
|
/**
|
|
@@ -37792,4 +37996,4 @@ var workflows_default = feature;
|
|
|
37792
37996
|
*/
|
|
37793
37997
|
const doors = {};
|
|
37794
37998
|
//#endregion
|
|
37795
|
-
export { ARCHITECTURE, ESCALATION, FEEDBACK, FIX_CAP, QUALITY, REQUIREMENTS, REVIEW, REVIEW_ORIG, ShippedOptions, access, afterTail, agentWithSkills, answerProductQuestions, answerReviewQuestions, answerTechnicalQuestions, architecture, author, awaitReview, base, baseline, build, buildTail, builtInLenses, closeReview, coder, collecting, declaredTests, decompose, workflows_default as default, defaults, deferNotes, describeEscalation, design, doors, envDefaults, escalate, escalation, escalationExhausted, feature, fix, fixCheck, fixFlow, fixNits, fixQuality, fixQualityFindings, fixReviewNotes, fixRisks, fixSuite, gate, handOffReview, handOffTail, healthy, lensesOf, nextPackage, ordinaryStart, packageItem, packages, planAndBuild, planner, qualityLap, reUnwind, requireDeclaredTests, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewing, skills, steering, summary, tail, threads, triage, unwind };
|
|
37999
|
+
export { ARCHITECTURE, ESCALATION, FEEDBACK, FIX_CAP, FlagOptions, QUALITY, REQUIREMENTS, REVIEW, REVIEW_ORIG, ShippedOptions, access, afterTail, agentWithSkills, answerProductQuestions, answerReviewQuestions, answerTechnicalQuestions, architecture, author, awaitReview, base, baseline, build, buildTail, builtInLenses, cleanup, cleanupFlow, closeReview, coder, collecting, declaredTests, decompose, workflows_default as default, defaults, deferNotes, describeEscalation, design, doors, envDefaults, escalate, escalation, escalationExhausted, feature, fix, fixCheck, fixFlow, fixNits, fixQuality, fixQualityFindings, fixReviewNotes, fixRisks, fixSuite, gate, guarded, handOffReview, handOffTail, healthy, lensesOf, nextPackage, ordinaryStart, packageItem, packages, planAndBuild, planner, qualityLap, reUnwind, requireDeclaredTests, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewing, rollout, rolloutFlow, skills, steering, summary, tail, threads, triage, unwind };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pmelab/gtd",
|
|
3
|
-
"version": "27.
|
|
3
|
+
"version": "27.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
|
|
6
6
|
"bin": {
|
|
@@ -52,6 +52,16 @@
|
|
|
52
52
|
"source": "./src/workflows/index.ts",
|
|
53
53
|
"default": "./dist/workflows.mjs"
|
|
54
54
|
},
|
|
55
|
+
"./workflows/rollout": {
|
|
56
|
+
"types": "./src/workflows/rollout/index.ts",
|
|
57
|
+
"source": "./src/workflows/rollout/index.ts",
|
|
58
|
+
"default": "./dist/workflows/rollout.mjs"
|
|
59
|
+
},
|
|
60
|
+
"./workflows/cleanup": {
|
|
61
|
+
"types": "./src/workflows/cleanup/index.ts",
|
|
62
|
+
"source": "./src/workflows/cleanup/index.ts",
|
|
63
|
+
"default": "./dist/workflows/cleanup.mjs"
|
|
64
|
+
},
|
|
55
65
|
"./testkit": {
|
|
56
66
|
"types": "./src/testkit/index.ts",
|
|
57
67
|
"source": "./src/testkit/index.ts",
|
package/src/flags/index.test.ts
CHANGED
|
@@ -55,6 +55,34 @@ describe("resolveFlags", () => {
|
|
|
55
55
|
})
|
|
56
56
|
})
|
|
57
57
|
|
|
58
|
+
describe("resolveFlags defaultOn", () => {
|
|
59
|
+
const on = [
|
|
60
|
+
{ name: "beta", owner: "pmelab", expiry: "2099-01-01", kind: "release", defaultOn: true },
|
|
61
|
+
] as const satisfies readonly FlagDeclaration[]
|
|
62
|
+
|
|
63
|
+
it.each([undefined, ""])("reads unset or empty (%j) as on", (value) => {
|
|
64
|
+
expect(resolveFlags({ GTD_FLAG_BETA: value }, on).beta).toBe(true)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it.each(["0", "false", "FALSE"])("%s still forces a default-on flag off", (value) => {
|
|
68
|
+
expect(resolveFlags({ GTD_FLAG_BETA: value }, on).beta).toBe(false)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it.each(["1", "true"])("%s forces a default-on flag on", (value) => {
|
|
72
|
+
expect(resolveFlags({ GTD_FLAG_BETA: value }, on).beta).toBe(true)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("still throws for a garbled value", () => {
|
|
76
|
+
expect(() => resolveFlags({ GTD_FLAG_BETA: "ture" }, on)).toThrow(
|
|
77
|
+
/^GTD_FLAG_BETA must be 1, 0, true or false$/,
|
|
78
|
+
)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("reads an entry without defaultOn as off when unset", () => {
|
|
82
|
+
expect(resolveFlags({}, [...on, ...registry]).alpha).toBe(false)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
58
86
|
describe("FLAGS", () => {
|
|
59
87
|
it("declares settable, unique variable names", () => {
|
|
60
88
|
// Vacuous while FLAGS is empty; guards the first declared flag.
|
package/src/flags/index.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface FlagDeclaration {
|
|
|
9
9
|
/** ISO `YYYY-MM-DD`; never enforced at runtime. */
|
|
10
10
|
readonly expiry: string
|
|
11
11
|
readonly kind: FlagKind
|
|
12
|
+
/** Unset or empty resolves on; `0`/`false` is still the kill switch. */
|
|
13
|
+
readonly defaultOn?: boolean
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
export const FLAGS = [] as const satisfies readonly FlagDeclaration[]
|
|
@@ -18,17 +20,20 @@ export type Flags<R extends readonly FlagDeclaration[] = typeof FLAGS> = {
|
|
|
18
20
|
readonly [N in R[number]["name"]]: boolean
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
const BOOL: Record<string, boolean> = { "
|
|
23
|
+
const BOOL: Record<string, boolean> = { "0": false, false: false, "1": true, true: true }
|
|
22
24
|
|
|
23
25
|
export const resolveFlags = <const R extends readonly FlagDeclaration[] = typeof FLAGS>(
|
|
24
26
|
env: Readonly<Record<string, string | undefined>>,
|
|
25
27
|
registry: R = FLAGS as unknown as R,
|
|
26
28
|
): Flags<R> => {
|
|
27
29
|
const out: Record<string, boolean> = {}
|
|
28
|
-
for (const { name } of registry) {
|
|
30
|
+
for (const { name, defaultOn } of registry) {
|
|
29
31
|
const variable = `GTD_FLAG_${name.toUpperCase()}`
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
+
const key = (env[variable] ?? "").toLowerCase()
|
|
33
|
+
if (key === "") {
|
|
34
|
+
out[name] = defaultOn ?? false
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
32
37
|
if (!Object.hasOwn(BOOL, key)) {
|
|
33
38
|
throw new Error(`${variable} must be 1, 0, true or false`)
|
|
34
39
|
}
|