@pmelab/gtd 32.1.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.
@@ -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) => {
@@ -17999,24 +18000,47 @@ Print the closing message and stop — this writes nothing itself.
17999
18000
  `;
18000
18001
  };
18001
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\``;
18002
18006
  const criteriaBinding = (refused) => `- Its \`## Acceptance Criteria\` (\`AC-<n>\`) bind this turn: re-read them from
18003
18007
  the file, never from memory, and stop only when each one holds. They are
18004
18008
  settled — ${refused ? "a turn that changes them is refused" : "leave them as they are"}`;
18005
- 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}
18006
18025
  - Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
18007
18026
  ${criteriaBinding(false)}
18008
18027
  - Implement them the quickest way that works — the naive version. Do not
18009
18028
  restructure, abstract or refactor around the change; a later step judges
18010
18029
  the shape of your diff and a reviewer makes it fit
18011
- - 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
18012
18042
  - Leave everything uncommitted and finish your turn — do not commit
18013
- ${rejection === void 0 ? "" : `
18014
- Your previous attempt was rejected: the judge answered \`reject\` (p=${rejection.p}),
18015
- meaning the naive pass solved the task the wrong way — discard it and
18016
- re-implement. It changed these files outside the expected set:
18017
- ${listed(rejection.strays)}
18018
- Start over and choose a different approach; do not rebuild the same diff.
18019
- `}`;
18043
+ ${retry === void 0 ? "" : naiveRetry(retry)}`;
18020
18044
  const implementBuildingPrompt = (reference, tier, amend, afterRefactor, flagMode = true) => `${stateFileRules}
18021
18045
  - Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
18022
18046
  ${criteriaBinding(true)}
@@ -18041,8 +18065,41 @@ const implementFixPrompt = () => `${stateFileRules}
18041
18065
  ${fixFeedbackPrompt}
18042
18066
  - Leave everything uncommitted and finish your turn — do not commit
18043
18067
  `;
18044
- const seamSteerMessage = (rejection) => `The naive pass was rejected three times and the retries are spent.
18045
- 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:
18046
18103
  ${listed(rejection.strays)}
18047
18104
 
