@pmelab/gtd 32.0.0 → 33.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +28 -22
  3. package/dist/chunks/{reserved-CHTJTzpA.mjs → reserved-BU5Zi-nX.mjs} +74 -13
  4. package/dist/chunks/{tail-CCsDBbZe.mjs → tail-DuXanjIY.mjs} +6 -3
  5. package/dist/testkit.mjs +2 -2
  6. package/dist/workflow-kit.mjs +15 -3
  7. package/dist/workflows/cleanup.mjs +2 -2
  8. package/dist/workflows/feature.mjs +158 -34
  9. package/dist/workflows/fix.mjs +2 -2
  10. package/dist/workflows/review.mjs +2 -2
  11. package/dist/workflows/rollout.mjs +1 -1
  12. package/dist/workflows.mjs +246 -46
  13. package/package.json +1 -1
  14. package/src/workflow-kit/quality.test.ts +25 -1
  15. package/src/workflow-kit/quality.ts +22 -4
  16. package/src/workflows/feature/acceptance-criteria.feature +8 -0
  17. package/src/workflows/feature/fast-path.feature +8 -2
  18. package/src/workflows/feature/feature.test.ts +19 -9
  19. package/src/workflows/feature/lens-models.feature +60 -0
  20. package/src/workflows/feature/naive-acceptance.feature +140 -0
  21. package/src/workflows/feature/one-step-land.feature +8 -2
  22. package/src/workflows/feature/refactor-absorbs.feature +6 -1
  23. package/src/workflows/feature/refactor-first.feature +9 -3
  24. package/src/workflows/feature/refactor-lens-models.feature +67 -0
  25. package/src/workflows/feature/refactor-review.feature +50 -5
  26. package/src/workflows/feature/refactor.ts +6 -5
  27. package/src/workflows/feature/risk-tier.feature +10 -0
  28. package/src/workflows/feature/seam-rejection.feature +19 -6
  29. package/src/workflows/feature/seam.feature +70 -16
  30. package/src/workflows/feature/seam.test.ts +189 -3
  31. package/src/workflows/feature/seam.ts +195 -30
  32. package/src/workflows/feature/steps.test.ts +13 -0
  33. package/src/workflows/index.ts +1 -1
  34. package/src/workflows/shared/skills.test.ts +7 -0
  35. package/src/workflows/shared/steps.test.ts +45 -0
  36. package/src/workflows/shared/steps.ts +4 -2
  37. package/src/workflows/shared/tail.test.ts +31 -1
  38. package/src/workflows/shared/tail.ts +6 -3
  39. package/src/workflows/shared/text.test.ts +40 -0
  40. package/src/workflows/shared/text.ts +97 -16
@@ -1,3 +1,4 @@
1
+ import { posix } from "node:path";
1
2
  //#region \0rolldown/runtime.js
2
3
  var __defProp = Object.defineProperty;
3
4
  var __exportAll = (all, no_symbols) => {
@@ -476,8 +477,20 @@ const gate$1 = (name, passes, blocked) => scope(name, async () => {
476
477
  });
477
478
  //#endregion
478
479
  //#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);
480
+ const lensOf = (entry) => {
481
+ const at = entry.indexOf("=");
482
+ return at < 0 ? {
483
+ lens: entry.trim(),
484
+ model: ""
485
+ } : {
486
+ lens: entry.slice(0, at).trim(),
487
+ model: entry.slice(at + 1).trim()
488
+ };
489
+ };
490
+ /** Split a comma-separated lens list into whole entries, `<lens>` or `<lens>=<model>`: trimmed, blanks dropped. */
491
+ const lensEntriesOf = (value) => value.split(",").map((entry) => entry.trim()).filter((entry) => lensOf(entry).lens.length > 0);
492
+ /** The same list's lens names, model hints cut off. */
493
+ const lensesOf = (value) => lensEntriesOf(value).map((entry) => lensOf(entry).lens);
481
494
  /** One `<scope>.<lens>` entry per lens — the per-lens part of a skills or access export. */
482
495
  const lensScopes = (scope, lenses, entry) => Object.fromEntries(lenses.map((lens) => [`${scope}.${lens}`, entry(lens)]));
483
496
  /** One `review` turn per lens, each appending its findings to `findings`; resolves `"findings"` when that file has any. */
@@ -17987,24 +18000,47 @@ Print the closing message and stop — this writes nothing itself.
17987
18000
  `;
17988
18001
  };
17989
18002
  const listed = (paths, unreadable = []) => paths.map((path) => ` - \`${displayPath(path)}\`${unreadable.includes(path) ? " (unreadable)" : ""}`).join("\n");
