@pmelab/gtd 27.1.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.
Files changed (35) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +16 -9
  3. package/dist/chunks/registry-CP3YuV9j.mjs +101 -0
  4. package/dist/chunks/{reserved-BL9krPao.mjs → reserved-B2QaImnN.mjs} +41 -73
  5. package/dist/chunks/tail-BnhIxCYv.mjs +81 -0
  6. package/dist/gtd.bundle.mjs +5 -2
  7. package/dist/testkit.mjs +5 -2
  8. package/dist/workflows/cleanup.mjs +37 -0
  9. package/dist/workflows/feature.mjs +2 -1
  10. package/dist/workflows/fix.mjs +2 -1
  11. package/dist/workflows/review.mjs +2 -1
  12. package/dist/workflows/rollout.mjs +33 -0
  13. package/dist/workflows.mjs +220 -21
  14. package/package.json +11 -1
  15. package/src/flags/index.test.ts +28 -0
  16. package/src/flags/index.ts +9 -4
  17. package/src/testkit/steps.ts +26 -0
  18. package/src/testkit/testkit.feature +8 -1
  19. package/src/workflows/cleanup/cleanup.feature +323 -0
  20. package/src/workflows/cleanup/cleanup.test.ts +272 -0
  21. package/src/workflows/cleanup/cleanup.ts +52 -0
  22. package/src/workflows/cleanup/index.ts +6 -0
  23. package/src/workflows/index.ts +3 -1
  24. package/src/workflows/rollout/index.ts +6 -0
  25. package/src/workflows/rollout/rollout.feature +214 -0
  26. package/src/workflows/rollout/rollout.test.ts +167 -0
  27. package/src/workflows/rollout/rollout.ts +44 -0
  28. package/src/workflows/shared/health.test.ts +39 -0
  29. package/src/workflows/shared/health.ts +30 -7
  30. package/src/workflows/shared/options.test.ts +22 -0
  31. package/src/workflows/shared/options.ts +19 -0
  32. package/src/workflows/shared/registry.test.ts +305 -0
  33. package/src/workflows/shared/registry.ts +158 -0
  34. package/src/workflows/shared/tail.test.ts +36 -2
  35. package/src/workflows/shared/tail.ts +30 -18
@@ -17805,12 +17805,22 @@ const escalationSteps = {
17805
17805
  stop: escalate,
17806
17806
  exhausted: escalationExhausted
17807
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
+ });
17808
17818
  /**
17809
17819
  * Hand a red suite that repeated attempts could not fix to a person: an
17810
17820
  * agent writes `.gtd/ESCALATION.md`, a human narrows it. From the third round
17811
17821
  * on, the human is told the budget is spent instead.
17812
17822
  */
17813
- const escalation = (count) => escalation$1(count, escalationSteps);
17823
+ const escalation = (count, guard) => escalation$1(count, guardedSteps(guard));
17814
17824
  /** "Is this red round the same failure as the last one?" — asked of two reports, or of nothing when one is empty. */
