@pmelab/gtd 32.0.0 → 32.1.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtd",
3
- "version": "32.0.0",
3
+ "version": "32.1.0",
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
@@ -471,13 +471,16 @@ noted in steps 3 and 4 below.
471
471
  Once the change is built, the whole change goes through a qualitative review
472
472
  lap before you see anything: six lenses, one turn each, in order —
473
473
  `correctness`, `owasp-security`, `ponytail-review`, `test-audit`,
474
- `conventions`, `spec-challenge`. Each traces the change from its own angle
475
- and records every finding, blocking or not; one fix turn then fixes ALL of
476
- them once, with no re-review after the fix. A clean turn means approval only
477
- when that lens found nothing at all. This lap is where code quality is looked
478
- at, and every round pays for it. It never replaces step 4 — your review stays
479
- the final gate, and nothing here skips it. The `fix` door (below) repairs a
480
- red baseline through this same lap.
474
+ `conventions`, `spec-challenge`. `correctness`, `owasp-security` and
475
+ `spec-challenge` run on the planner model (`smart`), the other three on the
476
+ coder model (`base`); a `qualityReviews` entry `<lens>=<model>` picks a
477
+ lens's own. Each traces the change from its own angle and records every
478
+ finding, blocking or not; one fix turn then fixes ALL of them once, with no
479
+ re-review after the fix. A clean turn means approval only when that lens
480
+ found nothing at all. This lap is where code quality is looked at, and every
481
+ round pays for it. It never replaces step 4 — your review stays the final
482
+ gate, and nothing here skips it. The `fix` door (below) repairs a red
483
+ baseline through this same lap.
481
484
 
482
485
  A red suite that keeps failing past a few fix attempts escalates instead of
483
486
  retrying forever: an agent turn reads the failing output and writes
@@ -1322,11 +1322,16 @@ const builtInLenses = {
1322
1322
  brief: specChallengeBrief
1323
1323
  }
1324
1324
  };
1325
+ const coderLenses = /* @__PURE__ */ new Set([
1326
+ "conventions",
1327
+ "ponytail-review",
1328
+ "test-audit"
1329
+ ]);
1325
1330
  /** One quality review through `lens`; each lens is its own scope, so its own conversation and skills. */
