@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,6 +1,6 @@
1
1
  {
2
2
  "name": "gtd",
3
- "version": "32.0.0",
3
+ "version": "33.0.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
@@ -585,21 +588,24 @@ home directory — fails every command until you delete it. See
585
588
 
586
589
  #### The naive pass
587
590
 
588
- After design the agent first implements the requirements directly. A judge
589
- checks whether it changed anything outside the expected set (tests, steering
590
- files; see `seamAllow`). Incidental changes land; a needed seam change is
591
- refactored first, in its own slice with its own quality lap
592
- (`refactorQualityReviews`) and review, before the feature is implemented and
593
- reviewed; a wrong approach is retried twice, then rests for you to steer or
594
- overrule it. `gtd slices` lists the `refactor` and `feature` slices. A refactor
595
- that already carried the whole feature leaves the implementation nothing to do:
596
- its empty turn passes, and the feature review covers the whole change. Feedback
597
- at the feature review re-runs design and the implementation, never the naive
598
- pass; feedback at the refactor review re-runs the refactor, never design; a
599
- refactor-review change the refactor cannot build is refused, and the only way
600
- out is `gtd abandon`. A process from 27.5.0 or later whose judge ruled
601
- `refactor-first` must `gtd abandon` too. Each slice's quality lap reviews only
602
- that slice. Details in
591
+ After design the agent first implements the requirements directly,
592
+ breadth-first, with a behaviour-level acceptance test per criterion that names
593
+ its ID (`[AC-3] …`) and reaches only code that existed before the pass. The pass
594
+ is sent back while a criterion's ID is missing from its tests, a test imports a
595
+ file it added, or the suite is red on it. A judge checks whether it changed
596
+ anything outside the expected set (tests, steering files; see `seamAllow`).
597
+ Incidental changes land; a needed seam change is refactored first, in its own
598
+ slice with its own quality lap (`refactorQualityReviews`) and review, before the
599
+ feature is implemented and reviewed; a wrong approach is retried twice, then
600
+ rests for you to steer or overrule it. `gtd slices` lists the `refactor` and
601
+ `feature` slices. A refactor that already carried the whole feature leaves the
602
+ implementation nothing to do: its empty turn passes, and the feature review
603
+ covers the whole change. Feedback at the feature review re-runs design and the
604
+ implementation, never the naive pass; feedback at the refactor review re-runs
605
+ the refactor, never design; a refactor-review change the refactor cannot build
606
+ is refused, and the only way out is `gtd abandon`. A process from 27.5.0 or
607
+ later whose judge ruled `refactor-first` must `gtd abandon` too. Each slice's
608
+ quality lap reviews only that slice. Details in
603
609
  [the naive-pass path](docs/configuration.md#the-naive-pass-path).
604
610
 
605
611
  **Flag mode (`flagMode`, on by default).** On, the implementation is asked for a
@@ -1203,24 +1203,47 @@ Print the closing message and stop — this writes nothing itself.
1203
1203
  `;
1204
1204
  };
1205
1205
  const listed = (paths, unreadable = []) => paths.map((path) => ` - \`${displayPath(path)}\`${unreadable.includes(path) ? " (unreadable)" : ""}`).join("\n");
1206
+ const isRed = (retry) => "red" in retry;
1207
+ const isFault = (retry) => "fault" in retry;
1208
+ const pickUp = (hash) => `Pick it up — \`git diff --binary ${hash}^ ${hash} | git apply\``;
1206
1209
  const criteriaBinding = (refused) => `- Its \`## Acceptance Criteria\` (\`AC-<n>\`) bind this turn: re-read them from
1207
1210
  the file, never from memory, and stop only when each one holds. They are
1208
1211
  settled — ${refused ? "a turn that changes them is refused" : "leave them as they are"}`;
1209
- const naivePassPrompt = (rejection) => `${stateFileRules}
1212
+ const naiveRetry = (retry) => isFault(retry) ? `
1213
+ Your previous attempt's (\`${retry.hash}\`) tests fall short:
1214
+ ${retry.fault}
1215
+ ${pickUp(retry.hash)} — and fix its tests; do not start over.
1216
+ ` : isRed(retry) ? `
1217
+ Your previous attempt (\`${retry.red}\`) left the suite red; \`.gtd/FEEDBACK.md\`
1218
+ holds the output. ${pickUp(retry.red)}
1219
+ — and fix it until the suite is green; do not start over.
1220
+ ` : `
1221
+ Your previous attempt was rejected: the judge answered \`reject\` (p=${retry.p}),
1222
+ meaning the naive pass solved the task the wrong way — discard it and
1223
+ re-implement. It changed these files outside the expected set:
1224
+ ${listed(retry.strays)}
1225
+ Start over and choose a different approach; do not rebuild the same diff.
1226
+ `;
1227
+ const naivePassPrompt = (retry) => `${stateFileRules}
1210
1228
  - Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
1211
1229
  ${criteriaBinding(false)}
1212
1230
  - Implement them the quickest way that works — the naive version. Do not
1213
1231
  restructure, abstract or refactor around the change; a later step judges
1214
1232
  the shape of your diff and a reviewer makes it fit
1215
- - Add tests as you go, and leave \`.gtd/\` untouched
1233
+ - Work breadth-first: rough out every part of the change before finishing any
1234
+ one, so a run cut off midway still shows the whole shape
1235
+ - Write behaviour-level acceptance tests, at least one per criterion, each
1236
+ naming its ID in the test or scenario name (\`[AC-3] …\`). Test through code
1237
+ that existed before this pass — a public entry point, the CLI, an existing
1238
+ module's exports — never a file this pass adds: that is naive structure a
1239
+ later reset and refactor replaces. A pass that leaves a criterion's ID out
1240
+ of its tests, or whose test imports a file it added, is sent back
1241
+ - Run \`${env.testCommand}\` and finish only once it is green with every
1242
+ criterion's test passing: the next step runs it on your diff and sends a red
1243
+ pass back
1244
+ - Leave \`.gtd/\` untouched
1216
1245
  - Leave everything uncommitted and finish your turn — do not commit
1217
- ${rejection === void 0 ? "" : `
1218
- Your previous attempt was rejected: the judge answered \`reject\` (p=${rejection.p}),
1219
- meaning the naive pass solved the task the wrong way — discard it and
1220
- re-implement. It changed these files outside the expected set:
1221
- ${listed(rejection.strays)}
1222
- Start over and choose a different approach; do not rebuild the same diff.
1223
- `}`;
1246
+ ${retry === void 0 ? "" : naiveRetry(retry)}`;
1224
1247
  const implementBuildingPrompt = (reference, tier, amend, afterRefactor, flagMode = true) => `${stateFileRules}
1225
1248
  - Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
1226
1249
  ${criteriaBinding(true)}
@@ -1245,8 +1268,41 @@ const implementFixPrompt = () => `${stateFileRules}
1245
1268
  ${fixFeedbackPrompt}
1246
1269
  - Leave everything uncommitted and finish your turn — do not commit
1247
1270
  `;
1248
- const seamSteerMessage = (rejection) => `The naive pass was rejected three times and the retries are spent.
1249
- The last attempt changed these files outside the expected set:
1271
+ const steerChoices = (overrule) => `Two ways forward (then run \`gtd land\`):
1272
+ - **Steer** — edit \`.gtd/REQUIREMENTS.md\` to redirect the change; the naive
1273
+ pass runs again with a fresh retry budget
1274
+ - **Overrule** — ${overrule}
1275
+
1276
+ An edit together with an approval counts as steer. Only
1277
+ \`.gtd/REQUIREMENTS.md\` is meant to change here: any other edit lands on the
1278
+ branch unjudged.
1279
+ `;
1280
+ const seamSteerMessage = (retry) => isFault(retry) ? `The naive pass failed three times and the retries are spent. The last
1281
+ attempt's tests (\`git show ${retry.hash}\`) fall short:
1282
+ ${retry.fault}
1283
+
1284
+ ${steerChoices(`land a clean tree to go on with the last attempt as it is:
1285
+ \`seam.check\` runs its suite and the seam judge rules on its shape. Use it
1286
+ when the rule cannot fit this change (tests the globs do not see, or no
1287
+ entry point that existed before). Risk: a criterion may have no test that
1288
+ 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
1289
+ attempt left the suite red: \`.gtd/FEEDBACK.md\` holds the last run's output; \`git show ${retry.red}\` shows
1290
+ the last attempt.
1291
+
1292
+ Two ways forward (then run \`gtd land\`):
1293
+ - **Steer** — edit \`.gtd/REQUIREMENTS.md\` to redirect the change; the naive
1294
+ pass runs again with a fresh retry budget
1295
+ - **Overrule** — land a clean tree to go on with the last attempt as it is:
1296
+ the seam judge rules on its shape, and the implementation's health loop
1297
+ must turn the suite green. Risk: the criteria's tests never passed on the
1298
+ naive pass — the choice is recorded in history.
1299
+
1300
+ An edit together with an approval counts as steer. Only
1301
+ \`.gtd/REQUIREMENTS.md\` is meant to change here: any other edit lands on the
1302
+ branch unjudged.
1303
+ ` : seamRejectedMessage(retry);
1304
+ const seamRejectedMessage = (rejection) => `The naive pass failed three times and the retries are spent. The judge
1305
+ rejected the last attempt: it changed these files outside the expected set:
1250
1306
  ${listed(rejection.strays)}
1251
1307
 
1252
1308
  Two ways forward (then run \`gtd land\`):
@@ -1322,11 +1378,16 @@ const builtInLenses = {
1322
1378
  brief: specChallengeBrief
1323
1379
  }
1324
1380
  };
1381
+ const coderLenses = /* @__PURE__ */ new Set([
1382
+ "conventions",
1383
+ "ponytail-review",
1384
+ "test-audit"
1385
+ ]);
1325
1386
  /** 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), {
1387
+ const reviewQuality = (lens, base, model) => agentWithSkills(`quality.${lens}.reviewing`, buildQualityReviewingPrompt(lens, builtInLenses[lens]?.brief, base), {
1327
1388
  label: "Reviewing (one quality lens)",
1328
1389
  file: QUALITY,
1329
- model: planner(),
1390
+ model: model || (coderLenses.has(lens) ? coder() : planner()),
1330
1391
  system: reviewerSystem(),
1331
1392
  allowEmpty: true
1332
1393
  });
@@ -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-BU5Zi-nX.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),
package/dist/testkit.mjs CHANGED
@@ -5,13 +5,13 @@ import { $ as logWarning, $i as NodeInspectSymbol, $n as map$5, $r as make$19, $
5
5
  import { createRequire } from "node:module";
6
6
  import * as Crypto from "node:crypto";
7
7
  import { createHash } from "node:crypto";
8
+ import * as Path from "node:path";
9
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
8
10
  import { PassThrough } from "node:stream";
9
11
  import * as NFS from "node:fs";
10
12
  import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
11
13
  import * as OS from "node:os";
12
14
  import { homedir, tmpdir } from "node:os";
13
- import * as Path from "node:path";
14
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
15
15
  import * as ChildProcess from "node:child_process";
16
16
  import { execFileSync, execSync, spawnSync } from "node:child_process";
17
17
  import * as NodeUrl from "node:url";
@@ -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-BU5Zi-nX.mjs";
3
+ import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-DuXanjIY.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,8 @@
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";
1
+ import { answered, changes, changesSince, check, env, 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 { 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-BU5Zi-nX.mjs";
4
+ import { a as refusePendingHandOff, i as isOptionalObject, n as closeReview, o as tail } from "../chunks/tail-DuXanjIY.mjs";
5
+ import { posix } from "node:path";
5
6
  //#region src/workflows/shared/fences.ts
6
7
  const markFenced = (text) => {
7
8
  let fence;
@@ -711,7 +712,7 @@ const reviewedRefactor = async (shape, options = {}) => {
711
712
  green: true,
712
713
  guard,
713
714
  guardRests: false,
714
- lenses: lensesOf(vars.refactorQualityReviews ?? ""),
715
+ lenses: lensEntriesOf(vars.refactorQualityReviews ?? ""),
715
716
  feedback,
716
717
  fixAccess: strayAccess(p.strays),
717
718
  lensBase: entry,
@@ -727,7 +728,7 @@ const reviewedRefactor = async (shape, options = {}) => {
727
728
  };
728
729
  //#endregion
729
730
  //#region src/workflows/feature/seam.ts
730
- /** Naive-pass retries after a `reject`; the next `reject` rests at `seam.steer`. */
731
+ /** Naive-pass retries after a `reject` or a red suite; the next one rests at `seam.steer`. */
731
732
  const NAIVE_RETRIES = 2;
732
733
  /** A change to an existing file outside the allowlist is a stray even when the task needs it: the judge decides. */
733
734
  const judgeSeam = (tip) => judgeDiffShape("judge", {
@@ -735,12 +736,95 @@ const judgeSeam = (tip) => judgeDiffShape("judge", {
735
736
  allow: (vars.seamAllow ?? defaults.seamAllow).split(",").map((glob) => glob.trim()).filter((glob) => glob !== ""),
736
737
  exempt: tip.changes.filter((c) => c.status === "added").map((c) => c.path)
737
738
  });
738
- const naivePass = (rejection) => agentWithSkills("naive", naivePassPrompt(rejection), {
739
- label: rejection === void 0 ? "Writing the naive pass" : "Rewriting the naive pass",
739
+ const naivePass = (retry) => agentWithSkills("naive", naivePassPrompt(retry), {
740
+ label: retry === void 0 ? "Writing the naive pass" : "Rewriting the naive pass",
740
741
  keep: true,
741
742
  model: coder(),
742
743
  system: builderSystem()
743
744
  });
745
+ const IMPORTED = /(?:\bfrom|\bimport|\brequire\s*\(|\bmock\s*\()\s*\(?\s*(["'`])([^"'`\n]+)\1/g;
746
+ const EXTENSION = /\.[\w-]+$/;
747
+ const ALIAS = /^(?:[@~]\/|#)/;
748
+ /** The specifiers an added `path` answers to: itself, without its extension, and its directory for an `index`. */
749
+ const namesOf = (path) => {
750
+ const bare = path.replace(EXTENSION, "");
751
+ return posix.basename(bare) === "index" ? [
752
+ path,
753
+ bare,
754
+ posix.dirname(bare)
755
+ ] : [path, bare];
756
+ };
757
+ /**
758
+ * The repository paths `test` imports, each with and without an extension: a
759
+ * relative specifier resolved from its directory, an aliased one (`@/`, `~/`,
760
+ * `#`) as a path tail, anything else as written. Comments are skipped; a
761
+ * computed import goes unseen.
762
+ */
763
+ const importsOf = (test) => [...(test.after ?? "").replace(COMMENT, "").matchAll(IMPORTED)].flatMap(([, , spec]) => {
764
+ const relative = /^\.\.?(\/|$)/.test(spec);
765
+ const tail = ALIAS.test(spec);
766
+ const path = relative ? posix.join(posix.dirname(test.path), spec) : spec.replace(ALIAS, "");
767
+ return [path, path.replace(EXTENSION, "")].map((p) => ({
768
+ path: p,
769
+ tail
770
+ }));
771
+ });
772
+ const reaches = (imports, added) => namesOf(added).some((name) => imports.some(({ path, tail }) => name === path || tail && name.endsWith(`/${path}`)));
773
+ const named = (id, text) => new RegExp(`(?<![\\w-])${id}(?!\\d)`).test(text);
774
+ /** The lines `change` added: an ID an earlier change left in the file does not count. */
775
+ const addedLines = (change) => {
776
+ const before = new Set((change.before ?? "").split("\n"));
777
+ return change.after.split("\n").filter((line) => !before.has(line)).join("\n");
778
+ };
779
+ /**
780
+ * Why `tip`'s tests fall short, or `undefined`: a criterion ID of `file` no
781
+ * test line of the pass names, or a test importing a file the pass added —
782
+ * naive structure a reset and refactor replaces, which the tests must survive.
783
+ */
784
+ const acceptanceFault = (file, tip) => {
785
+ const ids = (criteriaOf(read(file) ?? "") ?? []).map((c) => c.id).filter((id) => id !== "");
786
+ const tests = tip.changes.filter((c) => c.after !== void 0 && isCode$1(c.path) && isTestFile(c.path));
787
+ const lines = tests.map(addedLines);
788
+ const untested = ids.filter((id) => !lines.some((text) => named(id, text)));
789
+ 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]}] …"`;
790
+ const added = tip.changes.filter((c) => c.status === "added" && isCode$1(c.path) && !isTestFile(c.path)).map((c) => c.path);
791
+ const reaching = tests.filter((test) => holdsCallSites(test.path)).flatMap((test) => {
792
+ const imports = importsOf(test);
793
+ return added.filter((path) => reaches(imports, path)).map((path) => ` - ${test.path} → ${path}`);
794
+ });
795
+ 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")}`;
796
+ };
797
+ 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`;
798
+ /**
799
+ * Run the suite with `tip`'s diff applied, then take it out again; resolves
800
+ * `true` when green. The pass's paths go back to HEAD before (an interrupted
801
+ * run left them applied) and after; afterwards so does anything else the run
802
+ * changed, judged against what was already dirty, which is left alone.
803
+ */
804
+ const naiveGreen = (tip) => {
805
+ const paths = tip.changes.map((c) => restore(c.path));
806
+ return check("check", [
807
+ ...paths,
808
+ "dirty=$(git -c core.quotePath=false ls-files -m -d -o --exclude-standard)",
809
+ `if ! git diff --binary ${quote(`${tip.hash}^`)} ${quote(tip.hash)} -- | git apply; then`,
810
+ " echo 'gtd: the kept naive pass no longer applies to HEAD; no test ran'",
811
+ " exit 1",
812
+ "fi",
813
+ "(",
814
+ env.testCommand ?? "",
815
+ ")",
816
+ "code=$?",
817
+ ...paths,
818
+ "git -c core.quotePath=false ls-files -m -d -o --exclude-standard | while IFS= read -r path; do",
819
+ " printf '%s\\n' \"$dirty\" | grep -qxF -- \"$path\" && continue",
820
+ " if git cat-file -e \"HEAD:$path\" 2>/dev/null; then git checkout -q HEAD -- \"$path\"; else rm -f -- \"$path\"; fi",
821
+ "done",
822
+ "exit \"$code\""
823
+ ].join("\n"), {
824
+ report: FEEDBACK,
825
+ label: "Running the naive pass's tests"
826
+ });
827
+ };
744
828
  function implement(first, tier, amend) {
745
829
  const options = typeof first === "object" && first !== null ? first : {
746
830
  reference: first,
@@ -767,36 +851,76 @@ function implement(first, tier, amend) {
767
851
  await run("closing", removeScript([REQUIREMENTS]), { label: "Closing out the implementation" });
768
852
  });
769
853
  }
770
- const decide = () => scope("seam", async () => {
771
- let rejection;
854
+ /** Resolves `true` when the human steered (edited), `false` on a clean overrule. */
855
+ const steer = async (retry) => {
856
+ await human("steer", {
857
+ message: seamSteerMessage(retry),
858
+ label: "Steer or overrule the naive pass",
859
+ file: REQUIREMENTS,
860
+ acceptClean: true
861
+ });
862
+ if (changes().length === 0) return false;
863
+ requireCriteria(REQUIREMENTS);
864
+ return true;
865
+ };
866
+ /** Rejects, test faults and red checks share one budget; a steer or an overrule refills it. */
867
+ const budget = () => {
772
868
  let retries = 0;
773
- for (;;) {
774
- await naivePass(rejection);
775
- const tip = kept("naive");
776
- const shape = await judgeSeam(tip);
777
- if (shape.choice === "land") return { reference: tip.hash };
778
- if (shape.choice === "refactor-first") return {
779
- reference: tip.hash,
780
- shape
781
- };
782
- rejection = {
783
- strays: shape.strays,
784
- p: shape.judgment?.answers.shape?.p ?? 0
785
- };
869
+ return async (next) => {
786
870
  if (retries < NAIVE_RETRIES) {
787
871
  retries++;
788
- continue;
872
+ return {
873
+ kind: "retry",
874
+ retry: next
875
+ };
789
876
  }
790
- await human("steer", {
791
- message: seamSteerMessage(rejection),
792
- label: "Steer or overrule the rejected naive pass",
793
- file: REQUIREMENTS,
794
- acceptClean: true
795
- });
796
- if (changes().length === 0) return { reference: tip.hash };
797
- requireCriteria(REQUIREMENTS);
798
- rejection = void 0;
877
+ const steered = await steer(next);
799
878
  retries = 0;
879
+ if (read(".gtd/FEEDBACK.md") !== void 0) await run("clear", removeScript([FEEDBACK]), { label: "Clearing the red check" });
880
+ return { kind: steered ? "steered" : "overruled" };
881
+ };
882
+ };
883
+ /** Sends a pass whose tests fall short or run red back (`undefined`: it goes on, an overruled one as it is). */
884
+ const vetted = async (tip, vetting) => {
885
+ const fault = vetting.waived ? void 0 : acceptanceFault(REQUIREMENTS, tip);
886
+ if (fault !== void 0) {
887
+ const verdict = await vetting.again({
888
+ fault,
889
+ hash: tip.hash
890
+ });
891
+ if (verdict.kind !== "overruled") return verdict;
892
+ vetting.waived = true;
893
+ }
894
+ if (await naiveGreen(tip)) return void 0;
895
+ const verdict = await vetting.again({ red: tip.hash });
896
+ return verdict.kind === "overruled" ? void 0 : verdict;
897
+ };
898
+ /** The judge's ruling on a vetted pass: the decision, or the next attempt's verdict. */
899
+ const ruled = async (tip, again) => {
900
+ const shape = await judgeSeam(tip);
901
+ if (shape.choice === "land") return { reference: tip.hash };
902
+ if (shape.choice === "refactor-first") return {
903
+ reference: tip.hash,
904
+ shape
905
+ };
906
+ const next = await again({
907
+ strays: shape.strays,
908
+ p: shape.judgment?.answers.shape?.p ?? 0
909
+ });
910
+ return next.kind === "overruled" ? { reference: tip.hash } : next;
911
+ };
912
+ const decide = () => scope("seam", async () => {
913
+ const vetting = {
914
+ again: budget(),
915
+ waived: false
916
+ };
917
+ let retry;
918
+ for (;;) {
919
+ await naivePass(retry);
920
+ const tip = kept("naive");
921
+ const outcome = await vetted(tip, vetting) ?? await ruled(tip, vetting.again);
922
+ if (!("kind" in outcome)) return outcome;
923
+ retry = outcome.kind === "retry" ? outcome.retry : void 0;
800
924
  }
801
925
  });
802
926
  const seamBuild = async (base) => {
@@ -898,4 +1022,4 @@ const feature = shipped(ordinaryStart);
898
1022
  //#region src/workflows/feature/index.ts
899
1023
  var feature_default = feature;
900
1024
  //#endregion
901
- export { REFACTOR_PACKAGE, access, afterTail, answerProductQuestions, answerReviewQuestions, buildFeature, buildTail, collecting, criteriaOf, feature_default as default, defaults, deferNotes, design, envDefaults, feature, fixNits, implement, judgeSeam, naivePass, ordinaryStart, planAndBuild, reUnwind, refactorCollecting, refactorFirst, refactorStrays, requireCriteria, requireCriteriaFrozen, requireNoNewFlags, requireNoTestFiles, requireTestCallSitesOnly, requireWithin, reviewedRefactor, seamBuild, skills, skipDesign, statedCriteria, steering, summary, triage, unwind };
1025
+ export { COMMENT, REFACTOR_PACKAGE, acceptanceFault, access, afterTail, answerProductQuestions, answerReviewQuestions, buildFeature, buildTail, collecting, criteriaOf, feature_default as default, defaults, deferNotes, design, envDefaults, feature, fixNits, holdsCallSites, implement, isTestFile, judgeSeam, naivePass, ordinaryStart, planAndBuild, reUnwind, refactorCollecting, refactorFirst, refactorStrays, requireCriteria, requireCriteriaFrozen, requireNoNewFlags, requireNoTestFiles, requireTestCallSitesOnly, requireWithin, reviewedRefactor, seamBuild, skills, skipDesign, statedCriteria, steering, summary, triage, unwind };
@@ -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-BU5Zi-nX.mjs";
4
+ import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-DuXanjIY.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-BU5Zi-nX.mjs";
3
+ import { a as refusePendingHandOff, r as handOffTail } from "../chunks/tail-DuXanjIY.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-BU5Zi-nX.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.";