17815
17825
  const retryQuestion = (comparable) => comparable ? {
17816
17826
  id: "verdict",
@@ -17846,21 +17856,25 @@ const runSuite = (options) => check("health.check", env.testCommand ?? "", {
17846
17856
  * first once `FIX_CAP` fixes are spent, or a judge calls the failure
17847
17857
  * identical to the previous round's.
17848
17858
  */
17849
- const healthy = (fix, options = {}) => healthy$1(fix, {
17859
+ const healthy = (fix, options = {}) => healthy$1(guarded(fix, options.guard), {
17850
17860
  check: () => runSuite(options),
17851
17861
  report: FEEDBACK,
17852
17862
  fixCap: 3,
17853
17863
  fixesSoFar: options.fixesSoFar ?? 0,
17854
17864
  sameFailure,
17855
17865
  escalations: options.escalations ?? { rounds: 0 },
17856
- escalation: escalationSteps
17866
+ escalation: guardedSteps(options.guard)
17857
17867
  });
17858
- /** Hold the process at `<name>.blocked` until the suite is green. */
17859
- const gate = (name, message) => gate$1(name, () => baseline("check"), () => human("blocked", {
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", {
17860
17874
  message,
17861
17875
  label: "Baseline is red",
17862
17876
  file: FEEDBACK
17863
- }));
17877
+ }), guard));
17864
17878
  //#endregion
17865
17879
  //#region node_modules/effect/dist/esm/Function.js
17866
17880
  /**
@@ -37221,6 +37235,27 @@ const shipped = (flow, doors, base) => defineWorkflow(standardSchemaV1(ShippedOp
37221
37235
  doors,
37222
37236
  base
37223
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
+ });
37224
37259
  //#endregion
37225
37260
  //#region src/workflows/shared/tail.ts
37226
37261
  /** The reviewer's own `.gtd/REVIEW.md`, kept beside the human's notes so `fix` can tell them apart. */
@@ -37230,13 +37265,13 @@ const REVIEW_ORIG = ".gtd/REVIEW.orig.md";
37230
37265
  * finding to `.gtd/QUALITY.md`. Resolves `"findings"` when that file
37231
37266
  * has any.
37232
37267
  */
37233
- const qualityLap = () => qualityLap$1(lensesOf(vars.qualityReviews ?? ""), reviewQuality, QUALITY);
37268
+ const qualityLap = (guard) => qualityLap$1(lensesOf(vars.qualityReviews ?? ""), guarded(reviewQuality, guard), QUALITY);
37234
37269
  /** Resolves `true` once `.gtd/QUALITY.md` is resolved, `false` when the fix cap escalated instead. */
37235
- const fixQualityFindings = (escalations) => fixFindings({
37236
- fix: fixQuality,
37270
+ const fixQualityFindings = (escalations, guard) => fixFindings({
37271
+ fix: guarded(fixQuality, guard),
37237
37272
  findings: QUALITY,
37238
37273
  cap: 3,
37239
- escalate: () => escalation(escalations)
37274
+ escalate: () => escalation(escalations, guard)
37240
37275
  });
37241
37276
  /** `.gtd/REVIEW.md` removed at the end of a round at commit `round`. */
37242
37277
  const closeReview = (round) => run("review.closing", removeScript([REVIEW, REVIEW_ORIG]), {
@@ -37252,19 +37287,26 @@ const refusePendingHandOff = () => {
37252
37287
  * `finish` — the human review, as the calling workflow runs it. `green`: the
37253
37288
  * caller just left the full suite green (the package loop), so no run opens it.
37254
37289
  */
37255
- const tail = (fixFirst, finish, green = false) => scope("build", async () => {
37290
+ const tail = (fixFirst, finish, green = false, guard) => scope("build", async () => {
37256
37291
  const escalations = { rounds: 0 };
37257
37292
  const fix = fixCheck;
37258
37293
  const repair = async () => {
37259
- await fix();
37294
+ await guarded(fix, guard)();
37260
37295
  await healthy(fix, {
37261
37296
  fixesSoFar: 1,
37262
- escalations
37297
+ escalations,
37298
+ guard
37263
37299
  });
37264
37300
  };
37265
37301
  if (fixFirst) await repair();
37266
- else if (!green) await healthy(fix, { escalations });
37267
- if (await qualityLap() === "findings") if (await fixQualityFindings(escalations)) await healthy(fix, { escalations });
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
+ });
37268
37310
  else await repair();
37269
37311
  return finish(escalations);
37270
37312
  });
@@ -37273,11 +37315,14 @@ const tail = (fixFirst, finish, green = false) => scope("build", async () => {
37273
37315
  * `.gtd/REVIEW.md`; a note leaves it, with the reviewer's own version as
37274
37316
  * `REVIEW_ORIG`, for the next `fix` to read — never a re-plan.
37275
37317
  */
37276
- const handOffReview = (base, escalations) => reviewToHandOff(base, {
37318
+ const handOffReview = (base, escalations, guard) => reviewToHandOff(base, {
37277
37319
  file: REVIEW,
37278
- reviewing,
37279
- fixRisks,
37280
- keepGreen: () => healthy(fixCheck, { escalations }),
37320
+ reviewing: guarded(reviewing, guard),
37321
+ fixRisks: guarded(fixRisks, guard),
37322
+ keepGreen: () => healthy(fixCheck, {
37323
+ escalations,
37324
+ guard
37325
+ }),
37281
37326
  awaitReview: (b) => awaitReview(b, handOffAwaitReviewMessage(b)),
37282
37327
  reviewMissing,
37283
37328
  close: closeReview,
@@ -37287,7 +37332,7 @@ const handOffReview = (base, escalations) => reviewToHandOff(base, {
37287
37332
  deleted: "gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note to hand to fix."
37288
37333
  });
37289
37334
  /** The tail the review and fix workflows run: ends at the hand-off review since `base`. */
37290
- 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);
37291
37336
  //#endregion
37292
37337
  //#region src/workflows/feature/steps.ts
37293
37338
  /** Turn the sketch since `base` into `.gtd/REQUIREMENTS.md`. */
@@ -37789,6 +37834,160 @@ const fixFlow = async () => {
37789
37834
  };
37790
37835
  const fix = shipped(fixFlow, { fix: {} });
37791
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
37792
37991
  //#region src/workflows/index.ts
37793
37992
  var workflows_default = feature;
37794
37993
  /**
@@ -37797,4 +37996,4 @@ var workflows_default = feature;
37797
37996
  */
37798
37997
  const doors = {};
37799
37998
  //#endregion
37800
- 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.1.0",
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",
@@ -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.
@@ -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> = { "": false, "0": false, false: false, "1": true, true: true }
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 raw = env[variable] ?? ""
31
- const key = raw.toLowerCase()
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
  }
@@ -98,6 +98,11 @@ Given("step {string} changes nothing", (world: TestkitWorld, step: string) => {
98
98
  world.act(step, () => undefined)
99
99
  })
100
100
 
101
+ // Unlike `changes nothing`, this drops every action already queued for the step.
102
+ Given("step {string} from now on changes nothing", (world: TestkitWorld, step: string) => {
103
+ world.actions.set(step, [() => undefined])
104
+ })
105
+
101
106
  Given(
102
107
  "step {string} writes {string} with:",
103
108
  (world: TestkitWorld, step: string, path: string, content: string) => {
@@ -150,6 +155,22 @@ Then(
150
155
  },
151
156
  )
152
157
 
158
+ Then(
159
+ "starting workflow {string} with {string} is refused with {string}",
160
+ async (world: TestkitWorld, workflow: string, list: string, message: string) => {
161
+ await assert.rejects(world.repo().start(workflow, ...args(list)), (error: Error) =>
162
+ error.message.includes(message),
163
+ )
164
+ },
165
+ )
166
+
167
+ Then(
168
+ "driving the process is refused with {string}",
169
+ async (world: TestkitWorld, message: string) => {
170
+ await assert.rejects(world.drive(), (error: Error) => error.message.includes(message))
171
+ },
172
+ )
173
+
153
174
  Then("the process rests at {string}", async (world: TestkitWorld, step: string) => {
154
175
  assert.strictEqual((await world.repo().next()).state, step)
155
176
  })
@@ -172,6 +193,11 @@ Then("{string} contains {string}", (world: TestkitWorld, path: string, text: str
172
193
  assert.ok(world.repo().read(path)?.includes(text), `${path}: ${world.repo().read(path)}`)
173
194
  })
174
195
 
196
+ Then("{string} does not contain {string}", (world: TestkitWorld, path: string, text: string) => {
197
+ const content = world.repo().read(path)
198
+ assert.ok(content !== undefined && !content.includes(text), `${path}: ${content}`)
199
+ })
200
+
175
201
  Then("{string} does not exist", (world: TestkitWorld, path: string) => {
176
202
  assert.strictEqual(world.repo().read(path), undefined)
177
203
  })
@@ -6,7 +6,7 @@ Feature: testkit drives any workflow against the real engine
6
6
  Background:
7
7
  Given a gtd config with:
8
8
  """
9
- import { agent, human, judge, read, run } from "@pmelab/gtd/flows"
9
+ import { agent, human, judge, read, refuse, run } from "@pmelab/gtd/flows"
10
10
 
11
11
  export const tiny = async () => {
12
12
  await agent("plan", "Write PLAN.md.")
@@ -19,6 +19,10 @@ Feature: testkit drives any workflow against the real engine
19
19
  await human(answers.ready?.answer === "yes" ? "ship" : "rework")
20
20
  }
21
21
 
22
+ export const picky = async () => {
23
+ refuse("gtd: picky refuses every start")
24
+ }
25
+
22
26
  export default tiny
23
27
  """
24
28
 
@@ -59,6 +63,9 @@ Feature: testkit drives any workflow against the real engine
59
63
  And the resting step's content contains "Make the suite pass."
60
64
  And the last commit subject is "gtd(check): check → repair"
61
65
 
66
+ Scenario: a start the workflow refuses is reported with its message
67
+ Then starting workflow "picky" with "--var mode=bad" is refused with "picky refuses every start"
68
+
62
69
  # Fails if run: proves the `workflows` project honours @skip.
63
70
  @skip
64
71
  Scenario: a scenario tagged @skip never runs