18003
+ const isRed = (retry) => "red" in retry;
18004
+ const isFault = (retry) => "fault" in retry;
18005
+ const pickUp = (hash) => `Pick it up — \`git diff --binary ${hash}^ ${hash} | git apply\``;
17990
18006
  const criteriaBinding = (refused) => `- Its \`## Acceptance Criteria\` (\`AC-<n>\`) bind this turn: re-read them from
17991
18007
  the file, never from memory, and stop only when each one holds. They are
17992
18008
  settled — ${refused ? "a turn that changes them is refused" : "leave them as they are"}`;
17993
- const naivePassPrompt = (rejection) => `${stateFileRules}
18009
+ const naiveRetry = (retry) => isFault(retry) ? `
18010
+ Your previous attempt's (\`${retry.hash}\`) tests fall short:
18011
+ ${retry.fault}
18012
+ ${pickUp(retry.hash)} — and fix its tests; do not start over.
18013
+ ` : isRed(retry) ? `
18014
+ Your previous attempt (\`${retry.red}\`) left the suite red; \`.gtd/FEEDBACK.md\`
18015
+ holds the output. ${pickUp(retry.red)}
18016
+ — and fix it until the suite is green; do not start over.
18017
+ ` : `
18018
+ Your previous attempt was rejected: the judge answered \`reject\` (p=${retry.p}),
18019
+ meaning the naive pass solved the task the wrong way — discard it and
18020
+ re-implement. It changed these files outside the expected set:
18021
+ ${listed(retry.strays)}
18022
+ Start over and choose a different approach; do not rebuild the same diff.
18023
+ `;
18024
+ const naivePassPrompt = (retry) => `${stateFileRules}
17994
18025
  - Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
17995
18026
  ${criteriaBinding(false)}
17996
18027
  - Implement them the quickest way that works — the naive version. Do not
17997
18028
  restructure, abstract or refactor around the change; a later step judges
17998
18029
  the shape of your diff and a reviewer makes it fit
17999
- - Add tests as you go, and leave \`.gtd/\` untouched
18030
+ - Work breadth-first: rough out every part of the change before finishing any
18031
+ one, so a run cut off midway still shows the whole shape
18032
+ - Write behaviour-level acceptance tests, at least one per criterion, each
18033
+ naming its ID in the test or scenario name (\`[AC-3] …\`). Test through code
18034
+ that existed before this pass — a public entry point, the CLI, an existing
18035
+ module's exports — never a file this pass adds: that is naive structure a
18036
+ later reset and refactor replaces. A pass that leaves a criterion's ID out
18037
+ of its tests, or whose test imports a file it added, is sent back
18038
+ - Run \`${env.testCommand}\` and finish only once it is green with every
18039
+ criterion's test passing: the next step runs it on your diff and sends a red
18040
+ pass back
18041
+ - Leave \`.gtd/\` untouched
18000
18042
  - Leave everything uncommitted and finish your turn — do not commit
18001
- ${rejection === void 0 ? "" : `
18002
- Your previous attempt was rejected: the judge answered \`reject\` (p=${rejection.p}),
18003
- meaning the naive pass solved the task the wrong way — discard it and
18004
- re-implement. It changed these files outside the expected set:
18005
- ${listed(rejection.strays)}
18006
- Start over and choose a different approach; do not rebuild the same diff.
18007
- `}`;
18043
+ ${retry === void 0 ? "" : naiveRetry(retry)}`;
18008
18044
  const implementBuildingPrompt = (reference, tier, amend, afterRefactor, flagMode = true) => `${stateFileRules}
18009
18045
  - Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
18010
18046
  ${criteriaBinding(true)}
@@ -18029,8 +18065,41 @@ const implementFixPrompt = () => `${stateFileRules}
18029
18065
  ${fixFeedbackPrompt}
18030
18066
  - Leave everything uncommitted and finish your turn — do not commit
18031
18067
  `;
18032
- const seamSteerMessage = (rejection) => `The naive pass was rejected three times and the retries are spent.
18033
- The last attempt changed these files outside the expected set:
18068
+ const steerChoices = (overrule) => `Two ways forward (then run \`gtd land\`):
18069
+ - **Steer** — edit \`.gtd/REQUIREMENTS.md\` to redirect the change; the naive
18070
+ pass runs again with a fresh retry budget
18071
+ - **Overrule** — ${overrule}
18072
+
18073
+ An edit together with an approval counts as steer. Only
18074
+ \`.gtd/REQUIREMENTS.md\` is meant to change here: any other edit lands on the
18075
+ branch unjudged.
18076
+ `;
18077
+ const seamSteerMessage = (retry) => isFault(retry) ? `The naive pass failed three times and the retries are spent. The last
18078
+ attempt's tests (\`git show ${retry.hash}\`) fall short:
18079
+ ${retry.fault}
18080
+
18081
+ ${steerChoices(`land a clean tree to go on with the last attempt as it is:
18082
+ \`seam.check\` runs its suite and the seam judge rules on its shape. Use it
18083
+ when the rule cannot fit this change (tests the globs do not see, or no
18084
+ entry point that existed before). Risk: a criterion may have no test that
18085
+ survives a later refactor — the choice is recorded in history.`)}` : isRed(retry) ? `The naive pass failed three times and the retries are spent. The last
18086
+ attempt left the suite red: \`.gtd/FEEDBACK.md\` holds the last run's output; \`git show ${retry.red}\` shows
18087
+ the last attempt.
18088
+
18089
+ Two ways forward (then run \`gtd land\`):
18090
+ - **Steer** — edit \`.gtd/REQUIREMENTS.md\` to redirect the change; the naive
18091
+ pass runs again with a fresh retry budget
18092
+ - **Overrule** — land a clean tree to go on with the last attempt as it is:
18093
+ the seam judge rules on its shape, and the implementation's health loop
18094
+ must turn the suite green. Risk: the criteria's tests never passed on the
18095
+ naive pass — the choice is recorded in history.
18096
+
18097
+ An edit together with an approval counts as steer. Only
18098
+ \`.gtd/REQUIREMENTS.md\` is meant to change here: any other edit lands on the
18099
+ branch unjudged.
18100
+ ` : seamRejectedMessage(retry);
18101
+ const seamRejectedMessage = (rejection) => `The naive pass failed three times and the retries are spent. The judge
18102
+ rejected the last attempt: it changed these files outside the expected set:
18034
18103
  ${listed(rejection.strays)}
18035
18104
 
18036
18105
  Two ways forward (then run \`gtd land\`):
@@ -18106,11 +18175,16 @@ const builtInLenses = {
18106
18175
  brief: specChallengeBrief
18107
18176
  }
18108
18177
  };
18178
+ const coderLenses = /* @__PURE__ */ new Set([
18179
+ "conventions",
18180
+ "ponytail-review",
18181
+ "test-audit"
18182
+ ]);
18109
18183
  /** 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), {
18184
+ const reviewQuality = (lens, base, model) => agentWithSkills(`quality.${lens}.reviewing`, buildQualityReviewingPrompt(lens, builtInLenses[lens]?.brief, base), {
18111
18185
  label: "Reviewing (one quality lens)",
18112
18186
  file: QUALITY,
18113
- model: planner(),
18187
+ model: model || (coderLenses.has(lens) ? coder() : planner()),
18114
18188
  system: reviewerSystem(),
18115
18189
  allowEmpty: true
18116
18190
  });
@@ -37631,7 +37705,10 @@ const REVIEW_ORIG = ".gtd/REVIEW.orig.md";
37631
37705
  * finding to `.gtd/QUALITY.md`. Resolves `"findings"` when that file
37632
37706
  * has any.
37633
37707
  */
37634
- const qualityLap = (guard, lenses, base) => qualityLap$1(lenses ?? lensesOf(vars.qualityReviews ?? ""), guarded((lens) => reviewQuality(lens, base), guard), QUALITY);
37708
+ const qualityLap = (guard, lenses, base) => qualityLap$1(lenses ?? lensEntriesOf(vars.qualityReviews ?? ""), guarded((entry) => {
37709
+ const { lens, model } = lensOf(entry);
37710
+ return reviewQuality(lens, base, model);
37711
+ }, guard), QUALITY);
37635
37712
  /** Resolves `true` once `.gtd/QUALITY.md` is resolved, `false` when the fix cap escalated instead. */
37636
37713
  const fixQualityFindings = (escalations, guard, guardRests = true, rules) => fixFindings({
37637
37714
  fix: guarded(() => fixQuality(rules), guard),
@@ -38437,7 +38514,7 @@ const reviewedRefactor = async (shape, options = {}) => {
38437
38514
  green: true,
38438
38515
  guard,
38439
38516
  guardRests: false,
38440
- lenses: lensesOf(vars.refactorQualityReviews ?? ""),
38517
+ lenses: lensEntriesOf(vars.refactorQualityReviews ?? ""),
38441
38518
  feedback,
38442
38519
  fixAccess: strayAccess(p.strays),
38443
38520
  lensBase: entry,
@@ -38453,7 +38530,7 @@ const reviewedRefactor = async (shape, options = {}) => {
38453
38530
  };
38454
38531
  //#endregion
38455
38532
  //#region src/workflows/feature/seam.ts
38456
- /** Naive-pass retries after a `reject`; the next `reject` rests at `seam.steer`. */
38533
+ /** Naive-pass retries after a `reject` or a red suite; the next one rests at `seam.steer`. */
38457
38534
  const NAIVE_RETRIES = 2;
38458
38535
  /** A change to an existing file outside the allowlist is a stray even when the task needs it: the judge decides. */
38459
38536
  const judgeSeam = (tip) => judgeDiffShape("judge", {
@@ -38461,12 +38538,95 @@ const judgeSeam = (tip) => judgeDiffShape("judge", {
38461
38538
  allow: (vars.seamAllow ?? defaults.seamAllow).split(",").map((glob) => glob.trim()).filter((glob) => glob !== ""),
38462
38539
  exempt: tip.changes.filter((c) => c.status === "added").map((c) => c.path)
38463
38540
  });
38464
- const naivePass = (rejection) => agentWithSkills("naive", naivePassPrompt(rejection), {
38465
- label: rejection === void 0 ? "Writing the naive pass" : "Rewriting the naive pass",
38541
+ const naivePass = (retry) => agentWithSkills("naive", naivePassPrompt(retry), {
38542
+ label: retry === void 0 ? "Writing the naive pass" : "Rewriting the naive pass",
38466
38543
  keep: true,
38467
38544
  model: coder(),
38468
38545
  system: builderSystem()
38469
38546
  });
38547
+ const IMPORTED = /(?:\bfrom|\bimport|\brequire\s*\(|\bmock\s*\()\s*\(?\s*(["'`])([^"'`\n]+)\1/g;
38548
+ const EXTENSION = /\.[\w-]+$/;
38549
+ const ALIAS = /^(?:[@~]\/|#)/;
38550
+ /** The specifiers an added `path` answers to: itself, without its extension, and its directory for an `index`. */
38551
+ const namesOf = (path) => {
38552
+ const bare = path.replace(EXTENSION, "");
38553
+ return posix.basename(bare) === "index" ? [
38554
+ path,
38555
+ bare,
38556
+ posix.dirname(bare)
38557
+ ] : [path, bare];
38558
+ };
38559
+ /**
38560
+ * The repository paths `test` imports, each with and without an extension: a
38561
+ * relative specifier resolved from its directory, an aliased one (`@/`, `~/`,
38562
+ * `#`) as a path tail, anything else as written. Comments are skipped; a
38563
+ * computed import goes unseen.
38564
+ */
38565
+ const importsOf = (test) => [...(test.after ?? "").replace(COMMENT, "").matchAll(IMPORTED)].flatMap(([, , spec]) => {
38566
+ const relative = /^\.\.?(\/|$)/.test(spec);
38567
+ const tail = ALIAS.test(spec);
38568
+ const path = relative ? posix.join(posix.dirname(test.path), spec) : spec.replace(ALIAS, "");
38569
+ return [path, path.replace(EXTENSION, "")].map((p) => ({
38570
+ path: p,
38571
+ tail
38572
+ }));
38573
+ });
38574
+ const reaches = (imports, added) => namesOf(added).some((name) => imports.some(({ path, tail }) => name === path || tail && name.endsWith(`/${path}`)));
38575
+ const named = (id, text) => new RegExp(`(?<![\\w-])${id}(?!\\d)`).test(text);
38576
+ /** The lines `change` added: an ID an earlier change left in the file does not count. */
38577
+ const addedLines = (change) => {
38578
+ const before = new Set((change.before ?? "").split("\n"));
38579
+ return change.after.split("\n").filter((line) => !before.has(line)).join("\n");
38580
+ };
38581
+ /**
38582
+ * Why `tip`'s tests fall short, or `undefined`: a criterion ID of `file` no
38583
+ * test line of the pass names, or a test importing a file the pass added —
38584
+ * naive structure a reset and refactor replaces, which the tests must survive.
38585
+ */
38586
+ const acceptanceFault = (file, tip) => {
38587
+ const ids = (criteriaOf(read(file) ?? "") ?? []).map((c) => c.id).filter((id) => id !== "");
38588
+ const tests = tip.changes.filter((c) => c.after !== void 0 && isCode$1(c.path) && isTestFile(c.path));
38589
+ const lines = tests.map(addedLines);
38590
+ const untested = ids.filter((id) => !lines.some((text) => named(id, text)));
38591
+ if (untested.length > 0) return `acceptance-tests: no test of the naive pass names ${untested.join(", ")} — write a behaviour-level test for each criterion with its ID in the test or scenario name, e.g. "[${untested[0]}] …"`;
38592
+ const added = tip.changes.filter((c) => c.status === "added" && isCode$1(c.path) && !isTestFile(c.path)).map((c) => c.path);
38593
+ const reaching = tests.filter((test) => holdsCallSites(test.path)).flatMap((test) => {
38594
+ const imports = importsOf(test);
38595
+ return added.filter((path) => reaches(imports, path)).map((path) => ` - ${test.path} → ${path}`);
38596
+ });
38597
+ if (reaching.length > 0) return `acceptance-internals: these tests import files the naive pass added — test through code that existed before it (a public entry point, an existing module's exports):\n${reaching.join("\n")}`;
38598
+ };
38599
+ const restore = (path) => `if git cat-file -e ${quote(`HEAD:${path}`)} 2>/dev/null; then git checkout -q HEAD -- ${quote(path)}; else rm -f -- ${quote(path)}; fi`;
38600
+ /**
38601
+ * Run the suite with `tip`'s diff applied, then take it out again; resolves
38602
+ * `true` when green. The pass's paths go back to HEAD before (an interrupted
38603
+ * run left them applied) and after; afterwards so does anything else the run
38604
+ * changed, judged against what was already dirty, which is left alone.
38605
+ */
38606
+ const naiveGreen = (tip) => {
38607
+ const paths = tip.changes.map((c) => restore(c.path));
38608
+ return check("check", [
38609
+ ...paths,
38610
+ "dirty=$(git -c core.quotePath=false ls-files -m -d -o --exclude-standard)",
38611
+ `if ! git diff --binary ${quote(`${tip.hash}^`)} ${quote(tip.hash)} -- | git apply; then`,
38612
+ " echo 'gtd: the kept naive pass no longer applies to HEAD; no test ran'",
38613
+ " exit 1",
38614
+ "fi",
38615
+ "(",
38616
+ env.testCommand ?? "",
38617
+ ")",
38618
+ "code=$?",
38619
+ ...paths,
38620
+ "git -c core.quotePath=false ls-files -m -d -o --exclude-standard | while IFS= read -r path; do",
38621
+ " printf '%s\\n' \"$dirty\" | grep -qxF -- \"$path\" && continue",
38622
+ " if git cat-file -e \"HEAD:$path\" 2>/dev/null; then git checkout -q HEAD -- \"$path\"; else rm -f -- \"$path\"; fi",
38623
+ "done",
38624
+ "exit \"$code\""
38625
+ ].join("\n"), {
38626
+ report: FEEDBACK,
38627
+ label: "Running the naive pass's tests"
38628
+ });
38629
+ };
38470
38630
  function implement(first, tier, amend) {
38471
38631
  const options = typeof first === "object" && first !== null ? first : {
38472
38632
  reference: first,
@@ -38493,36 +38653,76 @@ function implement(first, tier, amend) {
38493
38653
  await run("closing", removeScript([REQUIREMENTS]), { label: "Closing out the implementation" });
38494
38654
  });
38495
38655
  }
38496
- const decide = () => scope("seam", async () => {
38497
- let rejection;
38656
+ /** Resolves `true` when the human steered (edited), `false` on a clean overrule. */
38657
+ const steer = async (retry) => {
38658
+ await human("steer", {
38659
+ message: seamSteerMessage(retry),
38660
+ label: "Steer or overrule the naive pass",
38661
+ file: REQUIREMENTS,
38662
+ acceptClean: true
38663
+ });
38664
+ if (changes().length === 0) return false;
38665
+ requireCriteria(REQUIREMENTS);
38666
+ return true;
38667
+ };
38668
+ /** Rejects, test faults and red checks share one budget; a steer or an overrule refills it. */
38669
+ const budget = () => {
38498
38670
  let retries = 0;
38499
- for (;;) {
38500
- await naivePass(rejection);
38501
- const tip = kept("naive");
38502
- const shape = await judgeSeam(tip);
38503
- if (shape.choice === "land") return { reference: tip.hash };
38504
- if (shape.choice === "refactor-first") return {
38505
- reference: tip.hash,
38506
- shape
38507
- };
38508
- rejection = {
38509
- strays: shape.strays,
38510
- p: shape.judgment?.answers.shape?.p ?? 0
38511
- };
38671
+ return async (next) => {
38512
38672
  if (retries < NAIVE_RETRIES) {
38513
38673
  retries++;
38514
- continue;
38674
+ return {
38675
+ kind: "retry",
38676
+ retry: next
38677
+ };
38515
38678
  }
38516
- await human("steer", {
38517
- message: seamSteerMessage(rejection),
38518
- label: "Steer or overrule the rejected naive pass",
38519
- file: REQUIREMENTS,
38520
- acceptClean: true
38521
- });
38522
- if (changes().length === 0) return { reference: tip.hash };
38523
- requireCriteria(REQUIREMENTS);
38524
- rejection = void 0;
38679
+ const steered = await steer(next);
38525
38680
  retries = 0;
38681
+ if (read(".gtd/FEEDBACK.md") !== void 0) await run("clear", removeScript([FEEDBACK]), { label: "Clearing the red check" });
38682
+ return { kind: steered ? "steered" : "overruled" };
38683
+ };
38684
+ };
38685
+ /** Sends a pass whose tests fall short or run red back (`undefined`: it goes on, an overruled one as it is). */
38686
+ const vetted = async (tip, vetting) => {
38687
+ const fault = vetting.waived ? void 0 : acceptanceFault(REQUIREMENTS, tip);
38688
+ if (fault !== void 0) {
38689
+ const verdict = await vetting.again({
38690
+ fault,
38691
+ hash: tip.hash
38692
+ });
38693
+ if (verdict.kind !== "overruled") return verdict;
38694
+ vetting.waived = true;
38695
+ }
38696
+ if (await naiveGreen(tip)) return void 0;
38697
+ const verdict = await vetting.again({ red: tip.hash });
38698
+ return verdict.kind === "overruled" ? void 0 : verdict;
38699
+ };
38700
+ /** The judge's ruling on a vetted pass: the decision, or the next attempt's verdict. */
38701
+ const ruled = async (tip, again) => {
38702
+ const shape = await judgeSeam(tip);
38703
+ if (shape.choice === "land") return { reference: tip.hash };
38704
+ if (shape.choice === "refactor-first") return {
38705
+ reference: tip.hash,
38706
+ shape
38707
+ };
38708
+ const next = await again({
38709
+ strays: shape.strays,
38710
+ p: shape.judgment?.answers.shape?.p ?? 0
38711
+ });
38712
+ return next.kind === "overruled" ? { reference: tip.hash } : next;
38713
+ };
38714
+ const decide = () => scope("seam", async () => {
38715
+ const vetting = {
38716
+ again: budget(),
38717
+ waived: false
38718
+ };
38719
+ let retry;
38720
+ for (;;) {
38721
+ await naivePass(retry);
38722
+ const tip = kept("naive");
38723
+ const outcome = await vetted(tip, vetting) ?? await ruled(tip, vetting.again);
38724
+ if (!("kind" in outcome)) return outcome;
38725
+ retry = outcome.kind === "retry" ? outcome.retry : void 0;
38526
38726
  }
38527
38727
  });
38528
38728
  const seamBuild = async (base) => {
@@ -38803,4 +39003,4 @@ var workflows_default = feature;
38803
39003
  */
38804
39004
  const doors = {};
38805
39005
  //#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 };
39006
+ export { COMMENT, ESCALATION, FEEDBACK, FIX_CAP, FlagOptions, QUALITY, REFACTOR, REFACTOR_PACKAGE, REQUIREMENTS, REVIEW, REVIEW_ORIG, ShippedOptions, acceptanceFault, 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, holdsCallSites, implement, isTestFile, 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": "33.0.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>(
@@ -86,6 +86,14 @@ Feature: acceptance criteria carry stable IDs, and a sketch that states clear on
86
86
  - AC-1: `greeting` reads "Hello, world".
87
87
  """
88
88
  And step "design.gate.answer" changes nothing
89
+ And step "seam.check" changes nothing
90
+ And step "seam.naive" writes "src/acceptance.test.ts" with:
91
+ """
92
+ it("[AC-1] holds", () => {})
93
+ it("[AC-2] holds", () => {})
94
+ it("[AC-3] holds", () => {})
95
+ it("[AC-7] holds", () => {})
96
+ """
89
97
  And step "seam.naive" writes "src/greeting.ts" with:
90
98
  """
91
99
  export const greeting = "Hello, world"
@@ -57,7 +57,12 @@ Feature: a trivial change takes the same few beats at either risk tier, flag-fre
57
57
  Then the process rests at "seam.naive"
58
58
  And the last commit subject is "gtd(human): design.gate.answer → seam.naive"
59
59
 
60
- Given step "seam.naive" writes "src/greeting.ts" with:
60
+ Given step "seam.check" changes nothing
61
+ And step "seam.naive" writes "src/acceptance.test.ts" with:
62
+ """
63
+ it("[AC-1] holds", () => {})
64
+ """
65
+ And step "seam.naive" writes "src/greeting.ts" with:
61
66
  """
62
67
  export const greeting = "Hello, world"
63
68
  """
@@ -99,7 +104,8 @@ Feature: a trivial change takes the same few beats at either risk tier, flag-fre
99
104
  gtd(check): start-gate.check → design.triage
100
105
  gtd(agent): design.triage → design.gate.answer
101
106
  gtd(human): design.gate.answer → seam.naive
102
- gtd(agent): seam.naive → seam.judge
107
+ gtd(agent): seam.naive → seam.check
108
+ gtd(check): seam.check → seam.judge
103
109
  gtd(judge): seam.judge → implement.building
104
110
  gtd(agent): implement.building → implement.health.check
105
111
  gtd(check): implement.health.check → implement.closing
@@ -47,6 +47,8 @@ const review = (text?: string): string =>
47
47
 
48
48
  const CALC = "export const add = (a: number, b: number) => a + b\n"
49
49
 
50
+ const ACCEPTANCE = 'it("[AC-1] done", () => {})\n'
51
+
50
52
  const naivePlanned = () => {
51
53
  const repo = testRepo({
52
54
  workflow: CONFIG_SCAFFOLD,
@@ -77,7 +79,11 @@ describe("planAndBuild", () => {
77
79
  "# Requirements\n\n## Concerns\n\n### S\n\n- `s`\n\n## Acceptance Criteria\n\n- AC-1: done\n",
78
80
  ),
79
81
  "design.gate.answer": () => undefined,
80
- "seam.naive": (_, r) => r.write("src/calc.ts", SUBTRACT),
82
+ "seam.naive": (_, r) => {
83
+ r.write("src/calc.ts", SUBTRACT)
84
+ r.write("src/calc.test.ts", ACCEPTANCE)
85
+ },
86
+ "seam.check": () => undefined,
81
87
  "seam.judge": () => '[{"id":"shape","answer":"refactor-first","p":0.9}]',
82
88
  "refactor.deriving": runsItsScript,
83
89
  "refactor.building": (_, r) => r.write("src/calc.ts", SUBTRACT),
@@ -112,7 +118,11 @@ describe("planAndBuild", () => {
112
118
  "start-gate.check": () => undefined,
113
119
  "design.triage": requirements,
114
120
  "design.gate.answer": () => undefined,
115
- "seam.naive": (_: unknown, r: typeof repo) => r.write("src/calc.ts", `${CALC}// naive\n`),
121
+ "seam.naive": (_: unknown, r: typeof repo) => {
122
+ r.write("src/calc.ts", `${CALC}// naive\n`)
123
+ r.write("src/calc.test.ts", ACCEPTANCE)
124
+ },
125
+ "seam.check": () => undefined,
116
126
  "seam.judge": () => '[{"id":"shape","answer":"land","p":0.9}]',
117
127
  "implement.building": (_: unknown, r: typeof repo) =>
118
128
  r.write("src/calc.ts", `${CALC}// built\n`),
@@ -341,7 +351,10 @@ describe("an in-flight 28.0.0 naive-pass process", () => {
341
351
  "# Requirements\n\n## Concerns\n\n### S\n\n- `s`\n\n## Acceptance Criteria\n\n- AC-1: done\n",
342
352
  ),
343
353
  "design.gate.answer": () => undefined,
344
- "seam.naive": (_, r) => r.write("src/calc.ts", `${CALC}// naive\n`),
354
+ "seam.naive": (_, r) => {
355
+ r.write("src/calc.ts", `${CALC}// naive\n`)
356
+ r.write("src/calc.test.ts", ACCEPTANCE)
357
+ },
345
358
  })
346
359
  const kept = /Gtd-Kept: (\w+)/.exec(repo.messages().at(-1)!)![1]!
347
360
  const seen: Record<string, number> = {
@@ -366,16 +379,13 @@ describe("an in-flight 28.0.0 naive-pass process", () => {
366
379
  return repo
367
380
  }
368
381
 
382
+ // seam.check now follows every naive pass, so no rest past the first naive pass replays.
369
383
  for (const [name, trail] of Object.entries(trails)) {
370
- const replays = name !== "refactor-first"
371
- it(`${name}: ${replays ? "every rest replays to itself" : "every rest from the verdict on fails replay"}`, async () => {
384
+ it(`${name}: every rest past the naive pass fails replay`, async () => {
372
385
  for (let upTo = 1; upTo <= trail.length; upTo++) {
373
386
  const rest = trail[upTo - 1]![2]
374
387
  const repo = await forged(trail, upTo)
375
- if (replays) expect((await repo.next()).state, rest).toBe(rest)
376
- else {
377
- await expect(repo.next(), rest).rejects.toThrow("the workflow changed under this process")
378
- }
388
+ await expect(repo.next(), rest).rejects.toThrow("the workflow changed under this process")
379
389
  }
380
390
  })
381
391
  }
@@ -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"