@pmelab/gtd 29.1.1 → 29.2.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtd",
3
- "version": "29.1.1",
3
+ "version": "29.2.1",
4
4
  "description": "Drive the gtd loop inside Claude Code: agent turns run as subagents, gtd decides every beat",
5
5
  "author": {
6
6
  "name": "Philipp Melab"
package/README.md CHANGED
@@ -601,6 +601,9 @@ means `high`. Each lap lands a `Gtd-Mark: risk-tier=<low|high>` trailer and a
601
601
  lap's only. Its "needs a human reviewer" line for `high` is advisory, not a
602
602
  gate.
603
603
 
604
+ A `gtd.config.ts` call to `buildTail` that fits neither call form is rejected;
605
+ see [docs/configuration.md](docs/configuration.md).
606
+
604
607
  ### Rolling out and cleaning up release flags
605
608
 
606
609
  Two more bundled workflows retire a release flag. They are opt-in: the
@@ -83,7 +83,9 @@ export function judgePrompt(j: Judgment): string {
83
83
  "Answer every question below from the evidence alone. For each, give your answer and p, your confidence between 0 and 1 that the given answer is correct.",
84
84
  ]
85
85
  for (const [key, body] of Object.entries(j.state ?? {}))
86
- parts.push(`<evidence key="${key}">\n${body}\n</evidence>`)
86
+ parts.push(
87
+ `<evidence key="${key}">\n${body.replace(/<\/evidence/gi, "<\\/evidence")}\n</evidence>`,
88
+ )
87
89
  for (const q of j.questions) {
88
90
  const levels = levelsOf(q)
89
91
  if (typeof levels === "string") throw new Error(levels)
@@ -776,16 +776,7 @@ ${strays.map((path) => ` - \`${displayPath(path)}\``).join("\n")}
776
776
  - Introduce no new flag read and add no second read of an existing one:
777
777
  every match of \`${pattern}\` may move, never increase in number`;
778
778
  const withRules = (rules) => rules === void 0 ? "" : `${rules}\n`;
779
- function refactorBuildingPrompt(first, strays, pattern, reference, feedback) {
780
- return refactorBuildingPromptOf(typeof first === "string" ? {
781
- pkg: first,
782
- strays: strays ?? [],
783
- pattern: pattern ?? "",
784
- reference,
785
- feedback
786
- } : first);
787
- }
788
- const refactorBuildingPromptOf = ({ pkg, strays, pattern, reference, feedback = false }) => `${stateFileRules}
779
+ const refactorBuildingPrompt = ({ pkg, strays, pattern, reference, feedback = false }) => `${stateFileRules}
789
780
  - The package to implement is \`${pkg}\`; never delete it
790
781
  ${feedback ? `- The package carries a \`## Review feedback\` section: a human's review of the
791
782
  last refactor. Act on it, within the rules below
@@ -1,6 +1,6 @@
1
1
  import { read, refuse, removeScript, run, scope, showScript, vars } from "../flows.mjs";
2
2
  import { fixFindings, lensesOf, qualityLap as qualityLap$1, reviewToHandOff } from "../workflow-kit.mjs";
3
- import { E as reviewing, S as fixRisks, T as reviewQuality, W as handOffAwaitReviewMessage, _ as REVIEW, b as fixCheck, c as escalation, d as healthy, m as QUALITY, u as guarded, v as awaitReview, w as reviewMissing, x as fixQuality } from "./reserved-fqf-c9dc.mjs";
3
+ import { E as reviewing, S as fixRisks, T as reviewQuality, W as handOffAwaitReviewMessage, _ as REVIEW, b as fixCheck, c as escalation, d as healthy, m as QUALITY, u as guarded, v as awaitReview, w as reviewMissing, x as fixQuality } from "./reserved-BzeofpoC.mjs";
4
4
  //#region src/workflows/shared/tail.ts
5
5
  /** The reviewer's own `.gtd/REVIEW.md`, kept beside the human's notes so `fix` can tell them apart. */
6
6
  const REVIEW_ORIG = ".gtd/REVIEW.orig.md";
package/dist/flows.mjs CHANGED
@@ -370,7 +370,7 @@ const SHAPE_CRITERIA = [
370
370
  "land: every stray is incidental (a needed import, a test fixture, a renamed call site).",
371
371
  "refactor-first: the strays are a seam change the task needs; land them first as a behaviour-neutral refactor.",
372
372
  "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."
373
- ].join("\n");
373
+ ].join(" ");
374
374
  const readable = (c) => (c.status === "deleted" || c.after !== void 0) && (c.status === "added" || c.before !== void 0);
375
375
  const strayContent = (strays) => strays.map((c) => [
376
376
  `=== ${c.path} (${c.status})`,
@@ -22014,7 +22014,7 @@ const SHAPE_CRITERIA = [
22014
22014
  "land: every stray is incidental (a needed import, a test fixture, a renamed call site).",
22015
22015
  "refactor-first: the strays are a seam change the task needs; land them first as a behaviour-neutral refactor.",
22016
22016
  "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."
22017
- ].join("\n");
22017
+ ].join(" ");
22018
22018
  const readable = (c) => (c.status === "deleted" || c.after !== void 0) && (c.status === "added" || c.before !== void 0);
22019
22019
  const strayContent = (strays) => strays.map((c) => [
22020
22020
  `=== ${c.path} (${c.status})`,
@@ -41238,7 +41238,9 @@ const planLanding = (opts = {}, current) => gen(function* () {
41238
41238
  * closes or sits inside its own closing message. Writes nothing — no git, no
41239
41239
  * state transition, no file. Refuses (throws, mapped to the runtime-error
41240
41240
  * exit code) when the workflow declares no `summary` prompt, or the resolved
41241
- * run has no commits to name. The edge never narrates: the CLI prints the
41241
+ * run has no commits to name. `--slice` also refuses an unknown slice name
41242
+ * (listing the valid slices), a config edited since the process closed, and a
41243
+ * replay that diverged. The edge never narrates: the CLI prints the
41242
41244
  * version warning, before the unknown-slice refusal; a replay refusal carries
41243
41245
  * it in its message.
41244
41246
  */
package/dist/testkit.mjs CHANGED
@@ -10341,7 +10341,9 @@ const planLanding = (opts = {}, current) => gen(function* () {
10341
10341
  * closes or sits inside its own closing message. Writes nothing — no git, no
10342
10342
  * state transition, no file. Refuses (throws, mapped to the runtime-error
10343
10343
  * exit code) when the workflow declares no `summary` prompt, or the resolved
10344
- * run has no commits to name. The edge never narrates: the CLI prints the
10344
+ * run has no commits to name. `--slice` also refuses an unknown slice name
10345
+ * (listing the valid slices), a config edited since the process closed, and a
10346
+ * replay that diverged. The edge never narrates: the CLI prints the
10345
10347
  * version warning, before the unknown-slice refusal; a replay refusal carries
10346
10348
  * it in its message.
10347
10349
  */
@@ -1,6 +1,6 @@
1
1
  import { agent, changes, glob, read, refuse, scope, start, vars } from "../flows.mjs";
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-fqf-c9dc.mjs";
3
- import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-Do11DVcw.mjs";
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-BzeofpoC.mjs";
3
+ import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-eS_ZSImR.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
- 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-fqf-c9dc.mjs";
4
- import { a as tail, i as refusePendingHandOff, n as closeReview } from "../chunks/tail-Do11DVcw.mjs";
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-BzeofpoC.mjs";
4
+ import { a as tail, i as refusePendingHandOff, n as closeReview } from "../chunks/tail-eS_ZSImR.mjs";
5
5
  //#region src/workflows/shared/fences.ts
6
6
  const markFenced = (text) => {
7
7
  let fence;
@@ -476,13 +476,14 @@ const review = (base, escalations = { rounds: 0 }, options = {}) => {
476
476
  });
477
477
  };
478
478
  function buildTail(first, second, third = false, fourth = {}) {
479
- const base = typeof first === "string" ? first : second;
480
- const options = typeof first === "string" ? second ?? {} : {
479
+ if (typeof first !== "string" && typeof first !== "boolean" && typeof second !== "string") return Promise.reject(/* @__PURE__ */ new Error("buildTail takes buildTail(base, options?) or the deprecated buildTail(fixFirst, base, green?, options?); this call fits neither"));
480
+ const [base, options] = typeof first === "string" ? [first, second ?? {}] : [second, {
481
481
  ...fourth,
482
482
  fixFirst: first,
483
483
  green: third
484
- };
485
- return tail(options.fixFirst ?? false, (escalations) => review(base, escalations, options), options.green ?? false, options.guard, options.lenses, options.guardRests, options.lensBase, options.rules);
484
+ }];
485
+ const { fixFirst, green, ...reviewOptions } = options;
486
+ return tail(fixFirst ?? false, (escalations) => review(base, escalations, reviewOptions), green ?? false, options.guard, options.lenses, options.guardRests, options.lensBase, options.rules);
486
487
  }
487
488
  /**
488
489
  * Undo the human's review-round code edits. A path changed since the human
@@ -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 { 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-fqf-c9dc.mjs";
4
- import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-Do11DVcw.mjs";
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-BzeofpoC.mjs";
4
+ import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-eS_ZSImR.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 { 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-fqf-c9dc.mjs";
3
- import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-Do11DVcw.mjs";
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-BzeofpoC.mjs";
3
+ import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-eS_ZSImR.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, dt as defaults, ft as envDefaults, gt as flipProblems, i as skills, l as gate, mt as flagMentions, n as summary, p as FEEDBACK, r as access, t as steering, vt as requireFlag, y as coder } from "../chunks/reserved-fqf-c9dc.mjs";
2
+ import { a as flagShipped, dt as defaults, ft as envDefaults, gt as flipProblems, i as skills, l as gate, mt as flagMentions, n as summary, p as FEEDBACK, r as access, t as steering, vt as requireFlag, y as coder } from "../chunks/reserved-BzeofpoC.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.";
@@ -349,7 +349,7 @@ const SHAPE_CRITERIA = [
349
349
  "land: every stray is incidental (a needed import, a test fixture, a renamed call site).",
350
350
  "refactor-first: the strays are a seam change the task needs; land them first as a behaviour-neutral refactor.",
351
351
  "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."
352
- ].join("\n");
352
+ ].join(" ");
353
353
  const readable = (c) => (c.status === "deleted" || c.after !== void 0) && (c.status === "added" || c.before !== void 0);
354
354
  const strayContent = (strays) => strays.map((c) => [
355
355
  `=== ${c.path} (${c.status})`,
@@ -17560,16 +17560,7 @@ ${strays.map((path) => ` - \`${displayPath(path)}\``).join("\n")}
17560
17560
  - Introduce no new flag read and add no second read of an existing one:
17561
17561
  every match of \`${pattern}\` may move, never increase in number`;
17562
17562
  const withRules = (rules) => rules === void 0 ? "" : `${rules}\n`;
17563
- function refactorBuildingPrompt(first, strays, pattern, reference, feedback) {
17564
- return refactorBuildingPromptOf(typeof first === "string" ? {
17565
- pkg: first,
17566
- strays: strays ?? [],
17567
- pattern: pattern ?? "",
17568
- reference,
17569
- feedback
17570
- } : first);
17571
- }
17572
- const refactorBuildingPromptOf = ({ pkg, strays, pattern, reference, feedback = false }) => `${stateFileRules}
17563
+ const refactorBuildingPrompt = ({ pkg, strays, pattern, reference, feedback = false }) => `${stateFileRules}
17573
17564
  - The package to implement is \`${pkg}\`; never delete it
17574
17565
  ${feedback ? `- The package carries a \`## Review feedback\` section: a human's review of the
17575
17566
  last refactor. Act on it, within the rules below
@@ -38308,13 +38299,14 @@ const review$1 = (base, escalations = { rounds: 0 }, options = {}) => {
38308
38299
  });
38309
38300
  };
38310
38301
  function buildTail(first, second, third = false, fourth = {}) {
38311
- const base = typeof first === "string" ? first : second;
38312
- const options = typeof first === "string" ? second ?? {} : {
38302
+ if (typeof first !== "string" && typeof first !== "boolean" && typeof second !== "string") return Promise.reject(/* @__PURE__ */ new Error("buildTail takes buildTail(base, options?) or the deprecated buildTail(fixFirst, base, green?, options?); this call fits neither"));
38303
+ const [base, options] = typeof first === "string" ? [first, second ?? {}] : [second, {
38313
38304
  ...fourth,
38314
38305
  fixFirst: first,
38315
38306
  green: third
38316
- };
38317
- return tail(options.fixFirst ?? false, (escalations) => review$1(base, escalations, options), options.green ?? false, options.guard, options.lenses, options.guardRests, options.lensBase, options.rules);
38307
+ }];
38308
+ const { fixFirst, green, ...reviewOptions } = options;
38309
+ return tail(fixFirst ?? false, (escalations) => review$1(base, escalations, reviewOptions), green ?? false, options.guard, options.lenses, options.guardRests, options.lensBase, options.rules);
38318
38310
  }
38319
38311
  /**
38320
38312
  * Undo the human's review-round code edits. A path changed since the human
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "29.1.1",
3
+ "version": "29.2.1",
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": {
@@ -56,7 +56,7 @@ export {
56
56
  export default feature
57
57
 
58
58
  export async function hotfix() {
59
- return afterTail(await buildTail(true, start()))
59
+ return afterTail(await buildTail(start(), { fixFirst: true }))
60
60
  }
61
61
 
62
62
  export const doors = { hotfix: { workflow: "hotfix" } }
@@ -227,9 +227,11 @@ pass (which writes `.gtd/packages/`), then builds the packages. Add a human
227
227
  sign-off between the two:
228
228
 
229
229
  ```ts
230
- const planAndBuild = async (): Promise<void> => {
230
+ const planAndBuild = async (firstBase: string): Promise<void> => {
231
+ let base = firstBase
232
+ let lap: "first" | "feedback" = "first"
231
233
  for (;;) {
232
- await design()
234
+ await design(base)
233
235
  await architecture()
234
236
  await human("approve-plan", {
235
237
  message:
@@ -238,8 +240,14 @@ const planAndBuild = async (): Promise<void> => {
238
240
  acceptClean: true,
239
241
  })
240
242
  await packages()
241
- if ((await buildTail(false)) === "signoff") return
242
- await reUnwind()
243
+ const outcome = await buildTail(base, {
244
+ green: true,
245
+ ...(lap === "first" ? {} : { lap }),
246
+ })
247
+ if (outcome.verdict === "signoff") return
248
+ await reUnwind(outcome)
249
+ base = outcome.base
250
+ lap = "feedback"
243
251
  }
244
252
  }
245
253
  ```
@@ -326,7 +326,9 @@ const planLanding = (
326
326
  * closes or sits inside its own closing message. Writes nothing — no git, no
327
327
  * state transition, no file. Refuses (throws, mapped to the runtime-error
328
328
  * exit code) when the workflow declares no `summary` prompt, or the resolved
329
- * run has no commits to name. The edge never narrates: the CLI prints the
329
+ * run has no commits to name. `--slice` also refuses an unknown slice name
330
+ * (listing the valid slices), a config edited since the process closed, and a
331
+ * replay that diverged. The edge never narrates: the CLI prints the
330
332
  * version warning, before the unknown-slice refusal; a replay refusal carries
331
333
  * it in its message.
332
334
  */
@@ -174,7 +174,7 @@ const SHAPE_CRITERIA = [
174
174
  "land: every stray is incidental (a needed import, a test fixture, a renamed call site).",
175
175
  "refactor-first: the strays are a seam change the task needs; land them first as a behaviour-neutral refactor.",
176
176
  "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.",
177
- ].join("\n")
177
+ ].join(" ")
178
178
 
179
179
  const readable = (c: Change): boolean =>
180
180
  (c.status === "deleted" || c.after !== undefined) &&
@@ -7,6 +7,7 @@ import {
7
7
  type JudgeAnswer,
8
8
  type StepRequest,
9
9
  } from "../flows/index.js"
10
+ import { toRequest } from "../judges/index.js"
10
11
  import { globMatches } from "../wire/index.js"
11
12
 
12
13
  // Lives in replay, which wires `matches: globMatches` (flows tests may not import wire).
@@ -123,6 +124,19 @@ describe("judgeDiffShape", () => {
123
124
  expect(q?.criteria).toContain("strays a refactor could absorb never count as reject")
124
125
  })
125
126
 
127
+ it("is sendable to jev with exactly its three labels", async () => {
128
+ const requests = install({ shape: { answer: "land", p: 0.9 } })
129
+ await judgeDiffShape("shape", { changes: naive, allow: ["src/**"] })
130
+ const request = requests[0]
131
+ if (request?.kind !== "judge") throw new Error("expected a judge step")
132
+ const { questions } = toRequest(request.questions, {})
133
+ expect(Object.keys((questions["shape"] as { criteria: object }).criteria)).toEqual([
134
+ "land",
135
+ "refactor-first",
136
+ "reject",
137
+ ])
138
+ })
139
+
126
140
  it("carries exactly four evidence keys, with stray content before/after and in-allowlist paths only", async () => {
127
141
  const requests = install({ shape: { answer: "land", p: 0.9 } })
128
142
  await judgeDiffShape("shape", { changes: naive, allow: ["src/**"] })
@@ -131,7 +131,7 @@ describe("afterTail feedback lap", () => {
131
131
  `export { feature as default, defaults, envDefaults, summary, base, steering, skills, access } from "@pmelab/gtd/workflows"`,
132
132
  `export async function tail() {`,
133
133
  ` await gate("review-gate", "The baseline is red.")`,
134
- ` return afterTail(await buildTail(false, start()))`,
134
+ ` return afterTail(await buildTail(start()))`,
135
135
  `}`,
136
136
  ].join("\n")
137
137
 
@@ -5,7 +5,7 @@ export * from "./planning.js"
5
5
  export * from "./packages.js"
6
6
  export * from "./refactor.js"
7
7
  export * from "./seam.js"
8
- export { buildTail, type ReviewOutcome } from "./review.js"
8
+ export { buildTail, type BuildTailOptions, type ReviewOutcome } from "./review.js"
9
9
  export * from "./feature.js"
10
10
  export * from "../shared/reserved.js"
11
11
 
@@ -3,6 +3,7 @@ import {
3
3
  installContext,
4
4
  type Change,
5
5
  type JudgeAnswer,
6
+ type JudgeQuestion,
6
7
  type ScopeOptions,
7
8
  type StepRequest,
8
9
  } from "../../flows/index.js"
@@ -16,7 +17,9 @@ import {
16
17
  } from "./review.js"
17
18
  import type { ReviewNote } from "../../steering/index.js"
18
19
  import * as bundled from "../index.js"
20
+ import type { BuildTailOptions as PublicBuildTailOptions } from "./index.js"
19
21
  import { fixtureContext } from "../shared/text.fixture.js"
22
+ import { toRequest } from "../../judges/index.js"
20
23
 
21
24
  afterEach(() => installContext(undefined))
22
25
 
@@ -64,6 +67,7 @@ interface Run {
64
67
  readonly prompts: Map<string, string>
65
68
  readonly result: ReviewOutcome | "stopped"
66
69
  readonly judged: readonly string[]
70
+ readonly asked: readonly JudgeQuestion[]
67
71
  /** `acceptClean` of each `review.await-review` gate, in order. */
68
72
  readonly gates: readonly boolean[]
69
73
  readonly gateMessages: readonly string[]
@@ -77,7 +81,7 @@ interface Run {
77
81
  const drive = async (d: Drive): Promise<Run> => {
78
82
  const log: string[] = []
79
83
  const prompts = new Map<string, string>()
80
- const judged: string[] = []
84
+ const asked: JudgeQuestion[] = []
81
85
  const gates: boolean[] = []
82
86
  const gateMessages: string[] = []
83
87
  const since: string[] = []
@@ -112,7 +116,7 @@ const drive = async (d: Drive): Promise<Run> => {
112
116
  gateMessages.push(request.options.message ?? "")
113
117
  }
114
118
  if (request.kind === "agent") prompts.set(request.name, request.prompt)
115
- if (request.kind === "judge") judged.push(...request.questions.map((q) => q.id))
119
+ if (request.kind === "judge") asked.push(...request.questions)
116
120
  }
117
121
  const context = fixtureContext(
118
122
  { vars: d.vars ?? {} },
@@ -158,7 +162,17 @@ const drive = async (d: Drive): Promise<Run> => {
158
162
  if (!(error instanceof Stop)) throw error
159
163
  result = "stopped"
160
164
  }
161
- return { log, prompts, result, judged, gates, gateMessages, since, scopes }
165
+ return {
166
+ log,
167
+ prompts,
168
+ result,
169
+ judged: asked.map((q) => q.id),
170
+ asked,
171
+ gates,
172
+ gateMessages,
173
+ since,
174
+ scopes,
175
+ }
162
176
  }
163
177
 
164
178
  const verdict = (answer: string, p = 0.9): JudgeAnswer => ({ answer, p })
@@ -417,6 +431,15 @@ describe("review verdict routing", () => {
417
431
  expect(run.log.filter((n) => n === "review.triage")).toHaveLength(1)
418
432
  })
419
433
 
434
+ it("asks only questions sendable to jev, each choice with its labels", async () => {
435
+ const run = await drive({ notes: ["add — a"] })
436
+ const { questions } = toRequest(run.asked, {})
437
+ const labels = (id: string) => Object.keys((questions[id] as { criteria: object }).criteria)
438
+ expect(labels("note-1")).toEqual(["edit", "question", "nit", "praise"])
439
+ expect(labels("note-1.reversibility")).toEqual(["one-way", "two-way"])
440
+ expect(labels("note-1.blastRadius")).toEqual(["local", "module", "contract"])
441
+ })
442
+
420
443
  it("an unanswered note counts as edit", async () => {
421
444
  const run = await drive({ notes: ["add — typo", "sub", "mul"], answers: {} })
422
445
  expect(run.log).toEqual([
@@ -771,7 +794,10 @@ describe("severity routing", () => {
771
794
  })
772
795
 
773
796
  describe("BuildTailOptions.lensBase", () => {
774
- const lensPrompts = async (options: BuildTailOptions): Promise<string[]> => {
797
+ const lensPrompts = async (
798
+ options: BuildTailOptions,
799
+ call: () => Promise<unknown> = () => buildTail("base", { ...options, green: true }),
800
+ ): Promise<string[]> => {
775
801
  const prompts: string[] = []
776
802
  installContext(
777
803
  fixtureContext(
@@ -793,7 +819,7 @@ describe("BuildTailOptions.lensBase", () => {
793
819
  },
794
820
  ),
795
821
  )
796
- await buildTail(false, "base", true, options).catch((error: unknown) => {
822
+ await call().catch((error: unknown) => {
797
823
  if (!(error instanceof Stop)) throw error
798
824
  })
799
825
  return prompts
@@ -810,8 +836,104 @@ describe("BuildTailOptions.lensBase", () => {
810
836
  expect(prompts).toHaveLength(2)
811
837
  for (const prompt of prompts) expect(prompt).toContain("from `START-SHA`")
812
838
  })
839
+
840
+ it("the deprecated positional form runs the same tail as the options form", async () => {
841
+ const options = { lensBase: "SLICE-BASE" }
842
+ const modern = await lensPrompts(options)
843
+ const legacy = await lensPrompts(options, () => buildTail(false, "base", true, options))
844
+ expect(legacy).toEqual(modern)
845
+ expect(legacy).toHaveLength(2)
846
+ })
847
+
848
+ describe("buildTail flags", () => {
849
+ const requestNames = async (
850
+ call: () => Promise<unknown>,
851
+ names: string[] = [],
852
+ ): Promise<string[]> => {
853
+ installContext(
854
+ fixtureContext(
855
+ {},
856
+ {
857
+ step: (request: StepRequest) => {
858
+ if (request.kind === "restart") return Promise.resolve()
859
+ names.push(`${request.kind}:${request.name}`)
860
+ if (names.length >= 4) throw new Stop()
861
+ return Promise.resolve()
862
+ },
863
+ pushScope: () => undefined,
864
+ popScope: () => undefined,
865
+ changesSince: () => {
866
+ throw new Stop()
867
+ },
868
+ start: () => "START-SHA",
869
+ },
870
+ ),
871
+ )
872
+ await call().catch((error: unknown) => {
873
+ if (!(error instanceof Stop)) throw error
874
+ })
875
+ return names
876
+ }
877
+
878
+ for (const [fixFirst, green] of [
879
+ [true, false],
880
+ [true, true],
881
+ [false, true],
882
+ [false, false],
883
+ ] as const) {
884
+ it(`fixFirst ${fixFirst}, green ${green} match the options form`, async () => {
885
+ const modern = await requestNames(() => buildTail("base", { fixFirst, green }))
886
+ const legacy = await requestNames(() => buildTail(fixFirst, "base", green))
887
+ expect(legacy).toEqual(modern)
888
+ })
889
+ }
890
+
891
+ it("fixFirst and green each change the request sequence", async () => {
892
+ const seq = (fixFirst: boolean, green: boolean) =>
893
+ requestNames(() => buildTail(fixFirst, "base", green))
894
+ const base = await seq(false, false)
895
+ expect(await seq(true, false)).not.toEqual(base)
896
+ expect(await seq(false, true)).not.toEqual(base)
897
+ })
898
+
899
+ for (const bad of [undefined, null, 1, {}]) {
900
+ it(`rejects naming both forms for sole first argument ${String(bad)}`, async () => {
901
+ const names: string[] = []
902
+ const rejected = expect(requestNames(() => buildTail(bad as never), names)).rejects
903
+ await rejected.toThrow("buildTail(base, options?)")
904
+ await rejected.toThrow("buildTail(fixFirst, base, green?, options?)")
905
+ expect(names).toEqual([])
906
+ })
907
+ }
908
+
909
+ it("rejects an undefined first argument followed by an options object", async () => {
910
+ const names: string[] = []
911
+ const rejected = expect(
912
+ requestNames(() => buildTail(undefined as never, { green: true } as never), names),
913
+ ).rejects
914
+ await rejected.toThrow("buildTail(base, options?)")
915
+ await rejected.toThrow("buildTail(fixFirst, base, green?, options?)")
916
+ expect(names).toEqual([])
917
+ })
918
+
919
+ it("still runs buildTail(undefined, base) as a legacy call", async () => {
920
+ const names: string[] = []
921
+ await requestNames(() => buildTail(undefined as never, "base"), names)
922
+ expect(names).not.toEqual([])
923
+ })
924
+
925
+ // pins `| undefined` on BuildTailOptions under exactOptionalPropertyTypes (typecheck)
926
+ it("explicitly undefined fixFirst and green default like omitted ones", async () => {
927
+ expect(
928
+ await requestNames(() => buildTail("base", { fixFirst: undefined, green: undefined })),
929
+ ).toEqual(await requestNames(() => buildTail("base")))
930
+ })
931
+ })
813
932
  })
814
933
 
934
+ // pins the public re-exports from the feature entry point and the published workflows entry
935
+ export type _PublicBuildTailOptions = [PublicBuildTailOptions, bundled.BuildTailOptions]
936
+
815
937
  describe("buildTail without rules", () => {
816
938
  const RULES = "Change only these paths"
817
939
 
@@ -850,7 +972,7 @@ describe("buildTail without rules", () => {
850
972
  },
851
973
  ),
852
974
  )
853
- await buildTail(true, "base", false, {}).catch((error: unknown) => {
975
+ await buildTail("base", { fixFirst: true }).catch((error: unknown) => {
854
976
  if (!(error instanceof Stop)) throw error
855
977
  })
856
978
  return prompts
@@ -257,9 +257,9 @@ export interface FeedbackTarget {
257
257
 
258
258
  export interface BuildTailOptions {
259
259
  /** Fix first when entered red; default `false`. */
260
- readonly fixFirst?: boolean
261
- /** Skip keeping green; default `false`. */
262
- readonly green?: boolean
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
263
  /** Runs after every code-changing turn: fix-check, quality fix, risk fix, nit fix, escalation describe. */
264
264
  readonly guard?: Guard
265
265
  /** `false`: `guard` skips the escalation's human rests. */
@@ -292,15 +292,23 @@ export function buildTail(
292
292
  third = false,
293
293
  fourth: Omit<BuildTailOptions, "fixFirst" | "green"> = {},
294
294
  ): Promise<kit.ReviewOutcome> {
295
- const base = typeof first === "string" ? first : (second as string)
296
- const options: BuildTailOptions =
295
+ // Before any step runs, so a bad call never leaves a half-walked flow.
296
+ if (typeof first !== "string" && typeof first !== "boolean" && typeof second !== "string") {
297
+ return Promise.reject(
298
+ new Error(
299
+ "buildTail takes buildTail(base, options?) or the deprecated buildTail(fixFirst, base, green?, options?); this call fits neither",
300
+ ),
301
+ )
302
+ }
303
+ const [base, options]: [string, BuildTailOptions] =
297
304
  typeof first === "string"
298
- ? ((second as BuildTailOptions | undefined) ?? {})
299
- : { ...fourth, fixFirst: first, green: third }
305
+ ? [first, (second as BuildTailOptions | undefined) ?? {}]
306
+ : [second as string, { ...fourth, fixFirst: first, green: third }]
307
+ const { fixFirst, green, ...reviewOptions } = options
300
308
  return tail(
301
- options.fixFirst ?? false,
302
- (escalations) => review(base, escalations, options),
303
- options.green ?? false,
309
+ fixFirst ?? false,
310
+ (escalations) => review(base, escalations, reviewOptions),
311
+ green ?? false,
304
312
  options.guard,
305
313
  options.lenses,
306
314
  options.guardRests,
@@ -225,6 +225,7 @@ describe("implement", () => {
225
225
  reference: string | undefined,
226
226
  tier?: "low" | "high",
227
227
  amend?: boolean,
228
+ call: () => Promise<void> = () => implement({ reference, tier, amend }),
228
229
  ) => {
229
230
  const requests: StepRequest[] = []
230
231
  installContext(
@@ -241,12 +242,25 @@ describe("implement", () => {
241
242
  },
242
243
  ),
243
244
  )
244
- await implement(reference, tier, amend)
245
+ await call()
245
246
  const request = requests.find((r) => r.kind === "agent" && r.name === "building")
246
247
  if (request?.kind !== "agent") throw new Error("no building turn")
247
248
  return request.prompt
248
249
  }
249
250
 
251
+ it("the deprecated positional form renders the same prompt as the options form", async () => {
252
+ const modern = await building("abc", "low")
253
+ const legacy = await building("abc", "low", undefined, () => implement("abc", "low"))
254
+ expect(legacy).toBe(modern)
255
+ expect(legacy).toContain("git show abc")
256
+ const amended = await building(undefined, undefined, true)
257
+ expect(
258
+ await building(undefined, undefined, undefined, () => implement(undefined, undefined, true)),
259
+ ).toBe(amended)
260
+ const bare = await building(undefined, undefined, undefined, () => implement())
261
+ expect(bare).toBe(await building(undefined))
262
+ })
263
+
250
264
  it("names the reference with a git show line", async () => {
251
265
  expect(await building("abc")).toContain("git show abc")
252
266
  })
@@ -24,7 +24,7 @@ export * from "./feature/planning.js"
24
24
  export * from "./feature/packages.js"
25
25
  export * from "./feature/refactor.js"
26
26
  export * from "./feature/seam.js"
27
- export { buildTail, type ReviewOutcome } from "./feature/review.js"
27
+ export { buildTail, type BuildTailOptions, type ReviewOutcome } from "./feature/review.js"
28
28
  export * from "./feature/feature.js"
29
29
  export { base, review, reviewFlow } from "./review/review.js"
30
30
  export { fix, fixFlow, fixReviewNotes } from "./fix/fix.js"
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from "vitest"
2
+ import { FLAGS, type FlagDeclaration } from "../../flags/index.js"
2
3
  import { configure, type WorkflowDoors } from "../../flows/index.js"
3
4
  import * as workflows from "../index.js"
4
5
  import * as options from "./options.js"
@@ -10,13 +11,19 @@ describe("the shipped workflow factories", () => {
10
11
  it("take exactly the settings the shipped workflows declare as options", () => {
11
12
  expect(Object.keys(ShippedOptions.fields).sort()).toEqual(
12
13
  [...Object.keys(defaults), ...Object.keys(envDefaults)]
13
- .filter((name) => name !== "flag_naive_pass" && name !== "flag_risk_tier")
14
+ .filter((name) => !name.startsWith("flag_"))
14
15
  .sort(),
15
16
  )
16
17
  })
17
18
 
19
+ it("declare a flag_<name> default for exactly the registered flags", () => {
20
+ const declared = Object.keys(defaults).filter((name) => name.startsWith("flag_"))
21
+ expect(declared.sort()).toEqual(FLAGS.map(({ name }) => `flag_${name}`).sort())
22
+ for (const { name, defaultOn } of FLAGS as readonly FlagDeclaration[])
23
+ expect(defaults).toHaveProperty(`flag_${name}`, defaultOn ? "1" : "0")
24
+ })
25
+
18
26
  it("declare seamAllow and flag_naive_pass defaults; flag_naive_pass is no option", () => {
19
- expect(defaults.flag_naive_pass).toBe("0")
20
27
  expect(Object.keys(ShippedOptions.fields)).not.toContain("flag_naive_pass")
21
28
  expect(configure(feature({ seamAllow: "docs/**" }))).toMatchObject({
22
29
  ok: true,
@@ -25,7 +32,6 @@ describe("the shipped workflow factories", () => {
25
32
  })
26
33
 
27
34
  it("declare flag_risk_tier off by default; it is no factory option", () => {
28
- expect(defaults.flag_risk_tier).toBe("0")
29
35
  expect(Object.keys(ShippedOptions.fields)).not.toContain("flag_risk_tier")
30
36
  })
31
37
 
@@ -1063,7 +1063,7 @@ describe("the refactor prompts", () => {
1063
1063
 
1064
1064
  it("building and fix rules forbid every test file, with no stray exception", () => {
1065
1065
  for (const prompt of renderText(() => [
1066
- refactorBuildingPrompt(".gtd/REFACTOR.md", strays, pattern),
1066
+ refactorBuildingPrompt({ pkg: ".gtd/REFACTOR.md", strays, pattern }),
1067
1067
  refactorFixPrompt(strays, pattern),
1068
1068
  ])) {
1069
1069
  expect(prompt).toContain("never a test file")
@@ -1073,8 +1073,8 @@ describe("the refactor prompts", () => {
1073
1073
 
1074
1074
  it("building adds the act-on-review-feedback line only when asked", () => {
1075
1075
  const [plain, feedback] = renderText(() => [
1076
- refactorBuildingPrompt(".gtd/REFACTOR.md", strays, pattern),
1077
- refactorBuildingPrompt(".gtd/REFACTOR.md", strays, pattern, undefined, true),
1076
+ refactorBuildingPrompt({ pkg: ".gtd/REFACTOR.md", strays, pattern }),
1077
+ refactorBuildingPrompt({ pkg: ".gtd/REFACTOR.md", strays, pattern, feedback: true }),
1078
1078
  ])
1079
1079
  expect(plain).not.toContain("## Review feedback")
1080
1080
  expect(feedback).toContain("## Review feedback")
@@ -473,44 +473,19 @@ ${strays.map((path) => ` - \`${displayPath(path)}\``).join("\n")}
473
473
 
