@pmelab/gtd 30.1.0 → 31.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 (32) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +6 -1
  3. package/dist/chunks/{reserved-DDiiAZ8U.mjs → reserved-CHTJTzpA.mjs} +52 -3
  4. package/dist/chunks/{tail-ILPlELuy.mjs → tail-CCsDBbZe.mjs} +1 -1
  5. package/dist/workflows/cleanup.mjs +2 -2
  6. package/dist/workflows/feature.mjs +137 -8
  7. package/dist/workflows/fix.mjs +2 -2
  8. package/dist/workflows/review.mjs +2 -2
  9. package/dist/workflows/rollout.mjs +1 -1
  10. package/dist/workflows.mjs +188 -10
  11. package/package.json +1 -1
  12. package/src/workflows/feature/acceptance-criteria-tier.feature +25 -0
  13. package/src/workflows/feature/acceptance-criteria.feature +189 -0
  14. package/src/workflows/feature/criteria.test.ts +106 -0
  15. package/src/workflows/feature/criteria.ts +168 -0
  16. package/src/workflows/feature/fast-path.feature +4 -0
  17. package/src/workflows/feature/feature.test.ts +41 -11
  18. package/src/workflows/feature/feature.ts +7 -4
  19. package/src/workflows/feature/flag-mode.feature +8 -0
  20. package/src/workflows/feature/index.ts +1 -0
  21. package/src/workflows/feature/one-step-land.feature +4 -0
  22. package/src/workflows/feature/planning.ts +2 -0
  23. package/src/workflows/feature/refactor-absorbs.feature +4 -0
  24. package/src/workflows/feature/refactor-first.feature +4 -0
  25. package/src/workflows/feature/refactor-review.feature +24 -0
  26. package/src/workflows/feature/risk-tier.feature +32 -0
  27. package/src/workflows/feature/seam-rejection.feature +8 -0
  28. package/src/workflows/feature/seam.feature +8 -0
  29. package/src/workflows/feature/seam.ts +12 -7
  30. package/src/workflows/feature/tier-pin.feature +4 -0
  31. package/src/workflows/index.ts +1 -0
  32. package/src/workflows/shared/text.ts +77 -2