1326
- const reviewQuality = (lens, base) => agentWithSkills(`quality.${lens}.reviewing`, buildQualityReviewingPrompt(lens, builtInLenses[lens]?.brief, base), {
1331
+ const reviewQuality = (lens, base, model) => agentWithSkills(`quality.${lens}.reviewing`, buildQualityReviewingPrompt(lens, builtInLenses[lens]?.brief, base), {
1327
1332
  label: "Reviewing (one quality lens)",
1328
1333
  file: QUALITY,
1329
- model: planner(),
1334
+ model: model || (coderLenses.has(lens) ? coder() : planner()),
1330
1335
  system: reviewerSystem(),
1331
1336
  allowEmpty: true
1332
1337
  });
@@ -1,6 +1,6 @@
1
1
  import { read, refuse, removeScript, run, scope, showScript, vars } from "../flows.mjs";
2
- import { fixFindings, lensesOf, qualityLap as qualityLap$1, reviewToHandOff } from "../workflow-kit.mjs";
3
- import { E as reviewing, S as fixRisks, T as reviewQuality, U as handOffAwaitReviewMessage, _ as REVIEW, b as fixCheck, d as guarded, f as healthy, l as escalation, m as QUALITY, v as awaitReview, w as reviewMissing, x as fixQuality } from "./reserved-CHTJTzpA.mjs";
2
+ import { fixFindings, lensEntriesOf, lensOf, qualityLap as qualityLap$1, reviewToHandOff } from "../workflow-kit.mjs";
3
+ import { E as reviewing, S as fixRisks, T as reviewQuality, U as handOffAwaitReviewMessage, _ as REVIEW, b as fixCheck, d as guarded, f as healthy, l as escalation, m as QUALITY, v as awaitReview, w as reviewMissing, x as fixQuality } from "./reserved-2fV0mj14.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";
@@ -9,7 +9,10 @@ const REVIEW_ORIG = ".gtd/REVIEW.orig.md";
9
9
  * finding to `.gtd/QUALITY.md`. Resolves `"findings"` when that file
10
10
  * has any.
11
11
  */
12
- const qualityLap = (guard, lenses, base) => qualityLap$1(lenses ?? lensesOf(vars.qualityReviews ?? ""), guarded((lens) => reviewQuality(lens, base), guard), QUALITY);
12
+ const qualityLap = (guard, lenses, base) => qualityLap$1(lenses ?? lensEntriesOf(vars.qualityReviews ?? ""), guarded((entry) => {
13
+ const { lens, model } = lensOf(entry);
14
+ return reviewQuality(lens, base, model);
15
+ }, guard), QUALITY);
13
16
  /** Resolves `true` once `.gtd/QUALITY.md` is resolved, `false` when the fix cap escalated instead. */
14
17
  const fixQualityFindings = (escalations, guard, guardRests = true, rules) => fixFindings({
15
18
  fix: guarded(() => fixQuality(rules), guard),
@@ -47,8 +47,20 @@ const gate = (name, passes, blocked) => scope(name, async () => {
47
47
  });
48
48
  //#endregion
49
49
  //#region src/workflow-kit/quality.ts
50
- /** Split a comma-separated lens list: trimmed, blanks dropped. */
51
- const lensesOf = (value) => value.split(",").map((lens) => lens.trim()).filter((lens) => lens.length > 0);
50
+ const lensOf = (entry) => {
51
+ const at = entry.indexOf("=");
52
+ return at < 0 ? {
53
+ lens: entry.trim(),
54
+ model: ""
55
+ } : {
56
+ lens: entry.slice(0, at).trim(),
57
+ model: entry.slice(at + 1).trim()
58
+ };
59
+ };
60
+ /** Split a comma-separated lens list into whole entries, `<lens>` or `<lens>=<model>`: trimmed, blanks dropped. */
61
+ const lensEntriesOf = (value) => value.split(",").map((entry) => entry.trim()).filter((entry) => lensOf(entry).lens.length > 0);
62
+ /** The same list's lens names, model hints cut off. */
63
+ const lensesOf = (value) => lensEntriesOf(value).map((entry) => lensOf(entry).lens);
52
64
  /** One `<scope>.<lens>` entry per lens — the per-lens part of a skills or access export. */
53
65
  const lensScopes = (scope, lenses, entry) => Object.fromEntries(lenses.map((lens) => [`${scope}.${lens}`, entry(lens)]));
54
66
  /** One `review` turn per lens, each appending its findings to `findings`; resolves `"findings"` when that file has any. */
@@ -338,4 +350,4 @@ const reviewToHandOff = async (base, p) => {
338
350
  }
339
351
  };
340
352
  //#endregion
341
- export { assertionLines, escalation, fixFindings, gate, healthy, lensScopes, lensesOf, qualityLap, removedAssertions, review, reviewToHandOff, routeNotes, untilSettled };
353
+ export { assertionLines, escalation, fixFindings, gate, healthy, lensEntriesOf, lensOf, lensScopes, lensesOf, qualityLap, removedAssertions, review, reviewToHandOff, routeNotes, untilSettled };
@@ -1,6 +1,6 @@
1
1
  import { agent, changes, glob, read, refuse, scope, start, vars } from "../flows.mjs";
2
- import { gt as requireFlag, ht as registryEntry, i as skills, lt as defaults, n as summary, o as flagShipped, pt as flagReferences, r as access, t as steering, ut as envDefaults, y as coder } from "../chunks/reserved-CHTJTzpA.mjs";
3
- import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-CCsDBbZe.mjs";
2
+ import { gt as requireFlag, ht as registryEntry, i as skills, lt as defaults, n as summary, o as flagShipped, pt as flagReferences, r as access, t as steering, ut as envDefaults, y as coder } from "../chunks/reserved-2fV0mj14.mjs";
3
+ import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-BynhCfuv.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, 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
- import { lensesOf, review as review$1 } from "../workflow-kit.mjs";
3
- import { $ as reviewEditNotesCapture, A as buildReviewCollectingPrompt, B as designTriagePrompt, C as planner, D as agentWithSkills, E as reviewing, F as clearCriteriaQuestion, G as implementBuildingPrompt, I as criterionLine, J as normaliseCriteriaScript, K as implementFixPrompt, L as deferNotesScript, M as buildReviewTriageMessage, N as builderSystem, O as buildReviewAnswerQuestionsPrompt, P as clearCriteriaMessage, Q as refactorRules, R as designGateAnswerMessage, S as fixRisks, W as idleMessage, X as refactorDroppedRefusal, Y as refactorBuildingPrompt, Z as refactorFixPrompt, _ as REVIEW, a as flagModeOn, at as seamSteerMessage, b as fixCheck, ct as unwindFailure, d as guarded, dt as testFileGlobs, et as reviewEditsCapture, f as healthy, g as REQUIREMENTS, h as REFACTOR, i as skills, it as seamByHandMessage, j as buildReviewFixNitsPrompt, k as buildReviewAwaitAfterDeferMessage, lt as defaults, n as summary, nt as reviewNotesCapture, ot as startGateBlockedMessage, p as FEEDBACK, q as naivePassPrompt, r as access, rt as reviewerSystem, s as shipped, st as unwindFailedMessage, t as steering, u as gate, ut as envDefaults, v as awaitReview, w as reviewMissing, y as coder, z as designSystem } from "../chunks/reserved-CHTJTzpA.mjs";
4
- import { a as refusePendingHandOff, i as isOptionalObject, n as closeReview, o as tail } from "../chunks/tail-CCsDBbZe.mjs";
2
+ import { lensEntriesOf, review as review$1 } from "../workflow-kit.mjs";
3
+ import { $ as reviewEditNotesCapture, A as buildReviewCollectingPrompt, B as designTriagePrompt, C as planner, D as agentWithSkills, E as reviewing, F as clearCriteriaQuestion, G as implementBuildingPrompt, I as criterionLine, J as normaliseCriteriaScript, K as implementFixPrompt, L as deferNotesScript, M as buildReviewTriageMessage, N as builderSystem, O as buildReviewAnswerQuestionsPrompt, P as clearCriteriaMessage, Q as refactorRules, R as designGateAnswerMessage, S as fixRisks, W as idleMessage, X as refactorDroppedRefusal, Y as refactorBuildingPrompt, Z as refactorFixPrompt, _ as REVIEW, a as flagModeOn, at as seamSteerMessage, b as fixCheck, ct as unwindFailure, d as guarded, dt as testFileGlobs, et as reviewEditsCapture, f as healthy, g as REQUIREMENTS, h as REFACTOR, i as skills, it as seamByHandMessage, j as buildReviewFixNitsPrompt, k as buildReviewAwaitAfterDeferMessage, lt as defaults, n as summary, nt as reviewNotesCapture, ot as startGateBlockedMessage, p as FEEDBACK, q as naivePassPrompt, r as access, rt as reviewerSystem, s as shipped, st as unwindFailedMessage, t as steering, u as gate, ut as envDefaults, v as awaitReview, w as reviewMissing, y as coder, z as designSystem } from "../chunks/reserved-2fV0mj14.mjs";
4
+ import { a as refusePendingHandOff, i as isOptionalObject, n as closeReview, o as tail } from "../chunks/tail-BynhCfuv.mjs";
5
5
  //#region src/workflows/shared/fences.ts
6
6
  const markFenced = (text) => {
7
7
  let fence;
@@ -711,7 +711,7 @@ const reviewedRefactor = async (shape, options = {}) => {
711
711
  green: true,
712
712
  guard,
713
713
  guardRests: false,
714
- lenses: lensesOf(vars.refactorQualityReviews ?? ""),
714
+ lenses: lensEntriesOf(vars.refactorQualityReviews ?? ""),
715
715
  feedback,
716
716
  fixAccess: strayAccess(p.strays),
717
717
  lensBase: entry,
@@ -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 fixReviewNotesPrompt, V as finisherSystem, _ as REVIEW, c as baseline, i as skills, lt as defaults, n as summary, r as access, s as shipped, t as steering, ut as envDefaults, y as coder } from "../chunks/reserved-CHTJTzpA.mjs";
4
- import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-CCsDBbZe.mjs";
3
+ import { D as agentWithSkills, H as fixReviewNotesPrompt, V as finisherSystem, _ as REVIEW, c as baseline, i as skills, lt as defaults, n as summary, r as access, s as shipped, t as steering, ut as envDefaults, y as coder } from "../chunks/reserved-2fV0mj14.mjs";
4
+ import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-BynhCfuv.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 { i as skills, lt as defaults, n as summary, r as access, s as shipped, t as steering, tt as reviewGateBlockedMessage, u as gate, ut as envDefaults } from "../chunks/reserved-CHTJTzpA.mjs";
3
- import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-CCsDBbZe.mjs";
2
+ import { i as skills, lt as defaults, n as summary, r as access, s as shipped, t as steering, tt as reviewGateBlockedMessage, u as gate, ut as envDefaults } from "../chunks/reserved-2fV0mj14.mjs";
3
+ import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-BynhCfuv.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 { ft as flagMentions, gt as requireFlag, i as skills, lt as defaults, mt as flipProblems, n as summary, o as flagShipped, p as FEEDBACK, r as access, t as steering, u as gate, ut as envDefaults, y as coder } from "../chunks/reserved-CHTJTzpA.mjs";
2
+ import { ft as flagMentions, gt as requireFlag, i as skills, lt as defaults, mt as flipProblems, n as summary, o as flagShipped, p as FEEDBACK, r as access, t as steering, u as gate, ut as envDefaults, y as coder } from "../chunks/reserved-2fV0mj14.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.";
@@ -476,8 +476,20 @@ const gate$1 = (name, passes, blocked) => scope(name, async () => {
476
476
  });
477
477
  //#endregion
478
478
  //#region src/workflow-kit/quality.ts
479
- /** Split a comma-separated lens list: trimmed, blanks dropped. */
480
- const lensesOf = (value) => value.split(",").map((lens) => lens.trim()).filter((lens) => lens.length > 0);
479
+ const lensOf = (entry) => {
480
+ const at = entry.indexOf("=");
481
+ return at < 0 ? {
482
+ lens: entry.trim(),
483
+ model: ""
484
+ } : {
485
+ lens: entry.slice(0, at).trim(),
486
+ model: entry.slice(at + 1).trim()
487
+ };
488
+ };
489
+ /** Split a comma-separated lens list into whole entries, `<lens>` or `<lens>=<model>`: trimmed, blanks dropped. */
490
+ const lensEntriesOf = (value) => value.split(",").map((entry) => entry.trim()).filter((entry) => lensOf(entry).lens.length > 0);
491
+ /** The same list's lens names, model hints cut off. */
492
+ const lensesOf = (value) => lensEntriesOf(value).map((entry) => lensOf(entry).lens);
481
493
  /** One `<scope>.<lens>` entry per lens — the per-lens part of a skills or access export. */
482
494
  const lensScopes = (scope, lenses, entry) => Object.fromEntries(lenses.map((lens) => [`${scope}.${lens}`, entry(lens)]));
483
495
  /** One `review` turn per lens, each appending its findings to `findings`; resolves `"findings"` when that file has any. */
@@ -18106,11 +18118,16 @@ const builtInLenses = {
18106
18118
  brief: specChallengeBrief
18107
18119
  }
18108
18120
  };
18121
+ const coderLenses = /* @__PURE__ */ new Set([
18122
+ "conventions",
18123
+ "ponytail-review",
18124
+ "test-audit"
18125
+ ]);
18109
18126
  /** One quality review through `lens`; each lens is its own scope, so its own conversation and skills. */
18110
- const reviewQuality = (lens, base) => agentWithSkills(`quality.${lens}.reviewing`, buildQualityReviewingPrompt(lens, builtInLenses[lens]?.brief, base), {
18127
+ const reviewQuality = (lens, base, model) => agentWithSkills(`quality.${lens}.reviewing`, buildQualityReviewingPrompt(lens, builtInLenses[lens]?.brief, base), {
18111
18128
  label: "Reviewing (one quality lens)",
18112
18129
  file: QUALITY,
18113
- model: planner(),
18130
+ model: model || (coderLenses.has(lens) ? coder() : planner()),
18114
18131
  system: reviewerSystem(),
18115
18132
  allowEmpty: true
18116
18133
  });
@@ -37631,7 +37648,10 @@ const REVIEW_ORIG = ".gtd/REVIEW.orig.md";
37631
37648
  * finding to `.gtd/QUALITY.md`. Resolves `"findings"` when that file
37632
37649
  * has any.
37633
37650
  */
37634
- const qualityLap = (guard, lenses, base) => qualityLap$1(lenses ?? lensesOf(vars.qualityReviews ?? ""), guarded((lens) => reviewQuality(lens, base), guard), QUALITY);
37651
+ const qualityLap = (guard, lenses, base) => qualityLap$1(lenses ?? lensEntriesOf(vars.qualityReviews ?? ""), guarded((entry) => {
37652
+ const { lens, model } = lensOf(entry);
37653
+ return reviewQuality(lens, base, model);
37654
+ }, guard), QUALITY);
37635
37655
  /** Resolves `true` once `.gtd/QUALITY.md` is resolved, `false` when the fix cap escalated instead. */
37636
37656
  const fixQualityFindings = (escalations, guard, guardRests = true, rules) => fixFindings({
37637
37657
  fix: guarded(() => fixQuality(rules), guard),
@@ -38437,7 +38457,7 @@ const reviewedRefactor = async (shape, options = {}) => {
38437
38457
  green: true,
38438
38458
  guard,
38439
38459
  guardRests: false,
38440
- lenses: lensesOf(vars.refactorQualityReviews ?? ""),
38460
+ lenses: lensEntriesOf(vars.refactorQualityReviews ?? ""),
38441
38461
  feedback,
38442
38462
  fixAccess: strayAccess(p.strays),
38443
38463
  lensBase: entry,
@@ -38803,4 +38823,4 @@ var workflows_default = feature;
38803
38823
  */
38804
38824
  const doors = {};
38805
38825
  //#endregion
38806
- export { ESCALATION, FEEDBACK, FIX_CAP, FlagOptions, QUALITY, REFACTOR, REFACTOR_PACKAGE, REQUIREMENTS, REVIEW, REVIEW_ORIG, ShippedOptions, access, afterTail, agentWithSkills, answerProductQuestions, answerReviewQuestions, awaitReview, base, baseline, buildFeature, buildTail, builtInLenses, cleanup, cleanupFlow, closeReview, coder, collecting, criteriaOf, workflows_default as default, defaults, deferNotes, describeEscalation, design, doors, envDefaults, escalate, escalation, escalationExhausted, feature, fix, fixCheck, fixFlow, fixNits, fixQuality, fixQualityFindings, fixReviewNotes, fixRisks, gate, guarded, handOffReview, handOffTail, healthy, implement, judgeSeam, lensesOf, naivePass, ordinaryStart, planAndBuild, planner, qualityLap, reUnwind, refactorCollecting, refactorFirst, refactorStrays, requireCriteria, requireCriteriaFrozen, requireNoNewFlags, requireNoTestFiles, requireTestCallSitesOnly, requireWithin, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewedRefactor, reviewing, rollout, rolloutFlow, seamBuild, skills, skipDesign, statedCriteria, steering, summary, tail, threads, triage, unwind };
38826
+ export { ESCALATION, FEEDBACK, FIX_CAP, FlagOptions, QUALITY, REFACTOR, REFACTOR_PACKAGE, REQUIREMENTS, REVIEW, REVIEW_ORIG, ShippedOptions, access, afterTail, agentWithSkills, answerProductQuestions, answerReviewQuestions, awaitReview, base, baseline, buildFeature, buildTail, builtInLenses, cleanup, cleanupFlow, closeReview, coder, collecting, criteriaOf, workflows_default as default, defaults, deferNotes, describeEscalation, design, doors, envDefaults, escalate, escalation, escalationExhausted, feature, fix, fixCheck, fixFlow, fixNits, fixQuality, fixQualityFindings, fixReviewNotes, fixRisks, gate, guarded, handOffReview, handOffTail, healthy, implement, judgeSeam, lensEntriesOf, lensOf, lensesOf, naivePass, ordinaryStart, planAndBuild, planner, qualityLap, reUnwind, refactorCollecting, refactorFirst, refactorStrays, requireCriteria, requireCriteriaFrozen, requireNoNewFlags, requireNoTestFiles, requireTestCallSitesOnly, requireWithin, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewedRefactor, reviewing, rollout, rolloutFlow, seamBuild, skills, skipDesign, statedCriteria, steering, summary, tail, threads, triage, unwind };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "32.0.0",
3
+ "version": "32.1.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": {
@@ -1,10 +1,34 @@
1
1
  import { describe, expect, it } from "vitest"
2
- import { lensesOf, lensScopes } from "./quality.js"
2
+ import { lensEntriesOf, lensesOf, lensOf, lensScopes } from "./quality.js"
3
3
 
4
4
  describe("lensesOf", () => {
5
5
  it("splits on commas, trims, drops blanks", () => {
6
6
  expect(lensesOf(" a ,, b,")).toEqual(["a", "b"])
7
7
  })
8
+
9
+ it("cuts each entry's model hint off at its first =", () => {
10
+ expect(lensesOf("a = smart, plugin:b=vendor=x, c=")).toEqual(["a", "plugin:b", "c"])
11
+ })
12
+
13
+ it("drops an entry with a hint but no lens", () => {
14
+ expect(lensesOf("=smart, a")).toEqual(["a"])
15
+ })
16
+ })
17
+
18
+ describe("lensEntriesOf", () => {
19
+ it("keeps each entry whole, hint included", () => {
20
+ expect(lensEntriesOf(" a = smart ,, b, =x")).toEqual(["a = smart", "b"])
21
+ })
22
+ })
23
+
24
+ describe("lensOf", () => {
25
+ it("splits an entry at its first =, both sides trimmed", () => {
26
+ expect(lensOf(" plugin:b = vendor=x ")).toEqual({ lens: "plugin:b", model: "vendor=x" })
27
+ })
28
+
29
+ it("an entry without = has a blank hint", () => {
30
+ expect(lensOf("a")).toEqual({ lens: "a", model: "" })
31
+ })
8
32
  })
9
33
 
10
34
  describe("lensScopes", () => {
@@ -1,11 +1,29 @@
1
1
  import { read } from "../flows/index.js"
2
2
 
3
- /** Split a comma-separated lens list: trimmed, blanks dropped. */
4
- export const lensesOf = (value: string): readonly string[] =>
3
+ export interface LensEntry {
4
+ readonly lens: string
5
+ /** Blank when the entry carries none. */
6
+ readonly model: string
7
+ }
8
+
9
+ // Split at the first `=`: skill names may hold `:` (`plugin:skill`), never `=`.
10
+ export const lensOf = (entry: string): LensEntry => {
11
+ const at = entry.indexOf("=")
12
+ return at < 0
13
+ ? { lens: entry.trim(), model: "" }
14
+ : { lens: entry.slice(0, at).trim(), model: entry.slice(at + 1).trim() }
15
+ }
16
+
17
+ /** Split a comma-separated lens list into whole entries, `<lens>` or `<lens>=<model>`: trimmed, blanks dropped. */
18
+ export const lensEntriesOf = (value: string): readonly string[] =>
5
19
  value
6
20
  .split(",")
7
- .map((lens) => lens.trim())
8
- .filter((lens) => lens.length > 0)
21
+ .map((entry) => entry.trim())
22
+ .filter((entry) => lensOf(entry).lens.length > 0)
23
+
24
+ /** The same list's lens names, model hints cut off. */
25
+ export const lensesOf = (value: string): readonly string[] =>
26
+ lensEntriesOf(value).map((entry) => lensOf(entry).lens)
9
27
 
10
28
  /** One `<scope>.<lens>` entry per lens — the per-lens part of a skills or access export. */
11
29
  export const lensScopes = <T>(
@@ -0,0 +1,60 @@
1
+ @inmem
2
+ Feature: each quality lens runs on its own model hint
3
+
4
+ A `qualityReviews` entry may carry a model hint, `<lens>=<model>`. A lens
5
+ without one runs on the planner model (`smart`), except `conventions`,
6
+ `ponytail-review` and `test-audit`, which run on the coder model (`base`).
7
+
8
+ Background:
9
+ Given a gtd config with:
10
+ """
11
+ import { feature } from "@pmelab/gtd/workflows/feature"
12
+ export { summary, steering, skills, access } from "@pmelab/gtd/workflows/feature"
13
+ export default feature({
14
+ flagMode: false,
15
+ qualityReviews: "correctness, conventions, test-audit=opus",
16
+ })
17
+ """
18
+ And a file "src/calc.ts" with:
19
+ """
20
+ export const add = (a: number, b: number) => a + b
21
+ """
22
+ And the working tree is committed as "feat: add calculator"
23
+ And a file "TODO.md" with:
24
+ """
25
+ # Add subtract
26
+
27
+ subtract(a, b) returns the difference.
28
+ """
29
+ And step "unwind" changes nothing
30
+ And step "start-gate.check" changes nothing
31
+ And step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
32
+ """
33
+ # Requirements
34
+
35
+ ## Acceptance Criteria
36
+
37
+ - AC-1: `subtract(a, b)` returns `a - b`.
38
+ """
39
+ And step "design.gate.answer" changes nothing
40
+ And step "implement.building" writes "src/calc.ts" with:
41
+ """
42
+ export const add = (a: number, b: number) => a + b
43
+ export const subtract = (a: number, b: number) => a - b
44
+ """
45
+ And step "implement.health.check" changes nothing
46
+ And step "implement.closing" runs its script
47
+
48
+ Scenario: a hinted lens runs on its hint, every other lens on its default
49
+ When the process is driven
50
+ Then the process rests at "build.quality.correctness.reviewing"
51
+ And the output of gtd "next --json" contains "\"model\":\"smart\""
52
+ Given step "build.quality.correctness.reviewing" changes nothing
53
+ When the process is driven
54
+ Then the process rests at "build.quality.conventions.reviewing"
55
+ And the output of gtd "next --json" contains "\"model\":\"base\""
56
+ Given step "build.quality.conventions.reviewing" changes nothing
57
+ When the process is driven
58
+ Then the process rests at "build.quality.test-audit.reviewing"
59
+ And the output of gtd "next --json" contains "\"model\":\"opus\""
60
+ And the resting step's content contains "test-audit"
@@ -0,0 +1,62 @@
1
+ @inmem
2
+ Feature: the refactor review's lenses take their model hints from refactorQualityReviews alone
3
+
4
+ Background:
5
+ Given the shipped workflows, as gtd install scaffolds them
6
+ And an environment variable "GTD_QUALITYREVIEWS" set to "correctness=base"
7
+ And an environment variable "GTD_REFACTORQUALITYREVIEWS" set to "correctness, conventions=opus"
8
+ And an environment variable "GTD_FLAG_RISK_TIER" set to "0"
9
+ And a file "src/calc.ts" with:
10
+ """
11
+ export const add = (a: number, b: number) => a + b
12
+ """
13
+ And the working tree is committed as "feat: add calculator"
14
+ And a file "TODO.md" with:
15
+ """
16
+ # Add subtract
17
+
18
+ subtract(a, b) returns the difference.
19
+ """
20
+ And step "unwind" changes nothing
21
+ And step "start-gate.check" changes nothing
22
+ And step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
23
+ """
24
+ # Requirements
25
+
26
+ ## Concerns
27
+
28
+ ### Subtract
29
+
30
+ - `subtract(a, b)` returns `a - b`.
31
+
32
+ ## Acceptance Criteria
33
+
34
+ - AC-1: the change behaves as described.
35
+ """
36
+ And step "design.gate.answer" changes nothing
37
+
38
+ Scenario: a refactor lens runs on its refactorQualityReviews hint, else its default
39
+ Given step "seam.naive" writes "src/calc.ts" with:
40
+ """
41
+ export const add = (a: number, b: number) => a + b
42
+ export const subtract = (a: number, b: number) => a - b
43
+ """
44
+ And judge step "seam.judge" answers:
45
+ """
46
+ [{"id":"shape","answer":"refactor-first","p":0.9}]
47
+ """
48
+ And step "refactor.deriving" runs its script
49
+ And step "refactor.building" writes "src/calc.ts" with:
50
+ """
51
+ export const op = (a: number, b: number, sign: number) => a + sign * b
52
+ export const add = (a: number, b: number) => op(a, b, 1)
53
+ """
54
+ And step "refactor.health.check" changes nothing
55
+ And step "refactor.closing" runs its script
56
+ When the process is driven
57
+ Then the process rests at "refactor.build.quality.correctness.reviewing"
58
+ And the output of gtd "next --json" contains "\"model\":\"smart\""
59
+ Given step "refactor.build.quality.correctness.reviewing" changes nothing
60
+ When the process is driven
61
+ Then the process rests at "refactor.build.quality.conventions.reviewing"
62
+ And the output of gtd "next --json" contains "\"model\":\"opus\""
@@ -13,7 +13,7 @@ import {
13
13
  type Change,
14
14
  type DiffShapeResult,
15
15
  } from "../../flows/index.js"
16
- import { lensesOf } from "../../workflow-kit/index.js"
16
+ import { lensEntriesOf } from "../../workflow-kit/index.js"
17
17
  import { markFenced } from "../shared/fences.js"
18
18
  import { healthy, type Guard } from "../shared/health.js"
19
19
  import { coder, FEEDBACK, REFACTOR } from "../shared/steps.js"
@@ -343,7 +343,7 @@ export const reviewedRefactor = async (
343
343
  green: true,
344
344
  guard,
345
345
  guardRests: false,
346
- lenses: lensesOf(vars.refactorQualityReviews ?? ""),
346
+ lenses: lensEntriesOf(vars.refactorQualityReviews ?? ""),
347
347
  feedback,
348
348
  fixAccess: strayAccess(p.strays),
349
349
  lensBase: entry,
@@ -79,6 +79,17 @@ describe("naivePass", () => {
79
79
  expect(request.options).toMatchObject({ keep: true })
80
80
  })
81
81
 
82
+ it("runs on the coder model, base by default, the coderModel setting otherwise", async () => {
83
+ const plain = await capture()
84
+ const set = await captureStep(() => naivePass(), {
85
+ scope: "seam",
86
+ env: { coderModel: "sonnet" },
87
+ })
88
+ if (plain.kind !== "agent" || set.kind !== "agent") throw new Error("unreachable")
89
+ expect(plain.options.model).toBe("base")
90
+ expect(set.options.model).toBe("sonnet")
91
+ })
92
+
82
93
  it("carries the rejection into the retry prompt", async () => {
83
94
  const request = await capture({ strays: ["src/core.ts"], p: 0.8 })
84
95
  if (request.kind !== "agent") throw new Error("unreachable")
@@ -251,6 +262,11 @@ describe("implement", () => {
251
262
  return request.prompt
252
263
  }
253
264
 
265
+ it("builds on the coder model", async () => {
266
+ await building("abc")
267
+ expect(lastOptions?.model).toBe("base")
268
+ })
269
+
254
270
  it("after a refactor, lets an empty turn complete the step and says so", async () => {
255
271
  const prompt = await building("abc", undefined, undefined, () =>
256
272
  implement({ reference: "abc", afterRefactor: true }),
@@ -124,3 +124,16 @@ describe("the feature workflow's own steps declare skills — a step's rendered
124
124
  expect(request.options).toMatchObject({ file: ".gtd/TODO.md" })
125
125
  })
126
126
  })
127
+
128
+ describe("the feature workflow's planning steps run on the planner model", () => {
129
+ it("design triage defaults to smart and follows plannerModel", async () => {
130
+ const plain = await captureStep(() => steps.triage("base"), { scope: "design" })
131
+ const set = await captureStep(() => steps.triage("base"), {
132
+ scope: "design",
133
+ env: { plannerModel: "opus" },
134
+ })
135
+ if (plain.kind !== "agent" || set.kind !== "agent") throw new Error("unreachable")
136
+ expect(plain.options.model).toBe("smart")
137
+ expect(set.options.model).toBe("opus")
138
+ })
139
+ })
@@ -19,7 +19,7 @@ export {
19
19
  tail,
20
20
  type TailOptions,
21
21
  } from "./shared/tail.js"
22
- export { lensesOf } from "../workflow-kit/index.js"
22
+ export { lensEntriesOf, lensesOf, lensOf } from "../workflow-kit/index.js"
23
23
  export * from "./feature/steps.js"
24
24
  export * from "./feature/criteria.js"
25
25
  export * from "./feature/planning.js"
@@ -107,4 +107,11 @@ describe("the bundled workflow's skills export", () => {
107
107
  .sort(),
108
108
  ).toEqual(["build.quality.correctness", "build.quality.my-lens"])
109
109
  })
110
+
111
+ it("keys a hinted entry by its lens alone, with the lens's own skills", () => {
112
+ const custom = skills({ ...defaults, qualityReviews: "my-lens=smart, correctness = base" })
113
+ expect(custom["build.quality.my-lens"]).toEqual(["my-lens"])
114
+ expect(custom["build.quality.correctness"]).toEqual(["code-review-and-quality"])
115
+ expect(Object.keys(custom).some((k) => k.includes("="))).toBe(false)
116
+ })
110
117
  })
@@ -107,3 +107,48 @@ describe("the bundled workflow's steps declare skills — a bundled step's rende
107
107
  expect(carried.prompt).toContain("Carry-over: commit `abc1234`")
108
108
  })
109
109
  })
110
+
111
+ describe("the bundled workflow's model hints", () => {
112
+ const modelOf = async (
113
+ fn: () => Promise<void>,
114
+ context: Parameters<typeof captureStep>[1] = {},
115
+ ): Promise<string | undefined> => {
116
+ const request = await captureStep(fn, { scope: "build", ...context })
117
+ if (request.kind !== "agent") throw new Error(`expected an agent step, got ${request.kind}`)
118
+ return request.options.model
119
+ }
120
+
121
+ it.each([
122
+ ["correctness", "smart"],
123
+ ["owasp-security", "smart"],
124
+ ["spec-challenge", "smart"],
125
+ ["conventions", "base"],
126
+ ["ponytail-review", "base"],
127
+ ["test-audit", "base"],
128
+ ["my-lens", "smart"],
129
+ ])("lens %s defaults to %s", async (lens, model) => {
130
+ expect(await modelOf(() => steps.reviewQuality(lens))).toBe(model)
131
+ })
132
+
133
+ it("a lens's default follows the planner or coder setting it stands for", async () => {
134
+ const env = { plannerModel: "opus", coderModel: "sonnet" }
135
+ expect(await modelOf(() => steps.reviewQuality("correctness"), { env })).toBe("opus")
136
+ expect(await modelOf(() => steps.reviewQuality("conventions"), { env })).toBe("sonnet")
137
+ })
138
+
139
+ it("a model passed in wins over the lens's default; a blank one is none", async () => {
140
+ expect(await modelOf(() => steps.reviewQuality("correctness", undefined, "base"))).toBe("base")
141
+ expect(await modelOf(() => steps.reviewQuality("conventions", undefined, "opus"))).toBe("opus")
142
+ expect(await modelOf(() => steps.reviewQuality("conventions", undefined, ""))).toBe("base")
143
+ })
144
+
145
+ it("a lens named like an Object member still gets its default", async () => {
146
+ expect(await modelOf(() => steps.reviewQuality("constructor"))).toBe("smart")
147
+ })
148
+
149
+ it("the fix loop runs on the coder model, base by default", async () => {
150
+ expect(await modelOf(() => steps.fixCheck())).toBe("base")
151
+ expect(await modelOf(() => steps.fixQuality())).toBe("base")
152
+ expect(await modelOf(() => steps.fixCheck(), { env: { coderModel: "sonnet" } })).toBe("sonnet")
153
+ })
154
+ })
@@ -66,15 +66,17 @@ export const builtInLenses: Readonly<Record<string, BuiltInLens>> = {
66
66
  "spec-challenge": { skills: [], brief: t.specChallengeBrief },
67
67
  }
68
68
 
69
+ const coderLenses: ReadonlySet<string> = new Set(["conventions", "ponytail-review", "test-audit"])
70
+
69
71
  /** One quality review through `lens`; each lens is its own scope, so its own conversation and skills. */
70
- export const reviewQuality = (lens: string, base?: string): Promise<void> =>
72
+ export const reviewQuality = (lens: string, base?: string, model?: string): Promise<void> =>
71
73
  t.agentWithSkills(
72
74
  `quality.${lens}.reviewing`,
73
75
  t.buildQualityReviewingPrompt(lens, builtInLenses[lens]?.brief, base),
74
76
  {
75
77
  label: "Reviewing (one quality lens)",
76
78
  file: QUALITY,
77
- model: planner(),
79
+ model: model || (coderLenses.has(lens) ? coder() : planner()),
78
80
  system: t.reviewerSystem(),
79
81
  allowEmpty: true,
80
82
  },
@@ -208,18 +208,23 @@ describe("the hand-off review", () => {
208
208
  })
209
209
 
210
210
  const prompts: string[] = []
211
+ const models: (string | undefined)[] = []
211
212
 
212
213
  describe("the quality lap's lenses", () => {
213
214
  const lap = async (run: () => Promise<unknown>, vars: Record<string, string> = {}) => {
214
215
  const log: string[] = []
215
216
  prompts.length = 0
217
+ models.length = 0
216
218
  installContext(
217
219
  fixtureContext(
218
220
  { vars },
219
221
  {
220
222
  step: (request: StepRequest) => {
221
223
  if (request.kind !== "restart") log.push(request.name)
222
- if (request.kind === "agent") prompts.push(request.prompt)
224
+ if (request.kind === "agent") {
225
+ prompts.push(request.prompt)
226
+ models.push(request.options.model)
227
+ }
223
228
  return Promise.resolve()
224
229
  },
225
230
  pushScope: () => undefined,
@@ -279,6 +284,31 @@ describe("the quality lap's lenses", () => {
279
284
  expect(prompts[0]).not.toContain("from `R`")
280
285
  })
281
286
 
287
+ it("each lens runs on its qualityReviews hint, else its default, under its plain name", async () => {
288
+ const log = await lap(() => qualityLap(), {
289
+ qualityReviews: "correctness=base, conventions, my-lens",
290
+ })
291
+ expect(log).toEqual([
292
+ "quality.correctness.reviewing",
293
+ "quality.conventions.reviewing",
294
+ "quality.my-lens.reviewing",
295
+ ])
296
+ expect(models).toEqual(["base", "base", "smart"])
297
+ })
298
+
299
+ it("lenses passed in carry their own hints, never qualityReviews'", async () => {
300
+ const log = await lap(
301
+ () =>
302
+ tail(() => Promise.resolve(), {
303
+ green: true,
304
+ lenses: ["correctness", "conventions=opus"],
305
+ }),
306
+ { qualityReviews: "correctness=base" },
307
+ )
308
+ expect(log).toEqual(["quality.correctness.reviewing", "quality.conventions.reviewing"])
309
+ expect(models).toEqual(["smart", "opus"])
310
+ })
311
+
282
312
  it("tail passes lenses to its lap and a guard after each lens turn", async () => {
283
313
  let guards = 0
284
314
  const log = await lap(() =>
@@ -35,8 +35,11 @@ export const qualityLap = (
35
35
  base?: string,
36
36
  ): Promise<"clean" | "findings"> =>
37
37
  kit.qualityLap(
38
- lenses ?? kit.lensesOf(vars.qualityReviews ?? ""),
39
- guarded((lens: string) => reviewQuality(lens, base), guard),
38
+ lenses ?? kit.lensEntriesOf(vars.qualityReviews ?? ""),
39
+ guarded((entry: string) => {
40
+ const { lens, model } = kit.lensOf(entry)
41
+ return reviewQuality(lens, base, model)
42
+ }, guard),
40
43
  QUALITY,
41
44
  )
42
45
 
@@ -77,7 +80,7 @@ export interface TailOptions {
77
80
  readonly green?: boolean | undefined
78
81
  /** Runs after every code-changing turn: fix-check, quality fix, risk fix, nit fix, escalation describe. */
79
82
  readonly guard?: Guard | undefined
80
- /** Quality-lap lenses; default `lensesOf(vars.qualityReviews)`. */
83
+ /** Quality-lap lens entries, `<lens>` or `<lens>=<model>`; default `lensEntriesOf(vars.qualityReviews)`. */
81
84
  readonly lenses?: readonly string[] | undefined
82
85
  /** `false` leaves the escalation's human rests unguarded; default `true`. */
83
86
  readonly guardRests?: boolean | undefined