@pmelab/gtd 37.0.0 → 38.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.
@@ -16934,6 +16934,7 @@ const defaults = {
16934
16934
  refactorFlagPattern: "\\bGTD_FLAG_\\w+",
16935
16935
  seamAllow: [...testFileGlobs, ".gtd/**"].join(", "),
16936
16936
  flagMode: "true",
16937
+ sliceMaxCriteria: "8",
16937
16938
  flag_risk_tier: "0",
16938
16939
  qualityReviews: "correctness, owasp-security, ponytail-review, test-audit, conventions, spec-challenge",
16939
16940
  refactorQualityReviews: "correctness, ponytail-review, conventions"
@@ -17393,6 +17394,24 @@ printf '%s\\n' ${[
17393
17394
  ].map(quote).join(" ")} > ${quote(".gtd/REQUIREMENTS.md")}
17394
17395
  `;
17395
17396
  };
17397
+ const slicingPrompt = (cap) => `${stateFileRules}
17398
+ - Read \`.gtd/REQUIREMENTS.md\`: its \`## Acceptance Criteria\` are settled
17399
+ and more than ${cap}, too many to build in one slice
17400
+ - Group them into slices in \`.gtd/SLICES.md\`, one \`## <title>\` heading per
17401
+ slice over a \`- AC-<n>\` list item per criterion it builds. Put criteria of
17402
+ the same flow or subsystem together; every criterion in exactly one slice,
17403
+ at most ${cap} per slice
17404
+ - Keep it coarse: as few slices as the cap allows, never one per criterion
17405
+ - Order the slices so each builds on the ones before: they run in that order,
17406
+ each from where the previous one landed
17407
+ - Change nothing but \`.gtd/SLICES.md\` — leave the criteria as they are
17408
+ - Leave everything uncommitted and finish your turn — do not commit
17409
+ `;
17410
+ /** The line telling a slice's turn which criteria are its own; empty for a single slice. */
17411
+ const sliceLine = (ids) => ids === void 0 ? "" : `- This slice builds ${ids.join(", ")} only: every rule here about the criteria
17412
+ means these. The others are for other slices, before or after it — leave
17413
+ them unbuilt and untested
17414
+ `;
17396
17415
  const designSystem = () => `${designPersona}
17397
17416
 
17398
17417
  ${agentConduct}`;
@@ -18039,9 +18058,9 @@ re-implement. It changed these files outside the expected set:
18039
18058
  ${listed(retry.strays)}
18040
18059
  Start over and choose a different approach; do not rebuild the same diff.
18041
18060
  `;
18042
- const naivePassPrompt = (retry) => `${stateFileRules}
18061
+ const naivePassPrompt = (retry, slice) => `${stateFileRules}
18043
18062
  - Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
18044
- ${criteriaBinding(false)}
18063
+ ${sliceLine(slice)}${criteriaBinding(false)}
18045
18064
  - Implement them the quickest way that works — the naive version. Do not
18046
18065
  restructure, abstract or refactor around the change; it may land exactly as
18047
18066
  you leave it, and a reviewer makes it fit
@@ -18091,9 +18110,13 @@ const afterRefactorLine = (rebuild) => `- A reviewed refactor has just landed an
18091
18110
  though never a release flag. Build only what is still missing, held to every
18092
18111
  rule below; if nothing is, ${carries(rebuild) ? "carry over the acceptance tests below and change nothing else" : "change nothing and finish your turn"}
18093
18112
  `;
18094
- const implementBuildingPrompt = (reference, tier, amend, afterRefactor, flagMode = true, rebuild = {}) => `${stateFileRules}
18113
+ const flagLine = (flagMode, tier, amend, sliced) => !flagMode ? "- Add no release flag — this project ships without release flags" : tier === "low" ? "- Add no release flag — this change is low risk tier" : amend === true ? "- Keep the release flag the earlier lap added, if it added one; otherwise put\n the behaviour behind a new release flag" : sliced ? `- Put the new behaviour behind the release flag an earlier slice of this
18114
+ change added; only when none did, behind a new one, following the
18115
+ repository's flag conventions` : `- Put the new behaviour behind a new release flag, following the
18116
+ repository's flag conventions`;
18117
+ const implementBuildingPrompt = (reference, tier, amend, afterRefactor, flagMode = true, rebuild = {}, slice) => `${stateFileRules}
18095
18118
  - Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
18096
- ${criteriaBinding(true)}
18119
+ ${sliceLine(slice)}${criteriaBinding(true)}
18097
18120
  ${afterRefactor === true ? afterRefactorLine(rebuild) : ""}${amend === true ? `- The change already exists in the tree from an earlier lap; amend it to meet
18098
18121
  the re-derived requirements instead of rewriting it
18099
18122
  ` : flagMode ? `- Implement them properly, fitting the codebase's seams and conventions
@@ -18102,8 +18125,7 @@ ${afterRefactor === true ? afterRefactorLine(rebuild) : ""}${amend === true ? `-
18102
18125
  `}${reference === void 0 || amend === true ? "" : `- Reference only: \`git show ${reference}\` shows an earlier naive attempt.
18103
18126
  Read it for ideas; never apply, cherry-pick or check it out — write
18104
18127
  the change fresh${carries(rebuild) ? ", its acceptance tests excepted (below)" : ""}
18105
- `}${rebuildLines(rebuild)}${!flagMode ? "- Add no release flag — this project ships without release flags" : tier === "low" ? "- Add no release flag — this change is low risk tier" : amend === true ? "- Keep the release flag the earlier lap added, if it added one; otherwise put\n the behaviour behind a new release flag" : `- Put the new behaviour behind a new release flag, following the
18106
- repository's flag conventions`}
18128
+ `}${rebuildLines(rebuild)}${flagLine(flagMode, tier, amend, slice !== void 0)}
18107
18129
  - Write the tests first, then the code that makes them pass. Name each
18108
18130
  criterion's ID in the test that covers it (\`[AC-3] …\`): once the suite is
18109
18131
  green a judge checks every ID has a passing test, and sends uncovered ones back
@@ -18184,9 +18206,9 @@ const coverageMessage = () => `Judging acceptance-criteria coverage. Run \`gtd j
18184
18206
  \`yes\`/\`no\` verdict per criterion ID — or land without a verdict, which
18185
18207
  counts every criterion as uncovered.
18186
18208
  `;
18187
- const coveringPrompt = (uncovered) => `${stateFileRules}
18209
+ const coveringPrompt = (uncovered, slice) => `${stateFileRules}
18188
18210
  - Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
18189
- ${criteriaBinding(true)}
18211
+ ${sliceLine(slice)}${criteriaBinding(true)}
18190
18212
  - A judge found no passing test in this change that covers ${uncovered.join(", ")}.
18191
18213
  Write a behaviour-level test for each, naming its ID in the test or scenario
18192
18214
  name (\`[${uncovered[0]}] …\`), and fix the code if the test shows it falls short
@@ -18213,6 +18235,7 @@ const REVIEW = ".gtd/REVIEW.md";
18213
18235
  const REFACTOR = ".gtd/REFACTOR.md";
18214
18236
  const REFACTOR_NOTE = ".gtd/REFACTOR-NOTE.md";
18215
18237
  const QUALITY = ".gtd/QUALITY.md";
18238
+ const SLICES = ".gtd/SLICES.md";
18216
18239
  const planner = () => env.plannerModel ?? "";
18217
18240
  const coder = () => env.coderModel ?? "";
18218
18241
  const fixCheck = (rules) => agentWithSkills("fix", buildFixPrompt(rules), {
@@ -37724,6 +37747,7 @@ const ShippedOptions = Struct({
37724
37747
  reviewSeverityMinP: setting,
37725
37748
  refactorFlagPattern: setting,
37726
37749
  seamAllow: setting,
37750
+ sliceMaxCriteria: setting,
37727
37751
  qualityReviews: setting,
37728
37752
  refactorQualityReviews: setting,
37729
37753
  testCommand: setting,
@@ -37904,13 +37928,15 @@ const markFenced = (text) => {
37904
37928
  };
37905
37929
  });
37906
37930
  };
37931
+ /** A markdown heading: its `#` run, then its text. */
37932
+ const HEADING = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
37933
+ /** A list item, its checkbox dropped: its text. */
37934
+ const ITEM = /^ ?(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?(.*)$/;
37907
37935
  //#endregion
37908
37936
  //#region src/workflows/feature/criteria.ts
37909
37937
  /** Below this the judge's `yes` is not trusted: a wrong skip builds against criteria nobody can check. */
37910
37938
  const CLEAR_MIN_P = .8;
37911
- const HEADING = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
37912
- const ITEM = /^ ?(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?(.*)$/;
37913
- const ID = /^(AC-\d+):\s*(.*)$/;
37939
+ const ID$1 = /^(AC-\d+):\s*(.*)$/;
37914
37940
  /** The list items under the first heading `title` matches (at `level` when given), up to the next heading; wrapped lines joined, fenced lines ignored. */
37915
37941
  const listUnder = (text, title, level) => {
37916
37942
  const lines = markFenced(text);
@@ -37938,7 +37964,7 @@ const listUnder = (text, title, level) => {
37938
37964
  };
37939
37965
  /** The `- AC-<n>: …` list of `## Acceptance Criteria`, or `undefined` when the section is missing. */
37940
37966
  const criteriaOf = (text) => listUnder(text, /^acceptance criteria$/i, 2)?.map((item) => {
37941
- const id = ID.exec(item);
37967
+ const id = ID$1.exec(item);
37942
37968
  return id === null ? {
37943
37969
  id: "",
37944
37970
  text: item
@@ -37947,16 +37973,20 @@ const criteriaOf = (text) => listUnder(text, /^acceptance criteria$/i, 2)?.map((
37947
37973
  text: id[2]
37948
37974
  };
37949
37975
  });
37976
+ /** The ID'd criteria of `text`, only `slice`'s when given. */
37977
+ const criterionIds = (text, slice) => (criteriaOf(text) ?? []).map((c) => c.id).filter((id) => id !== "" && (slice?.only === void 0 || slice.only.includes(id)) && !(slice?.except ?? []).includes(id));
37978
+ /** `slice`'s IDs in `.gtd/REQUIREMENTS.md` as it is now; `undefined` for an unsliced change. */
37979
+ const sliceIds = (slice) => slice === void 0 ? void 0 : criterionIds(read(".gtd/REQUIREMENTS.md") ?? "", slice);
37950
37980
  /** Items numbered `AC-1`…, unless every one already carries its own distinct ID. */
37951
37981
  const numbered = (items) => {
37952
- const ids = items.map((item) => ID.exec(item));
37982
+ const ids = items.map((item) => ID$1.exec(item));
37953
37983
  const own = ids.every((id) => id !== null) && new Set(ids.map((id) => id[1])).size === ids.length;
37954
37984
  return items.map((item, i) => own ? {
37955
37985
  id: ids[i][1],
37956
37986
  text: ids[i][2]
37957
37987
  } : {
37958
37988
  id: `AC-${i + 1}`,
37959
- text: item.replace(ID, "$2")
37989
+ text: item.replace(ID$1, "$2")
37960
37990
  });
37961
37991
  };
37962
37992
  const STATED = /^acceptance criteria:?$/i;
@@ -38646,10 +38676,12 @@ const green = (guard) => healthy(() => agentWithSkills("fix.suite.fixing", imple
38646
38676
  const testsSince = (base) => changesSince(base).filter((c) => c.after !== void 0 && isCode$1(c.path) && isTestFile(c.path)).sort((a, b) => Number(/\bAC-\d/.test(a.after)) - Number(/\bAC-\d/.test(b.after))).map((c) => `=== ${c.path} (${c.status})\n${c.after}`).join("\n");
38647
38677
  /**
38648
38678
  * The criterion IDs of `.gtd/REQUIREMENTS.md` — read afresh, never carried
38649
- * over — no passing test since `base` covers, by the judge.
38679
+ * over, only `slice`'s when given — no passing test since `base` covers, by the judge.
38650
38680
  */
38651
- const uncoveredSince = async (base) => {
38652
- const criteria = (criteriaOf(read(".gtd/REQUIREMENTS.md") ?? "") ?? []).filter((c) => c.id !== "");
38681
+ const uncoveredSince = async (base, slice) => {
38682
+ const requirements = read(".gtd/REQUIREMENTS.md") ?? "";
38683
+ const own = criterionIds(requirements, slice);
38684
+ const criteria = (criteriaOf(requirements) ?? []).filter((c) => own.includes(c.id));
38653
38685
  if (criteria.length === 0) return {
38654
38686
  covered: [],
38655
38687
  uncovered: []
@@ -38676,14 +38708,14 @@ const record = (covered, uncovered = []) => {
38676
38708
  * sending uncovered IDs back to a covering turn; marks the IDs covered on the
38677
38709
  * landing that ends it. A human may overrule once the retries are spent.
38678
38710
  */
38679
- const requireCoverage = async (base = start()) => {
38711
+ const requireCoverage = async (base = start(), slice) => {
38680
38712
  let retries = 0;
38681
38713
  for (;;) {
38682
- const { covered, uncovered } = await uncoveredSince(base);
38714
+ const { covered, uncovered } = await uncoveredSince(base, slice);
38683
38715
  if (uncovered.length === 0) return record(covered);
38684
38716
  if (retries < COVERING_RETRIES) {
38685
38717
  retries++;
38686
- await agentWithSkills("covering", coveringPrompt(uncovered), {
38718
+ await agentWithSkills("covering", coveringPrompt(uncovered, sliceIds(slice)), {
38687
38719
  label: "Covering the acceptance criteria",
38688
38720
  model: coder(),
38689
38721
  system: builderSystem(),
@@ -38714,7 +38746,7 @@ const judgeSeam = (tip) => judgeDiffShape("judge", {
38714
38746
  allow: (vars.seamAllow ?? defaults.seamAllow).split(",").map((glob) => glob.trim()).filter((glob) => glob !== ""),
38715
38747
  exempt: tip.changes.filter((c) => c.status === "added").map((c) => c.path)
38716
38748
  });
38717
- const naivePass = (retry) => agentWithSkills("naive", naivePassPrompt(retry), {
38749
+ const naivePass = (retry, slice) => agentWithSkills("naive", naivePassPrompt(retry, sliceIds(slice)), {
38718
38750
  label: retry === void 0 ? "Writing the naive pass" : "Rewriting the naive pass",
38719
38751
  keep: true,
38720
38752
  model: coder(),
@@ -38755,7 +38787,7 @@ const addedLines = (change) => {
38755
38787
  return change.after.split("\n").filter((line) => !before.has(line)).join("\n");
38756
38788
  };
38757
38789
  const acceptanceTests = (changes) => changes.filter((c) => c.after !== void 0 && isCode$1(c.path) && isTestFile(c.path));
38758
- const idsOf = (file) => (criteriaOf(read(file) ?? "") ?? []).map((c) => c.id).filter((id) => id !== "");
38790
+ const idsOf = (file, slice) => criterionIds(read(file) ?? "", slice);
38759
38791
  const unnamed = (ids, tests) => {
38760
38792
  const lines = tests.map(addedLines);
38761
38793
  return ids.filter((id) => !lines.some((text) => named(id, text)));
@@ -38765,9 +38797,9 @@ const unnamed = (ids, tests) => {
38765
38797
  * test line of the pass names, or a test importing a file the pass added —
38766
38798
  * naive structure a reset and refactor replaces, which the tests must survive.
38767
38799
  */
38768
- const acceptanceFault = (file, tip) => {
38800
+ const acceptanceFault = (file, tip, slice) => {
38769
38801
  const tests = acceptanceTests(tip.changes);
38770
- const untested = unnamed(idsOf(file), tests);
38802
+ const untested = unnamed(idsOf(file, slice), tests);
38771
38803
  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]}] …"`;
38772
38804
  const added = tip.changes.filter((c) => c.status === "added" && isCode$1(c.path) && !isTestFile(c.path)).map((c) => c.path);
38773
38805
  const reaching = tests.filter((test) => holdsCallSites(test.path)).flatMap((test) => {
@@ -38777,8 +38809,8 @@ const acceptanceFault = (file, tip) => {
38777
38809
  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")}`;
38778
38810
  };
38779
38811
  /** What a rebuild carries over from the naive pass `tip`: the IDs its tests name, and those test files. */
38780
- const naiveAcceptance = (tip) => {
38781
- const ids = idsOf(REQUIREMENTS);
38812
+ const naiveAcceptance = (tip, slice) => {
38813
+ const ids = idsOf(REQUIREMENTS, slice);
38782
38814
  const perTest = acceptanceTests(tip.changes).map((c) => {
38783
38815
  const lines = addedLines(c);
38784
38816
  return {
@@ -38857,7 +38889,7 @@ function implement(first, tier, amend) {
38857
38889
  const since = head$1();
38858
38890
  const acceptance = options.rebuild?.acceptance;
38859
38891
  const built = options.naive === void 0 || !await applied(options.naive);
38860
- if (built) await agentWithSkills("building", implementBuildingPrompt(options.reference, options.tier, options.amend, options.afterRefactor, flagModeOn(), options.rebuild), {
38892
+ if (built) await agentWithSkills("building", implementBuildingPrompt(options.reference, options.tier, options.amend, options.afterRefactor, flagModeOn(), options.rebuild, sliceIds(options.slice)), {
38861
38893
  label: "Implementing",
38862
38894
  model: coder(),
38863
38895
  system: builderSystem(),
@@ -38866,10 +38898,18 @@ function implement(first, tier, amend) {
38866
38898
  requireCriteriaFrozen(REQUIREMENTS);
38867
38899
  if (built && acceptance !== void 0) requireAcceptance(acceptance, since);
38868
38900
  await green(built && acceptance !== void 0 ? () => requireAcceptanceKept(acceptance) : void 0);
38869
- await requireCoverage();
38870
- await run("closing", removeScript([REQUIREMENTS, REFACTOR_NOTE]), { label: "Closing out the implementation" });
38901
+ await requireCoverage(options.base, options.slice);
38902
+ await closing(options.more === true);
38871
38903
  });
38872
38904
  }
38905
+ /** The last slice's closing, for a last slice a steer left with no criterion to build. */
38906
+ const closeOut = () => scope("implement", () => closing(false));
38907
+ /** A slice with `more` after it keeps the criteria and the slicing for the next one. */
38908
+ const closing = (more) => run("closing", removeScript(more ? [REFACTOR_NOTE] : [
38909
+ REQUIREMENTS,
38910
+ REFACTOR_NOTE,
38911
+ SLICES
38912
+ ]), { label: "Closing out the implementation" });
38873
38913
  /** Resolves `true` when the human steered (edited), `false` on a clean overrule. */
38874
38914
  const steer = async (retry) => {
38875
38915
  await human("steer", {
@@ -38900,8 +38940,8 @@ const budget = () => {
38900
38940
  };
38901
38941
  };
38902
38942
  /** Sends a pass whose tests fall short or run red back (`undefined`: it goes on, an overruled one as it is). */
38903
- const vetted = async (tip, vetting) => {
38904
- const fault = vetting.waived ? void 0 : acceptanceFault(REQUIREMENTS, tip);
38943
+ const vetted = async (tip, vetting, slice) => {
38944
+ const fault = vetting.waived ? void 0 : acceptanceFault(REQUIREMENTS, tip, slice);
38905
38945
  if (fault !== void 0) {
38906
38946
  const verdict = await vetting.again({
38907
38947
  fault,
@@ -38935,16 +38975,16 @@ const ruled = async (tip, again) => {
38935
38975
  tip
38936
38976
  } : next;
38937
38977
  };
38938
- const decide = () => scope("seam", async () => {
38978
+ const decide = (slice) => scope("seam", async () => {
38939
38979
  const vetting = {
38940
38980
  again: budget(),
38941
38981
  waived: false
38942
38982
  };
38943
38983
  let retry;
38944
38984
  for (;;) {
38945
- await naivePass(retry);
38985
+ await naivePass(retry, slice);
38946
38986
  const tip = kept("naive");
38947
- const outcome = await vetted(tip, vetting) ?? (flagModeOn() ? await ruled(tip, vetting.again) : {
38987
+ const outcome = await vetted(tip, vetting, slice) ?? (flagModeOn() ? await ruled(tip, vetting.again) : {
38948
38988
  reference: tip.hash,
38949
38989
  tip
38950
38990
  });
@@ -38965,8 +39005,8 @@ const refactorNote = async (reference, strays) => {
38965
39005
  if (others.length > 0 || note.trim() === "") refuse(`gtd land: refactor-note: write ${REFACTOR_NOTE} and change nothing else${others.length > 0 ? ` — revert these:\n${others.map((path) => ` - ${path}`).join("\n")}` : ""}`);
38966
39006
  return note;
38967
39007
  };
38968
- const seamBuild = async (base) => {
38969
- const { reference, tip, shape } = await decide();
39008
+ const seamBuild = async (base, slice$1 = {}) => {
39009
+ const { reference, tip, shape } = await decide(slice$1.criteria);
38970
39010
  if (shape === void 0) return {
38971
39011
  reference,
38972
39012
  base,
@@ -38981,17 +39021,17 @@ const seamBuild = async (base) => {
38981
39021
  return {
38982
39022
  reference,
38983
39023
  base,
38984
- rebuild: { acceptance: naiveAcceptance(tip) }
39024
+ rebuild: { acceptance: naiveAcceptance(tip, slice$1.criteria) }
38985
39025
  };
38986
39026
  }
38987
39027
  const strays = refactorStrays(shape);
38988
39028
  if (strays.length === 0) return {
38989
39029
  reference,
38990
39030
  base,
38991
- rebuild: { acceptance: naiveAcceptance(tip) }
39031
+ rebuild: { acceptance: naiveAcceptance(tip, slice$1.criteria) }
38992
39032
  };
38993
39033
  const note = await scope("seam", () => refactorNote(reference, strays));
38994
- const refactored = await slice("refactor", () => reviewedRefactor(shape, {
39034
+ const refactored = await slice(slice$1.refactor ?? "refactor", () => reviewedRefactor(shape, {
38995
39035
  reference,
38996
39036
  note
38997
39037
  }));
@@ -39001,34 +39041,143 @@ const seamBuild = async (base) => {
39001
39041
  refactored,
39002
39042
  rebuild: {
39003
39043
  note,
39004
- acceptance: naiveAcceptance(tip)
39044
+ acceptance: naiveAcceptance(tip, slice$1.criteria)
39005
39045
  }
39006
39046
  };
39007
39047
  };
39008
39048
  //#endregion
39049
+ //#region src/workflows/feature/slicing.ts
39050
+ const ID = /^(AC-\d+)(?![\w-])/;
39051
+ /** The `sliceMaxCriteria` setting. Anything but a positive integer fails rather than guess a cap. */
39052
+ const sliceCap = () => {
39053
+ const value = vars.sliceMaxCriteria ?? defaults.sliceMaxCriteria;
39054
+ if (!/^[1-9]\d*$/.test(value)) refuse(`gtd: sliceMaxCriteria must be a positive integer, got ${JSON.stringify(value)}`);
39055
+ return Number(value);
39056
+ };
39057
+ /** Each `## ` heading of `text` with the list items under it; an item not led by an ID keeps an empty one. */
39058
+ const slicesOf = (text) => {
39059
+ const slices = [];
39060
+ let open = false;
39061
+ for (const { line, fenced } of markFenced(text)) {
39062
+ if (fenced) continue;
39063
+ const heading = HEADING.exec(line);
39064
+ if (heading !== null) {
39065
+ open = heading[1] === "##";
39066
+ if (open) slices.push({
39067
+ title: heading[2],
39068
+ ids: []
39069
+ });
39070
+ continue;
39071
+ }
39072
+ const item = ITEM.exec(line);
39073
+ if (item !== null && open) slices.at(-1).ids.push(ID.exec(item[1])?.[1] ?? "");
39074
+ }
39075
+ return slices;
39076
+ };
39077
+ const twice = (list) => [...new Set(list.filter((id, i) => list.indexOf(id) !== i))];
39078
+ /** Why `slices` fail to put each of `ids` in exactly one slice of at most `cap`, or `undefined`. */
39079
+ const sliceFault = (slices, ids, cap) => {
39080
+ const listed = slices.flatMap((s) => s.ids);
39081
+ if (slices.length === 0) return `no "## " slice`;
39082
+ const empty = slices.filter((s) => s.ids.length === 0).map((s) => s.title);
39083
+ if (empty.length > 0) return `a slice with no criterion: ${empty.join(", ")}`;
39084
+ if (listed.includes("")) return "a list item not led by a criterion ID";
39085
+ const over = slices.filter((s) => s.ids.length > cap).map((s) => s.title);
39086
+ if (over.length > 0) return `more than ${cap} criteria in: ${over.join(", ")}`;
39087
+ const repeated = twice(listed);
39088
+ if (repeated.length > 0) return `an ID in more than one slice: ${repeated.join(", ")}`;
39089
+ const unknown = listed.filter((id) => !ids.includes(id));
39090
+ if (unknown.length > 0) return `an ID ${REQUIREMENTS} does not list: ${unknown.join(", ")}`;
39091
+ const missing = ids.filter((id) => !listed.includes(id));
39092
+ if (missing.length > 0) return `no slice for: ${missing.join(", ")}`;
39093
+ };
39094
+ /** Refuse a turn whose `.gtd/SLICES.md` does not put each of `ids` in exactly one slice of at most `cap`. */
39095
+ const requireSlices = (ids, cap) => {
39096
+ const slices = slicesOf(read(".gtd/SLICES.md") ?? "");
39097
+ const why = sliceFault(slices, ids, cap);
39098
+ if (why !== void 0) refuse(`gtd land: slices: ${SLICES} has ${why} — list each slice as a "## <title>" heading over "- AC-<n>" items, every criterion of ${REQUIREMENTS} in exactly one slice, at most ${cap} per slice`);
39099
+ return slices;
39100
+ };
39101
+ /**
39102
+ * The slices to build, in order. Criteria within `sliceMaxCriteria` are one
39103
+ * slice and no turn; above it `design.slicing` groups them.
39104
+ */
39105
+ const slicing = async () => {
39106
+ const all = criterionIds(read(".gtd/REQUIREMENTS.md") ?? "");
39107
+ const cap = sliceCap();
39108
+ if (all.length <= cap) return [{
39109
+ title: "",
39110
+ ids: all
39111
+ }];
39112
+ return scope("design", async () => {
39113
+ await agentWithSkills("slicing", slicingPrompt(cap), {
39114
+ label: "Slicing the change",
39115
+ file: SLICES,
39116
+ model: planner(),
39117
+ system: designSystem()
39118
+ });
39119
+ return requireSlices(all, cap);
39120
+ });
39121
+ };
39122
+ /** Slice `i`'s criteria; `undefined` when `slices` is the one slice of an unsliced change. */
39123
+ const sliceCriteria = (slices, i) => slices.length === 1 ? void 0 : i < slices.length - 1 ? { only: slices[i].ids } : { except: slices.slice(0, -1).flatMap((s) => s.ids) };
39124
+ //#endregion
39009
39125
  //#region src/workflows/feature/feature.ts
39010
39126
  /** Revert the sketch that started the process out of the working tree; its intent survives in history. */
39011
39127
  const unwind = () => {
39012
39128
  const commit = head$1();
39013
39129
  return run("unwind", revertScript(commit, FEEDBACK, unwindFailure(commit)), { label: "Unwinding your input" });
39014
39130
  };
39015
- /** A naive pass (judged only with `flagMode` on), then implement and the quality lap; design must have run since `firstBase`. */
39131
+ /**
39132
+ * The slices `design.slicing` grouped the criteria into, each built in turn
39133
+ * from where the previous one landed; design must have run since `firstBase`.
39134
+ */
39016
39135
  const buildFeature = async (firstBase) => {
39017
39136
  const tier = riskTierOn() ? recordRisk() : void 0;
39018
- const seam = await seamBuild(firstBase);
39019
- await slice("feature", async () => {
39137
+ const slices = await slicing();
39138
+ const one = slices.length === 1;
39139
+ for (const i of slices.keys()) {
39140
+ const more = i < slices.length - 1;
39141
+ const criteria = sliceCriteria(slices, i);
39142
+ if (sliceIds(criteria)?.length === 0) {
39143
+ if (!more) await closeOut();
39144
+ continue;
39145
+ }
39146
+ const n = one ? "" : `-${i + 1}`;
39147
+ await buildSlice(i === 0 ? firstBase : head$1(), {
39148
+ tier,
39149
+ criteria,
39150
+ refactor: `refactor${n}`,
39151
+ feature: `feature${n}`,
39152
+ more
39153
+ });
39154
+ }
39155
+ };
39156
+ /** A naive pass (judged only with `flagMode` on), then implement and the quality lap. */
39157
+ const buildSlice = async (sliceBase, build) => {
39158
+ const seam = await seamBuild(sliceBase, {
39159
+ criteria: build.criteria,
39160
+ refactor: build.refactor
39161
+ });
39162
+ await slice(build.feature, async () => {
39020
39163
  await implement({
39021
39164
  reference: seam.reference,
39022
- tier,
39165
+ tier: build.tier,
39023
39166
  afterRefactor: seam.refactored,
39024
39167
  naive: seam.land === true ? seam.reference : void 0,
39025
- rebuild: seam.rebuild
39168
+ rebuild: seam.rebuild,
39169
+ slice: build.criteria,
39170
+ base: build.criteria === void 0 ? void 0 : sliceBase,
39171
+ more: build.more
39026
39172
  });
39027
39173
  const carried = seam.rebuild?.acceptance?.tests ?? [];
39028
- const base = seam.refactored === true && !changesSince(seam.base).some((c) => isCode$1(c.path) && !carried.includes(c.path)) ? firstBase : seam.base;
39174
+ const base = seam.refactored === true && !changesSince(seam.base).some((c) => isCode$1(c.path) && !carried.includes(c.path)) ? sliceBase : seam.base;
39175
+ const guard = build.more ? () => requireCriteriaFrozen(REQUIREMENTS) : void 0;
39029
39176
  await tail(settleQuality, {
39030
39177
  green: true,
39031
- lensBase: base
39178
+ lensBase: base,
39179
+ guard,
39180
+ guardRests: false
39032
39181
  });
39033
39182
  });
39034
39183
  };
@@ -39074,6 +39223,7 @@ const ordinaryStart = async () => {
39074
39223
  });
39075
39224
  refusePendingHandOff();
39076
39225
  flagModeOn();
39226
+ sliceCap();
39077
39227
  const stated = statedCriteria(changes(), head$1());
39078
39228
  await unwind();
39079
39229
  if (changes(".gtd/FEEDBACK.md").some((c) => c.status !== "deleted")) await human("unwind-failed", {
@@ -39268,4 +39418,4 @@ var workflows_default = feature;
39268
39418
  */
39269
39419
  const doors = {};
39270
39420
  //#endregion
39271
- export { COMMENT, ESCALATION, FEEDBACK, FIX_CAP, FlagOptions, QUALITY, REFACTOR, REFACTOR_NOTE, 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, naiveAcceptance, naivePass, ordinaryStart, planAndBuild, planner, qualityLap, reUnwind, refactorCollecting, refactorFirst, refactorNote, refactorStrays, requireAcceptance, requireAcceptanceKept, 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 };
39421
+ export { COMMENT, ESCALATION, FEEDBACK, FIX_CAP, FlagOptions, QUALITY, REFACTOR, REFACTOR_NOTE, REFACTOR_PACKAGE, REQUIREMENTS, REVIEW, REVIEW_ORIG, SLICES, ShippedOptions, acceptanceFault, access, afterTail, agentWithSkills, answerProductQuestions, answerReviewQuestions, awaitReview, base, baseline, buildFeature, buildTail, builtInLenses, cleanup, cleanupFlow, closeOut, closeReview, coder, collecting, criteriaOf, criterionIds, 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, naiveAcceptance, naivePass, ordinaryStart, planAndBuild, planner, qualityLap, reUnwind, refactorCollecting, refactorFirst, refactorNote, refactorStrays, requireAcceptance, requireAcceptanceKept, requireCriteria, requireCriteriaFrozen, requireNoNewFlags, requireNoTestFiles, requireSlices, requireTestCallSitesOnly, requireWithin, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewedRefactor, reviewing, rollout, rolloutFlow, seamBuild, skills, skipDesign, sliceCap, sliceCriteria, sliceIds, slicesOf, slicing, statedCriteria, steering, summary, tail, threads, triage, unwind };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "37.0.0",
3
+ "version": "38.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": {
@@ -26,6 +26,8 @@ export class TestkitWorld extends QuickPickleWorld {
26
26
  /** Per step, what its scripted actor does, in the order the scenario declared it. */
27
27
  actions = new Map<string, Actor[]>()
28
28
  verdicts = new Map<string, string>()
29
+ /** Per judge step, a verdict for each text its questions may contain; the first match answers. */
30
+ conditionalVerdicts = new Map<string, [text: string, verdict: string][]>()
29
31
  private created: TestRepo | undefined = undefined
30
32
 
31
33
  // The repository is created on first use and keeps the config and
@@ -79,6 +81,19 @@ export class TestkitWorld extends QuickPickleWorld {
79
81
  }
80
82
  actors[step] = () => verdict
81
83
  }
84
+ for (const [step, choices] of this.conditionalVerdicts) {
85
+ if (this.actions.has(step) || this.verdicts.has(step)) {
86
+ throw new Error(`step "${step}" has a conditional verdict and another script`)
87
+ }
88
+ // Matched against the pending questions, which `gtd judge` prints and the beat does not.
89
+ actors[step] = async (_, repo) => {
90
+ const { stdout } = await repo.gtd("judge")
91
+ const hit = choices.find(([text]) => stdout.includes(text))
92
+ if (hit === undefined)
93
+ throw new Error(`judge step "${step}" matched no verdict:\n${stdout}`)
94
+ return hit[1]
95
+ }
96
+ }
82
97
  await this.repo().drive(actors)
83
98
  }
84
99
  }
@@ -137,6 +152,16 @@ Given(
137
152
  },
138
153
  )
139
154
 
155
+ // Lets one step write differently per visit, told apart by what it is prompted with.
156
+ Given(
157
+ "step {string}, when prompted with {string}, writes {string} with:",
158
+ (world: TestkitWorld, step: string, text: string, path: string, content: string) => {
159
+ world.act(step, (beat, repo) => {
160
+ if (beat.content.includes(text)) repo.write(path, withNewline(content))
161
+ })
162
+ },
163
+ )
164
+
140
165
  // A standing assertion like any queued action: it applies on every later visit
141
166
  // to the step until `from now on` clears the list.
142
167
  Given(
@@ -169,6 +194,16 @@ Given("judge step {string} answers:", (world: TestkitWorld, step: string, verdic
169
194
  world.verdicts.set(step, String(verdict))
170
195
  })
171
196
 
197
+ Given(
198
+ "judge step {string}, when its questions contain {string}, answers:",
199
+ (world: TestkitWorld, step: string, text: string, verdict: string) => {
200
+ world.conditionalVerdicts.set(step, [
201
+ ...(world.conditionalVerdicts.get(step) ?? []),
202
+ [text, String(verdict)],
203
+ ])
204
+ },
205
+ )
206
+
172
207
  When("gtd starts workflow {string}", async (world: TestkitWorld, workflow: string) => {
173
208
  await world.repo().start(workflow)
174
209
  })
@@ -11,7 +11,13 @@ import {
11
11
  import { healthy } from "../shared/health.js"
12
12
  import { coder, FEEDBACK, REQUIREMENTS } from "../shared/steps.js"
13
13
  import * as t from "../shared/text.js"
14
- import { criteriaOf, requireCriteriaFrozen } from "./criteria.js"
14
+ import {
15
+ criteriaOf,
16
+ criterionIds,
17
+ requireCriteriaFrozen,
18
+ sliceIds,
19
+ type CriteriaSlice,
20
+ } from "./criteria.js"
15
21
  import { isTestFile } from "./refactor.js"
16
22
  import { isCode } from "./review.js"
17
23
 
@@ -51,13 +57,15 @@ const testsSince = (base: string): string =>
51
57
 
52
58
  /**
53
59
  * The criterion IDs of `.gtd/REQUIREMENTS.md` — read afresh, never carried
54
- * over — no passing test since `base` covers, by the judge.
60
+ * over, only `slice`'s when given — no passing test since `base` covers, by the judge.
55
61
  */
56
62
  const uncoveredSince = async (
57
63
  base: string,
64
+ slice: CriteriaSlice | undefined,
58
65
  ): Promise<{ readonly covered: string[]; readonly uncovered: string[] }> => {
59
66
  const requirements = read(REQUIREMENTS) ?? ""
60
- const criteria = (criteriaOf(requirements) ?? []).filter((c) => c.id !== "")
67
+ const own = criterionIds(requirements, slice)
68
+ const criteria = (criteriaOf(requirements) ?? []).filter((c) => own.includes(c.id))
61
69
  if (criteria.length === 0) return { covered: [], uncovered: [] }
62
70
  const { answers } = await judge("coverage", {
63
71
  questions: criteria.map(t.coverageQuestion),
@@ -81,14 +89,14 @@ const record = (covered: readonly string[], uncovered: readonly string[] = []):
81
89
  * sending uncovered IDs back to a covering turn; marks the IDs covered on the
82
90
  * landing that ends it. A human may overrule once the retries are spent.
83
91
  */
84
- export const requireCoverage = async (base = start()): Promise<void> => {
92
+ export const requireCoverage = async (base = start(), slice?: CriteriaSlice): Promise<void> => {
85
93
  let retries = 0
86
94
  for (;;) {
87
- const { covered, uncovered } = await uncoveredSince(base)
95
+ const { covered, uncovered } = await uncoveredSince(base, slice)
88
96
  if (uncovered.length === 0) return record(covered)
89
97
  if (retries < COVERING_RETRIES) {
90
98
  retries++
91
- await t.agentWithSkills("covering", t.coveringPrompt(uncovered), {
99
+ await t.agentWithSkills("covering", t.coveringPrompt(uncovered, sliceIds(slice)), {
92
100
  label: "Covering the acceptance criteria",
93
101
  model: coder(),
94
102
  system: t.builderSystem(),
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
  import type { Change } from "../../flows/index.js"
3
- import { criteriaOf, statedCriteria } from "./criteria.js"
3
+ import { criteriaOf, criterionIds, statedCriteria } from "./criteria.js"
4
4
 
5
5
  const added = (path: string, after: string): Change => ({
6
6
  path,
@@ -104,3 +104,19 @@ describe("statedCriteria", () => {
104
104
  ).toBeUndefined()
105
105
  })
106
106
  })
107
+
108
+ describe("criterionIds", () => {
109
+ const requirements = "## Acceptance Criteria\n\n- AC-1: a\n- AC-2: b\n- AC-4: added\n- unnamed\n"
110
+
111
+ it("lists every ID'd criterion of an unsliced change", () => {
112
+ expect(criterionIds(requirements)).toEqual(["AC-1", "AC-2", "AC-4"])
113
+ })
114
+
115
+ it("keeps `only` to the IDs the file still lists", () => {
116
+ expect(criterionIds(requirements, { only: ["AC-1", "AC-3"] })).toEqual(["AC-1"])
117
+ })
118
+
119
+ it("gives `except` every other ID, one added since included", () => {
120
+ expect(criterionIds(requirements, { except: ["AC-1", "AC-3"] })).toEqual(["AC-2", "AC-4"])
121
+ })
122
+ })