@@ -499,7 +499,7 @@ const fixFindings = async (f) => {
499
499
  const DOC_STRING = /^("""|```)/;
500
500
  const THEN = /^Then\s/;
501
501
  const CONTINUATION = /^(And|But|\*)\s/;
502
- const HEADING = /^(Feature|Rule|Background|Scenario|Scenario Outline|Scenario Template|Example):/;
502
+ const HEADING$1 = /^(Feature|Rule|Background|Scenario|Scenario Outline|Scenario Template|Example):/;
503
503
  /** Trimmed lines outside doc strings, whose content is test input, not steps. */
504
504
  const stepLines = (source) => {
505
505
  const out = [];
@@ -519,7 +519,7 @@ const assertionLines = (source) => {
519
519
  let inThen = false;
520
520
  let scenario = "";
521
521
  for (const line of stepLines(source)) {
522
- if (HEADING.test(line)) scenario = line;
522
+ if (HEADING$1.test(line)) scenario = line;
523
523
  if (THEN.test(line)) inThen = true;
524
524
  else if (!CONTINUATION.test(line) && !neutral(line)) inThen = false;
525
525
  if (inThen && !neutral(line)) out.push({
@@ -17223,6 +17223,21 @@ const codeThreadReplies = () => {
17223
17223
  concluded thread in and delete its lines:
17224
17224
  ${waiting.map((t) => ` - ${t.path}:${t.line}: ${t.first}`).join("\n")}`;
17225
17225
  };
17226
+ const acceptanceCriteriaBlock = `
17227
+ ## Acceptance criteria
17228
+
17229
+ Whichever lap this is, \`.gtd/REQUIREMENTS.md\` carries a
17230
+ \`## Acceptance Criteria\` section: one \`- AC-<n>: <criterion>\` line
17231
+ per criterion, numbered from \`AC-1\`. They bind every later step —
17232
+ the build is done when each one holds — so each must be checkable: a
17233
+ test or a command shows it holds or not. A later lap keeps every ID:
17234
+ reword a criterion in place, append a new one with the next unused
17235
+ number, delete a dropped one — never renumber, never reuse an ID. When
17236
+ the file holds no list (a review loop-back rewrites it), recover the
17237
+ last settled one from history (\`git log -p -- .gtd/REQUIREMENTS.md\`)
17238
+ and carry its IDs over. Landing is refused while a criterion has no ID or
17239
+ two share one.
17240
+ `;
17226
17241
  const riskTierBlock = `
17227
17242
  ## Risk tier and production signals
17228
17243
 
@@ -17266,7 +17281,7 @@ ${footnoteFoldIn}${codeThreadReplies()}
17266
17281
  or both
17267
17282
  - Whichever lap this is, leave \`.gtd/REQUIREMENTS.md\`
17268
17283
  uncommitted and finish once it reflects this lap's own work
17269
-
17284
+ ${acceptanceCriteriaBlock}
17270
17285
  ## First lap
17271
17286
 
17272
17287
  \`.gtd/REQUIREMENTS.md\` does not exist yet (or holds whatever the
@@ -17321,6 +17336,34 @@ ${questionBarReturn}
17321
17336
  made). If red, make that breakage the first concern, ahead of
17322
17337
  everything else — the build assumes the suite started green
17323
17338
  ${riskTier ? riskTierBlock : ""}`;
17339
+ const clearCriteriaQuestion = {
17340
+ id: "clear",
17341
+ primitive: "noul",
17342
+ instructions: "state.criteria lists the acceptance criteria the human's sketch (state.sketch) stated, as gtd numbered them. Decide whether they can stand as the binding definition of done, with no design conversation.",
17343
+ criteria: "yes: every criterion is clear and checkable — a test or a command can show it holds or not — and together they say what to build. no: any one criterion is vague, subjective or untestable, or the sketch leaves an open product question. Unsure means no."
17344
+ };
17345
+ const clearCriteriaMessage = () => `The sketch stated its own acceptance criteria. Run \`gtd judge answer\` and
17346
+ pipe \`yes\` when every one is clear and checkable, so design is skipped and
17347
+ they are recorded as they are — or land without a verdict to run the
17348
+ ordinary design conversation.
17349
+ `;
17350
+ const criterionLine = (c) => `- ${c.id}: ${c.text.replace(/\s+/g, " ")}`;
17351
+ /** Writes \`.gtd/REQUIREMENTS.md\` from criteria the sketch stated, wrapped the way oxfmt writes it. */
17352
+ const normaliseCriteriaScript = (stated) => {
17353
+ return `mkdir -p .gtd
17354
+ printf '%s\\n' ${[
17355
+ "# Requirements",
17356
+ "",
17357
+ `Stated in \`${stated.path}\` by the sketch:`,
17358
+ "",
17359
+ `\`git show ${stated.commit}:${stated.path}\``,
17360
+ "",
17361
+ "## Acceptance Criteria",
17362
+ "",
17363
+ ...stated.criteria.flatMap((c) => wrapItem(criterionLine(c)))
17364
+ ].map(quote).join(" ")} > ${quote(".gtd/REQUIREMENTS.md")}
17365
+ `;
17366
+ };
17324
17367
  const designSystem = () => `${designPersona}
17325
17368
 
17326
17369
  ${agentConduct}`;
@@ -17645,7 +17688,8 @@ What each change does next (then run \`gtd land\`):
17645
17688
  const wrapItem = (item) => {
17646
17689
  const out = [];
17647
17690
  let line = "";
17648
- for (const word of item.split(" ")) if (line !== "" && line.length + 1 + word.length > 80) {
17691
+ const words = item.split(" ").reduce((acc, word) => acc.length > 0 && /^([-+*>#]|\d+[.)])$/.test(word) ? [...acc.slice(0, -1), `${acc.at(-1)} ${word}`] : [...acc, word], []);
17692
+ for (const word of words) if (line !== "" && line.length + 1 + word.length > 80) {
17649
17693
  out.push(line);
17650
17694
  line = ` ${word}`;
17651
17695
  } else line = line === "" ? word : `${line} ${word}`;
@@ -17943,8 +17987,12 @@ Print the closing message and stop — this writes nothing itself.
17943
17987
  `;
17944
17988
  };
17945
17989
  const listed = (paths, unreadable = []) => paths.map((path) => ` - \`${displayPath(path)}\`${unreadable.includes(path) ? " (unreadable)" : ""}`).join("\n");
17990
+ const criteriaBinding = (refused) => `- Its \`## Acceptance Criteria\` (\`AC-<n>\`) bind this turn: re-read them from
17991
+ the file, never from memory, and stop only when each one holds. They are
17992
+ settled — ${refused ? "a turn that changes them is refused" : "leave them as they are"}`;
17946
17993
  const naivePassPrompt = (rejection) => `${stateFileRules}
17947
17994
  - Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
17995
+ ${criteriaBinding(false)}
17948
17996
  - Implement them the quickest way that works — the naive version. Do not
17949
17997
  restructure, abstract or refactor around the change; a later step judges
17950
17998
  the shape of your diff and a reviewer makes it fit
@@ -17959,6 +18007,7 @@ Start over and choose a different approach; do not rebuild the same diff.
17959
18007
  `}`;
17960
18008
  const implementBuildingPrompt = (reference, tier, amend, afterRefactor, flagMode = true) => `${stateFileRules}
17961
18009
  - Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
18010
+ ${criteriaBinding(true)}
17962
18011
  ${afterRefactor === true ? `- A reviewed refactor has just landed and may already carry part of the change,
17963
18012
  though never a release flag. Build only what is still missing, held to every
17964
18013
  rule below; if nothing is, change nothing and finish your turn
@@ -37694,6 +37743,128 @@ const markFenced = (text) => {
37694
37743
  });
37695
37744
  };
37696
37745
  //#endregion
37746
+ //#region src/workflows/feature/criteria.ts
37747
+ /** Below this the judge's `yes` is not trusted: a wrong skip builds against criteria nobody can check. */
37748
+ const CLEAR_MIN_P = .8;
37749
+ const HEADING = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
37750
+ const ITEM = /^ ?(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?(.*)$/;
37751
+ const ID = /^(AC-\d+):\s*(.*)$/;
37752
+ /** The list items under the first heading `title` matches (at `level` when given), up to the next heading; wrapped lines joined, fenced lines ignored. */
37753
+ const listUnder = (text, title, level) => {
37754
+ const lines = markFenced(text);
37755
+ const at = lines.findIndex((l) => {
37756
+ const heading = l.fenced ? null : HEADING.exec(l.line);
37757
+ return heading !== null && (level === void 0 || heading[1].length === level) && title.test(heading[2]);
37758
+ });
37759
+ if (at === -1) return void 0;
37760
+ const items = [];
37761
+ let open = false;
37762
+ for (const { line, fenced } of lines.slice(at + 1)) {
37763
+ if (fenced) {
37764
+ open = false;
37765
+ continue;
37766
+ }
37767
+ if (HEADING.test(line)) break;
37768
+ const item = ITEM.exec(line);
37769
+ if (item !== null) {
37770
+ items.push(item[1].trim());
37771
+ open = true;
37772
+ } else if (open && line.trim() !== "") items[items.length - 1] = `${items.at(-1)} ${line.trim()}`.trim();
37773
+ else open = false;
37774
+ }
37775
+ return items;
37776
+ };
37777
+ /** The `- AC-<n>: …` list of `## Acceptance Criteria`, or `undefined` when the section is missing. */
37778
+ const criteriaOf = (text) => listUnder(text, /^acceptance criteria$/i, 2)?.map((item) => {
37779
+ const id = ID.exec(item);
37780
+ return id === null ? {
37781
+ id: "",
37782
+ text: item
37783
+ } : {
37784
+ id: id[1],
37785
+ text: id[2]
37786
+ };
37787
+ });
37788
+ /** Items numbered `AC-1`…, unless every one already carries its own distinct ID. */
37789
+ const numbered = (items) => {
37790
+ const ids = items.map((item) => ID.exec(item));
37791
+ const own = ids.every((id) => id !== null) && new Set(ids.map((id) => id[1])).size === ids.length;
37792
+ return items.map((item, i) => own ? {
37793
+ id: ids[i][1],
37794
+ text: ids[i][2]
37795
+ } : {
37796
+ id: `AC-${i + 1}`,
37797
+ text: item.replace(ID, "$2")
37798
+ });
37799
+ };
37800
+ const STATED = /^acceptance criteria:?$/i;
37801
+ const statedItems = (text) => text === void 0 ? [] : (listUnder(text, STATED) ?? []).filter((item) => item !== "");
37802
+ /**
37803
+ * The criteria a `TODO.md` in the sketch `sketch` (the idle turn's changes)
37804
+ * added or changed under an `Acceptance criteria` heading. More than one such
37805
+ * file is ambiguous, and any other change in the sketch is input only triage
37806
+ * folds in, so none.
37807
+ */
37808
+ const statedCriteria = (sketch, commit) => {
37809
+ const found = sketch.flatMap((change) => {
37810
+ if (!/(^|\/)TODO\.md$/.test(change.path) || change.after === void 0) return [];
37811
+ const items = statedItems(change.after);
37812
+ if (items.length === 0 || items.join("\n") === statedItems(change.before).join("\n")) return [];
37813
+ return [{
37814
+ path: change.path,
37815
+ commit,
37816
+ text: change.after,
37817
+ criteria: numbered(items)
37818
+ }];
37819
+ });
37820
+ return found.length === 1 && sketch.length === 1 ? found[0] : void 0;
37821
+ };
37822
+ const listing = (criteria) => criteria.map(criterionLine).join("\n");
37823
+ /** Refuse a turn that leaves `file` without ID'd acceptance criteria. A deleted file is `requireProgress`'s call. */
37824
+ const requireCriteria = (file) => {
37825
+ const text = read(file);
37826
+ if (text === void 0) return;
37827
+ const criteria = criteriaOf(text) ?? [];
37828
+ const unnamed = criteria.filter((c) => c.id === "");
37829
+ const ids = criteria.map((c) => c.id).filter((id) => id !== "");
37830
+ const twice = ids.filter((id, i) => ids.indexOf(id) !== i);
37831
+ if (criteria.length > 0 && unnamed.length === 0 && twice.length === 0) return;
37832
+ refuse(`gtd land: acceptance-criteria: ${file} has ${criteria.length === 0 ? `no "## Acceptance Criteria" list` : unnamed.length > 0 ? `${unnamed.length} criterion without an ID:\n${unnamed.map((c) => ` - ${c.text}`).join("\n")}` : `duplicate IDs: ${[...new Set(twice)].join(", ")}`} — list every criterion under "## Acceptance Criteria" as "- AC-<n>: <checkable criterion>", each ID unique and never reused`);
37833
+ };
37834
+ /** Refuse a turn that changed the acceptance criteria in `file`: after the design gate only another design lap may. */
37835
+ const requireCriteriaFrozen = (file) => {
37836
+ const change = changes().get(file);
37837
+ if (change === void 0) return;
37838
+ if (listing(criteriaOf(change.before ?? "") ?? []) === listing(criteriaOf(change.after ?? "") ?? [])) return;
37839
+ refuse(`gtd land: criteria-frozen: the acceptance criteria in ${file} are settled — restore them; only a design lap changes them`);
37840
+ };
37841
+ /** Whether the judge trusts `stated` as clear and checkable, every criterion of it. Truncated evidence never is. */
37842
+ const clear = async (stated) => {
37843
+ const { answers, truncated } = await judge("clear", {
37844
+ questions: [clearCriteriaQuestion],
37845
+ evidence: {
37846
+ criteria: listing(stated.criteria),
37847
+ sketch: stated.text
37848
+ },
37849
+ message: clearCriteriaMessage(),
37850
+ label: "Judging the stated acceptance criteria"
37851
+ });
37852
+ return truncated.length === 0 && answered(answers.clear, "yes", CLEAR_MIN_P);
37853
+ };
37854
+ /**
37855
+ * The skip path: when the sketch stated clear, checkable criteria, write them
37856
+ * to `.gtd/REQUIREMENTS.md` as they are and resolve `true` — no triage, no
37857
+ * design gate. `false` when design must run.
37858
+ */
37859
+ const skipDesign = (stated) => stated === void 0 ? Promise.resolve(false) : scope("design", async () => {
37860
+ if (!await clear(stated)) return false;
37861
+ await run("normalise", normaliseCriteriaScript(stated), {
37862
+ label: "Recording the stated acceptance criteria",
37863
+ file: REQUIREMENTS
37864
+ });
37865
+ return true;
37866
+ });
37867
+ //#endregion
37697
37868
  //#region src/workflows/feature/tier.ts
37698
37869
  /** GTD_FLAG_RISK_TIER: pinned at process start, so a mid-process flip never moves a process. */
37699
37870
  const riskTierOn = () => vars.flag_risk_tier === "1";
@@ -37814,6 +37985,7 @@ const design = (base) => scope("design", async () => {
37814
37985
  await triage(base);
37815
37986
  requireProgress(REQUIREMENTS);
37816
37987
  requireReplies(REQUIREMENTS);
37988
+ requireCriteria(REQUIREMENTS);
37817
37989
  } while (await noteGate(REQUIREMENTS, answerProductQuestions));
37818
37990
  });
37819
37991
  //#endregion
@@ -38308,12 +38480,16 @@ function implement(first, tier, amend) {
38308
38480
  system: builderSystem(),
38309
38481
  ...options.afterRefactor === true ? { allowEmpty: true } : {}
38310
38482
  });
38483
+ requireCriteriaFrozen(REQUIREMENTS);
38311
38484
  await healthy(() => agentWithSkills("fix.suite.fixing", implementFixPrompt(), {
38312
38485
  label: "Fixing the check",
38313
38486
  file: FEEDBACK,
38314
38487
  model: coder(),
38315
38488
  system: builderSystem()
38316
- }));
38489
+ }), {
38490
+ guard: () => requireCriteriaFrozen(REQUIREMENTS),
38491
+ guardRests: false
38492
+ });
38317
38493
  await run("closing", removeScript([REQUIREMENTS]), { label: "Closing out the implementation" });
38318
38494
  });
38319
38495
  }
@@ -38344,6 +38520,7 @@ const decide = () => scope("seam", async () => {
38344
38520
  acceptClean: true
38345
38521
  });
38346
38522
  if (changes().length === 0) return { reference: tip.hash };
38523
+ requireCriteria(REQUIREMENTS);
38347
38524
  rejection = void 0;
38348
38525
  retries = 0;
38349
38526
  }
@@ -38400,9 +38577,9 @@ const buildFeature = async (firstBase) => {
38400
38577
  }), base);
38401
38578
  });
38402
38579
  };
38403
- /** Design, then `buildFeature`. */
38404
- const planAndBuild = async (firstBase) => {
38405
- await design(firstBase);
38580
+ /** Design — skipped when the sketch's own criteria are judged clear — then `buildFeature`. */
38581
+ const planAndBuild = async (firstBase, stated) => {
38582
+ if (!await skipDesign(riskTierOn() ? void 0 : stated)) await design(firstBase);
38406
38583
  await buildFeature(firstBase);
38407
38584
  };
38408
38585
  const featureFeedback = async (first, lensBase) => {
@@ -38432,6 +38609,7 @@ const ordinaryStart = async () => {
38432
38609
  });
38433
38610
  refusePendingHandOff();
38434
38611
  flagModeOn();
38612
+ const stated = statedCriteria(changes(), head$1());
38435
38613
  await unwind();
38436
38614
  if (changes(".gtd/FEEDBACK.md").some((c) => c.status !== "deleted")) await human("unwind-failed", {
38437
38615
  message: unwindFailedMessage(),
@@ -38439,7 +38617,7 @@ const ordinaryStart = async () => {
38439
38617
  file: FEEDBACK
38440
38618
  });
38441
38619
  await gate("start-gate", startGateBlockedMessage());
38442
- await planAndBuild(start());
38620
+ await planAndBuild(start(), stated);
38443
38621
  };
38444
38622
  const feature = shipped(ordinaryStart);
38445
38623
  //#endregion
@@ -38625,4 +38803,4 @@ var workflows_default = feature;
38625
38803
  */
38626
38804
  const doors = {};
38627
38805
  //#endregion
38628
- 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, 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, requireNoNewFlags, requireNoTestFiles, requireTestCallSitesOnly, requireWithin, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewedRefactor, reviewing, rollout, rolloutFlow, seamBuild, skills, steering, summary, tail, threads, triage, unwind };
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "30.1.0",
3
+ "version": "31.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": {
@@ -0,0 +1,25 @@
1
+ @inmem
2
+ Feature: with GTD_FLAG_RISK_TIER on, stated acceptance criteria never skip design
3
+
4
+ The risk tier is proposed at `design.triage` and set at the design gate, so
5
+ with the flag on a sketch that states its own criteria still runs triage and
6
+ is never judged at `design.clear`.
7
+
8
+ Scenario: stated criteria go through triage, where the tier is proposed and set
9
+ Given the shipped workflows, as gtd install scaffolds them
10
+ And an environment variable "GTD_QUALITYREVIEWS" set to ""
11
+ And an environment variable "GTD_FLAG_RISK_TIER" set to "1"
12
+ And step "unwind" changes nothing
13
+ And step "start-gate.check" changes nothing
14
+ And a file "TODO.md" with:
15
+ """
16
+ ## Acceptance criteria
17
+
18
+ - `greeting` reads "Hello, world".
19
+ """
20
+ When the process is driven
21
+ Then the process rests at "design.triage"
22
+ And the commit subjects end with:
23
+ """
24
+ gtd(check): start-gate.check → design.triage
25
+ """
@@ -0,0 +1,189 @@
1
+ @inmem
2
+ Feature: acceptance criteria carry stable IDs, and a sketch that states clear ones skips design
3
+
4
+ `.gtd/REQUIREMENTS.md` lists the change's acceptance criteria as
5
+ `- AC-<n>: …` under `## Acceptance Criteria`. On the full path
6
+ `design.triage` writes them and the design gate settles them; a sketch
7
+ that already states its criteria under an `Acceptance criteria` heading
8
+ is judged at `design.clear`, and a `yes` records them as they are — no
9
+ triage, no design gate. After design they are frozen.
10
+
11
+ Background:
12
+ Given the shipped workflows, as gtd install scaffolds them
13
+ And an environment variable "GTD_QUALITYREVIEWS" set to ""
14
+ And an environment variable "GTD_FLAG_RISK_TIER" set to "0"
15
+ And a file "src/greeting.ts" with:
16
+ """
17
+ export const greeting = "Helo, world"
18
+ """
19
+ And the working tree is committed as "feat: add greeting"
20
+ And step "unwind" changes nothing
21
+ And step "start-gate.check" changes nothing
22
+
23
+ Rule: the full path
24
+
25
+ Background:
26
+ Given a file "TODO.md" with:
27
+ """
28
+ # Fix the typo
29
+
30
+ greeting says "Helo"; make it "Hello".
31
+ """
32
+
33
+ Scenario: triage writes ID'd criteria that reach the naive pass through the design gate
34
+ Given step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
35
+ """
36
+ # Requirements
37
+
38
+ ## Acceptance Criteria
39
+
40
+ - AC-1: `greeting` reads "Hello, world".
41
+ - AC-2: no other export changes.
42
+ """
43
+ And step "design.gate.answer" changes nothing
44
+ When the process is driven
45
+ Then the process rests at "seam.naive"
46
+ And ".gtd/REQUIREMENTS.md" contains "- AC-1: `greeting` reads \"Hello, world\"."
47
+ And ".gtd/REQUIREMENTS.md" contains "- AC-2: no other export changes."
48
+ And the resting step's content contains "Acceptance Criteria"
49
+ And the commit subjects end with:
50
+ """
51
+ gtd(check): start-gate.check → design.triage
52
+ gtd(agent): design.triage → design.gate.answer
53
+ gtd(human): design.gate.answer → seam.naive
54
+ """
55
+
56
+ Scenario: a triage turn whose criteria carry no ID is refused
57
+ Given step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
58
+ """
59
+ # Requirements
60
+
61
+ ## Acceptance Criteria
62
+
63
+ - `greeting` reads "Hello, world".
64
+ """
65
+ Then driving the process is refused with "acceptance-criteria: .gtd/REQUIREMENTS.md has 1 criterion without an ID"
66
+
67
+ Scenario: a triage turn reusing an ID is refused
68
+ Given step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
69
+ """
70
+ # Requirements
71
+
72
+ ## Acceptance Criteria
73
+
74
+ - AC-1: `greeting` reads "Hello, world".
75
+ - AC-1: no other export changes.
76
+ """
77
+ Then driving the process is refused with "duplicate IDs: AC-1"
78
+
79
+ Scenario: an implementation turn that edits the settled criteria is refused
80
+ Given step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
81
+ """
82
+ # Requirements
83
+
84
+ ## Acceptance Criteria
85
+
86
+ - AC-1: `greeting` reads "Hello, world".
87
+ """
88
+ And step "design.gate.answer" changes nothing
89
+ And step "seam.naive" writes "src/greeting.ts" with:
90
+ """
91
+ export const greeting = "Hello, world"
92
+ """
93
+ And judge step "seam.judge" answers:
94
+ """
95
+ [{"id":"shape","answer":"land","p":0.9}]
96
+ """
97
+ And step "implement.building" writes ".gtd/REQUIREMENTS.md" with:
98
+ """
99
+ # Requirements
100
+
101
+ ## Acceptance Criteria
102
+
103
+ - AC-1: `greeting` reads "Hi".
104
+ """
105
+ Then driving the process is refused with "criteria-frozen"
106
+
107
+ Rule: the skip path
108
+
109
+ Scenario: clear stated criteria are normalised into ID'd requirements with no triage and no design gate
110
+ Given a file "TODO.md" with:
111
+ """
112
+ # Fix the typo
113
+
114
+ ## Acceptance criteria
115
+
116
+ - [ ] `greeting` reads "Hello, world".
117
+ - [ ] no other export changes.
118
+ """
119
+ And judge step "design.clear" answers:
120
+ """
121
+ [{"id":"clear","answer":"yes","p":0.9}]
122
+ """
123
+ And step "design.normalise" runs its script
124
+ When the process is driven
125
+ Then the process rests at "seam.naive"
126
+ And ".gtd/REQUIREMENTS.md" contains "- AC-1: `greeting` reads \"Hello, world\"."
127
+ And ".gtd/REQUIREMENTS.md" contains "- AC-2: no other export changes."
128
+ And the commit subjects end with:
129
+ """
130
+ gtd(human): idle → unwind
131
+ gtd(check): unwind → start-gate.check
132
+ gtd(check): start-gate.check → design.clear
133
+ gtd(judge): design.clear → design.normalise
134
+ gtd(check): design.normalise → seam.naive
135
+ """
136
+
137
+ Scenario: IDs the sketch already gave its criteria are kept
138
+ Given a file "TODO.md" with:
139
+ """
140
+ ## Acceptance criteria
141
+
142
+ 1. AC-3: `greeting` reads "Hello, world".
143
+ 2. AC-7: no other export changes.
144
+ """
145
+ And judge step "design.clear" answers:
146
+ """
147
+ [{"id":"clear","answer":"yes","p":0.9}]
148
+ """
149
+ And step "design.normalise" runs its script
150
+ When the process is driven
151
+ Then the process rests at "seam.naive"
152
+ And ".gtd/REQUIREMENTS.md" contains "- AC-3: `greeting` reads \"Hello, world\"."
153
+ And ".gtd/REQUIREMENTS.md" contains "- AC-7: no other export changes."
154
+
155
+ Scenario: one criterion the judge finds uncheckable falls back to the full path
156
+ Given a file "TODO.md" with:
157
+ """
158
+ # Fix the typo
159
+
160
+ ## Acceptance criteria
161
+
162
+ - `greeting` reads "Hello, world".
163
+ - the greeting feels friendlier.
164
+ """
165
+ And judge step "design.clear" answers:
166
+ """
167
+ [{"id":"clear","answer":"no","p":0.9}]
168
+ """
169
+ When the process is driven
170
+ Then the process rests at "design.triage"
171
+ And the commit subjects end with:
172
+ """
173
+ gtd(check): start-gate.check → design.clear
174
+ gtd(judge): design.clear → design.triage
175
+ """
176
+
177
+ Scenario: a sketch with no criteria heading goes straight to triage, unjudged
178
+ Given a file "TODO.md" with:
179
+ """
180
+ # Fix the typo
181
+
182
+ - `greeting` reads "Hello, world".
183
+ """
184
+ When the process is driven
185
+ Then the process rests at "design.triage"
186
+ And the commit subjects end with:
187
+ """
188
+ gtd(check): start-gate.check → design.triage
189
+ """
@@ -0,0 +1,106 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import type { Change } from "../../flows/index.js"
3
+ import { criteriaOf, statedCriteria } from "./criteria.js"
4
+
5
+ const added = (path: string, after: string): Change => ({
6
+ path,
7
+ status: "added",
8
+ before: undefined,
9
+ after,
10
+ })
11
+
12
+ describe("criteriaOf", () => {
13
+ it("reads ID'd items, joining wrapped lines, and stops at the next section", () => {
14
+ expect(
15
+ criteriaOf(
16
+ "# R\n\n## Acceptance Criteria\n\n- AC-1: one\n continued\n- two\n\n## Notes\n\n- AC-9: not one\n",
17
+ ),
18
+ ).toEqual([
19
+ { id: "AC-1", text: "one continued" },
20
+ { id: "", text: "two" },
21
+ ])
22
+ })
23
+
24
+ it("skips fenced lines, reads an indented item, and stops at a subheading", () => {
25
+ expect(
26
+ criteriaOf(
27
+ "## Acceptance Criteria\n\n- AC-1: runs\n\n```sh\n# setup\n- key: v\n```\n - AC-2: two\n\n### Notes\n\n- note\n",
28
+ ),
29
+ ).toEqual([
30
+ { id: "AC-1", text: "runs" },
31
+ { id: "AC-2", text: "two" },
32
+ ])
33
+ })
34
+
35
+ it("reads only the level-2 section and joins an unindented wrapped line", () => {
36
+ expect(
37
+ criteriaOf(
38
+ "### Acceptance criteria\n\n- x\n\n## Acceptance Criteria\n\n- AC-1: exits 2 when the config\nfile is missing\n",
39
+ ),
40
+ ).toEqual([{ id: "AC-1", text: "exits 2 when the config file is missing" }])
41
+ })
42
+
43
+ it("folds a nested sub-bullet into its criterion", () => {
44
+ expect(
45
+ criteriaOf(
46
+ "## Acceptance Criteria\n\n- AC-1: exits 2 when config is bad\n - missing file\n",
47
+ ),
48
+ ).toEqual([{ id: "AC-1", text: "exits 2 when config is bad - missing file" }])
49
+ })
50
+
51
+ it("is undefined without the section", () => {
52
+ expect(criteriaOf("# R\n\n- AC-1: x\n")).toBeUndefined()
53
+ })
54
+ })
55
+
56
+ describe("statedCriteria", () => {
57
+ it("numbers a stated checklist from AC-1", () => {
58
+ const sketch = [added("TODO.md", "# T\n\n### Acceptance criteria:\n\n- [ ] a\n* b\n\nprose\n")]
59
+ expect(statedCriteria(sketch, "abc")).toMatchObject({
60
+ path: "TODO.md",
61
+ commit: "abc",
62
+ criteria: [
63
+ { id: "AC-1", text: "a" },
64
+ { id: "AC-2", text: "b" },
65
+ ],
66
+ })
67
+ })
68
+
69
+ it("renumbers when the stated IDs are partial or clash", () => {
70
+ const sketch = [added("TODO.md", "## Acceptance criteria\n\n- AC-4: a\n- b\n")]
71
+ expect(statedCriteria(sketch, "abc")?.criteria).toEqual([
72
+ { id: "AC-1", text: "a" },
73
+ { id: "AC-2", text: "b" },
74
+ ])
75
+ })
76
+
77
+ it("ignores a list the sketch left unchanged, a file other than TODO.md, a sketch with more in it, and an ambiguous pair", () => {
78
+ const list = "## Acceptance criteria\n\n- a\n"
79
+ expect(
80
+ statedCriteria(
81
+ [{ path: "TODO.md", status: "modified", before: list, after: `${list}\nmore\n` }],
82
+ "abc",
83
+ ),
84
+ ).toBeUndefined()
85
+ expect(
86
+ statedCriteria(
87
+ [
88
+ {
89
+ path: "TODO.md",
90
+ status: "modified",
91
+ before: `${list}- \n`,
92
+ after: `${list}- \n\nmore\n`,
93
+ },
94
+ ],
95
+ "abc",
96
+ ),
97
+ ).toBeUndefined()
98
+ expect(statedCriteria([added("docs/guide.md", list)], "abc")).toBeUndefined()
99
+ expect(
100
+ statedCriteria([added("TODO.md", list), added("src/a.ts", "x\n")], "abc"),
101
+ ).toBeUndefined()
102
+ expect(
103
+ statedCriteria([added("TODO.md", list), added(".gtd/TODO.md", list)], "abc"),
104
+ ).toBeUndefined()
105
+ })
106
+ })