474
474
  const withRules = (rules?: string): string => (rules === undefined ? "" : `${rules}\n`)
475
475
 
476
- interface RefactorBuildingArgs {
477
- pkg: string
478
- strays: readonly string[]
479
- pattern: string
480
- reference?: string | undefined
481
- feedback?: boolean | undefined
482
- }
483
-
484
- export function refactorBuildingPrompt(args: RefactorBuildingArgs): string
485
- /** @deprecated Pass `refactorBuildingPrompt({ pkg, strays, pattern, reference, feedback })`. */
486
- export function refactorBuildingPrompt(
487
- pkg: string,
488
- strays: readonly string[],
489
- pattern: string,
490
- reference?: string,
491
- feedback?: boolean,
492
- ): string
493
- export function refactorBuildingPrompt(
494
- first: string | RefactorBuildingArgs,
495
- strays?: readonly string[],
496
- pattern?: string,
497
- reference?: string,
498
- feedback?: boolean,
499
- ): string {
500
- return refactorBuildingPromptOf(
501
- typeof first === "string"
502
- ? { pkg: first, strays: strays ?? [], pattern: pattern ?? "", reference, feedback }
503
- : first,
504
- )
505
- }
506
-
507
- const refactorBuildingPromptOf = ({
476
+ export const refactorBuildingPrompt = ({
508
477
  pkg,
509
478
  strays,
510
479
  pattern,
511
480
  reference,
512
481
  feedback = false,
513
- }: RefactorBuildingArgs): string =>
482
+ }: {
483
+ pkg: string
484
+ strays: readonly string[]
485
+ pattern: string
486
+ reference?: string | undefined
487
+ feedback?: boolean | undefined
488
+ }): string =>
514
489
  `${stateFileRules}
515
490
  - The package to implement is \`${pkg}\`; never delete it
516
491
  ${