18048
18105
  Two ways forward (then run \`gtd land\`):
@@ -38473,7 +38530,7 @@ const reviewedRefactor = async (shape, options = {}) => {
38473
38530
  };
38474
38531
  //#endregion
38475
38532
  //#region src/workflows/feature/seam.ts
38476
- /** 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`. */
38477
38534
  const NAIVE_RETRIES = 2;
38478
38535
  /** A change to an existing file outside the allowlist is a stray even when the task needs it: the judge decides. */
38479
38536
  const judgeSeam = (tip) => judgeDiffShape("judge", {
@@ -38481,12 +38538,95 @@ const judgeSeam = (tip) => judgeDiffShape("judge", {
38481
38538
  allow: (vars.seamAllow ?? defaults.seamAllow).split(",").map((glob) => glob.trim()).filter((glob) => glob !== ""),
38482
38539
  exempt: tip.changes.filter((c) => c.status === "added").map((c) => c.path)
38483
38540
  });
38484
- const naivePass = (rejection) => agentWithSkills("naive", naivePassPrompt(rejection), {
38485
- 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",
38486
38543
  keep: true,
38487
38544
  model: coder(),
38488
38545
  system: builderSystem()
38489
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
+ };
38490
38630
  function implement(first, tier, amend) {
38491
38631
  const options = typeof first === "object" && first !== null ? first : {
38492
38632
  reference: first,
@@ -38513,36 +38653,76 @@ function implement(first, tier, amend) {
38513
38653
  await run("closing", removeScript([REQUIREMENTS]), { label: "Closing out the implementation" });
38514
38654
  });
38515
38655
  }
38516
- const decide = () => scope("seam", async () => {
38517
- 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 = () => {
38518
38670
  let retries = 0;
38519
- for (;;) {
38520
- await naivePass(rejection);
38521
- const tip = kept("naive");
38522
- const shape = await judgeSeam(tip);
38523
- if (shape.choice === "land") return { reference: tip.hash };
38524
- if (shape.choice === "refactor-first") return {
38525
- reference: tip.hash,
38526
- shape
38527
- };
38528
- rejection = {
38529
- strays: shape.strays,
38530
- p: shape.judgment?.answers.shape?.p ?? 0
38531
- };
38671
+ return async (next) => {
38532
38672
  if (retries < NAIVE_RETRIES) {
38533
38673
  retries++;
38534
- continue;
38674
+ return {
38675
+ kind: "retry",
38676
+ retry: next
38677
+ };
38535
38678
  }
38536
- await human("steer", {
38537
- message: seamSteerMessage(rejection),
38538
- label: "Steer or overrule the rejected naive pass",
38539
- file: REQUIREMENTS,
38540
- acceptClean: true
38541
- });
38542
- if (changes().length === 0) return { reference: tip.hash };
38543
- requireCriteria(REQUIREMENTS);
38544
- rejection = void 0;
38679
+ const steered = await steer(next);
38545
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;
38546
38726
  }
38547
38727
  });
38548
38728
  const seamBuild = async (base) => {
@@ -38823,4 +39003,4 @@ var workflows_default = feature;
38823
39003
  */
38824
39004
  const doors = {};
38825
39005
  //#endregion
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 };
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.1.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": {
@@ -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,140 @@
1
+ @inmem
2
+ Feature: a naive pass ends only when a test names every criterion and the suite is green
3
+
4
+ `seam.naive` writes behaviour-level acceptance tests, each naming its
5
+ criterion's ID. A pass whose tests leave an ID out or import a file the
6
+ pass added is sent back; `seam.check` then runs the suite with the kept
7
+ pass applied, and a red run sends it back too, before the judge sees it.
8
+ Three failed attempts rest at `seam.steer`.
9
+
10
+ Background:
11
+ Given the shipped workflows, as gtd install scaffolds them
12
+ And an environment variable "GTD_QUALITYREVIEWS" set to ""
13
+ And an environment variable "GTD_FLAG_RISK_TIER" set to "0"
14
+ And a file "src/calc.ts" with:
15
+ """
16
+ export const add = (a: number, b: number) => a + b
17
+ """
18
+ And the working tree is committed as "feat: add calculator"
19
+ And a file "TODO.md" with:
20
+ """
21
+ # Add subtract
22
+
23
+ subtract(a, b) returns the difference.
24
+ """
25
+ And step "unwind" changes nothing
26
+ And step "start-gate.check" changes nothing
27
+ And step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
28
+ """
29
+ # Requirements
30
+
31
+ ## Acceptance Criteria
32
+
33
+ - AC-1: `subtract(5, 3)` returns 2.
34
+ - AC-2: `add` still adds.
35
+ """
36
+ And step "design.gate.answer" changes nothing
37
+ And step "seam.naive" writes "src/calc.ts" with:
38
+ """
39
+ export const add = (a: number, b: number) => a + b
40
+ export const subtract = (a: number, b: number) => a - b
41
+ """
42
+
43
+ Scenario: a pass whose tests leave a criterion's ID out is sent back, and an overrule runs the check and sticks
44
+ Given step "seam.naive" writes "tests/calc.test.ts" with:
45
+ """
46
+ import { add, subtract } from "../src/calc.js"
47
+ it("[AC-1] subtracts", () => expect(subtract(5, 3)).toBe(2))
48
+ """
49
+ When the process is driven
50
+ Then the process rests at "seam.steer"
51
+ And the resting step's content contains "acceptance-tests: no test of the naive pass names AC-2"
52
+
53
+ Given step "seam.check" changes nothing
54
+ When gtd lands
55
+ And the process is driven
56
+ Then the process rests at "seam.judge"
57
+ And the commit subjects end with:
58
+ """
59
+ gtd(human): design.gate.answer → seam.naive
60
+ gtd(agent): seam.naive
61
+ gtd(agent): seam.naive
62
+ gtd(agent): seam.naive → seam.steer
63
+ gtd(human): seam.steer → seam.check
64
+ gtd(check): seam.check → seam.judge
65
+ """
66
+
67
+ Given judge step "seam.judge" answers:
68
+ """
69
+ [{"id":"shape","answer":"reject","p":0.9}]
70
+ """
71
+ When the process is driven
72
+ Then the process rests at "seam.steer"
73
+ And the resting step's content contains "rejected the last attempt"
74
+ And the commit subjects end with:
75
+ """
76
+ gtd(check): seam.check → seam.judge
77
+ gtd(judge): seam.judge → seam.naive
78
+ gtd(agent): seam.naive → seam.check
79
+ gtd(check): seam.check → seam.judge
80
+ gtd(judge): seam.judge → seam.naive
81
+ gtd(agent): seam.naive → seam.check
82
+ gtd(check): seam.check → seam.judge
83
+ gtd(judge): seam.judge → seam.steer
84
+ """
85
+
86
+ Scenario: a test importing a file the pass added is sent back
87
+ Given step "seam.naive" writes "src/subtract.ts" with:
88
+ """
89
+ export const subtract = (a: number, b: number) => a - b
90
+ """
91
+ And step "seam.naive" writes "src/subtract.test.ts" with:
92
+ """
93
+ import { subtract } from "./subtract.js"
94
+ it("[AC-1] subtracts", () => expect(subtract(5, 3)).toBe(2))
95
+ it("[AC-2] still adds", () => {})
96
+ """
97
+ When the process is driven
98
+ Then the process rests at "seam.steer"
99
+ And the resting step's content contains "src/subtract.test.ts → src/subtract.ts"
100
+
101
+ Scenario: a red suite sends the pass back, and it moves on only once green
102
+ Given step "seam.naive" writes "tests/calc.test.ts" with:
103
+ """
104
+ import { add, subtract } from "../src/calc.js"
105
+ it("[AC-1] subtracts", () => expect(subtract(5, 3)).toBe(2))
106
+ it("[AC-2] still adds", () => expect(add(1, 1)).toBe(2))
107
+ """
108
+ When the process is driven
109
+ Then the process rests at "seam.check"
110
+ And the resting step's content contains "git checkout -q HEAD --"
111
+ And the resting step's content does not contain "'^'"
112
+ And the resting step's content contains "npm test"
113
+
114
+ Given step "seam.naive" is no longer scripted
115
+ And step "seam.check" writes ".gtd/FEEDBACK.md" with:
116
+ """
117
+ FAIL tests/calc.test.ts > [AC-1] subtracts
118
+ """
119
+ When the process is driven
120
+ Then the process rests at "seam.naive"
121
+ And the resting step's content contains "left the suite red"
122
+ And ".gtd/FEEDBACK.md" contains "[AC-1] subtracts"
123
+
124
+ Given step "seam.naive" writes "tests/calc.test.ts" with:
125
+ """
126
+ import { add, subtract } from "../src/calc.js"
127
+ it("[AC-1] subtracts", () => expect(subtract(5, 3)).toBe(2))
128
+ it("[AC-2] still adds", () => expect(add(2, 2)).toBe(4))
129
+ """
130
+ And step "seam.check" from now on changes nothing
131
+ When the process is driven
132
+ Then the process rests at "implement.building"
133
+ And the commit subjects end with:
134
+ """
135
+ gtd(human): design.gate.answer → seam.naive
136
+ gtd(agent): seam.naive → seam.check
137
+ gtd(check): seam.check → seam.naive
138
+ gtd(agent): seam.naive → seam.check
139
+ gtd(check): seam.check → implement.building
140
+ """
@@ -40,6 +40,11 @@ Feature: a naive pass the judge rules land goes straight to implement with no se
40
40
  - AC-1: the change behaves as described.
41
41
  """
42
42
  And step "design.gate.answer" changes nothing
43
+ And step "seam.check" changes nothing
44
+ And step "seam.naive" writes "src/acceptance.test.ts" with:
45
+ """
46
+ it("[AC-1] holds", () => {})
47
+ """
43
48
  And step "seam.naive" writes "src/greeting.ts" with:
44
49
  """
45
50
  export const greeting = "Hello, world"
@@ -93,7 +98,8 @@ Feature: a naive pass the judge rules land goes straight to implement with no se
93
98
  gtd(check): start-gate.check → design.triage
94
99
  gtd(agent): design.triage → design.gate.answer
95
100
  gtd(human): design.gate.answer → seam.naive
96
- gtd(agent): seam.naive → seam.judge
101
+ gtd(agent): seam.naive → seam.check
102
+ gtd(check): seam.check → seam.judge
97
103
  gtd(judge): seam.judge → implement.building
98
104
  gtd(agent): implement.building → implement.health.check
99
105
  gtd(check): implement.health.check → implement.closing
@@ -104,7 +110,7 @@ Feature: a naive pass the judge rules land goes straight to implement with no se
104
110
  gtd(check): build.review.closing → idle
105
111
  """
106
112
  And the commit "gtd(human): idle → unwind" has a body containing "Gtd-Var: flag_risk_tier=0"
107
- And the commit "gtd(agent): seam.naive → seam.judge" has a body containing "Gtd-Kept:"
113
+ And the commit "gtd(agent): seam.naive → seam.check" has a body containing "Gtd-Kept:"
108
114
  And the commit "gtd(judge): seam.judge → implement.building" has a body containing "\"answer\":\"land\""
109
115
  And no commit body contains "Gtd-Approved:"
110
116
  And the output lines of gtd "slices" start with:
@@ -43,7 +43,12 @@ Feature: a refactor that carried the whole feature still gets a feature review
43
43
  And step "design.gate.answer" changes nothing
44
44
 
45
45
  Scenario: an empty implement turn after a refactor that did it all reaches review and idle
46
- Given step "seam.naive" writes "src/calc.ts" with:
46
+ Given step "seam.check" changes nothing
47
+ And step "seam.naive" writes "src/acceptance.test.ts" with:
48
+ """
49
+ it("[AC-1] holds", () => {})
50
+ """
51
+ And step "seam.naive" writes "src/calc.ts" with:
47
52
  """
48
53
  export const add = (a: number, b: number) => a + b
49
54
  export const subtract = (a: number, b: number) => a - b
@@ -42,7 +42,12 @@ Feature: a refactor-first judgment walks one fixed path to idle
42
42
  And step "design.gate.answer" changes nothing
43
43
 
44
44
  Scenario: a refactor-first judgment runs refactor, its review, implement and review to idle
45
- Given step "seam.naive" writes "src/calc.ts" with:
45
+ Given step "seam.check" changes nothing
46
+ And step "seam.naive" writes "src/acceptance.test.ts" with:
47
+ """
48
+ it("[AC-1] holds", () => {})
49
+ """
50
+ And step "seam.naive" writes "src/calc.ts" with:
46
51
  """
47
52
  export const add = (a: number, b: number) => a + b
48
53
  export const subtract = (a: number, b: number) => a - b
@@ -141,7 +146,8 @@ Feature: a refactor-first judgment walks one fixed path to idle
141
146
  gtd(check): start-gate.check → design.triage
142
147
  gtd(agent): design.triage → design.gate.answer
143
148
  gtd(human): design.gate.answer → seam.naive
144
- gtd(agent): seam.naive → seam.judge
149
+ gtd(agent): seam.naive → seam.check
150
+ gtd(check): seam.check → seam.judge
145
151
  gtd(judge): seam.judge → refactor.deriving
146
152
  gtd(check): refactor.deriving → refactor.building
147
153
  gtd(agent): refactor.building → refactor.health.check
@@ -158,7 +164,7 @@ Feature: a refactor-first judgment walks one fixed path to idle
158
164
  gtd(check): build.review.closing → idle
159
165
  """
160
166
  And the commit "gtd(human): idle → unwind" has a body containing "Gtd-Var: flag_risk_tier=0"
161
- And the commit "gtd(agent): seam.naive → seam.judge" has a body containing "Gtd-Kept:"
167
+ And the commit "gtd(agent): seam.naive → seam.check" has a body containing "Gtd-Kept:"
162
168
  And the commit "gtd(judge): seam.judge → refactor.deriving" has a body containing "\"answer\":\"refactor-first\""
163
169
  And no commit body contains "Gtd-Approved:"
164
170
  And the output lines of gtd "slices" start with: