@pmelab/gtd 29.1.0 → 29.2.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.
@@ -17475,7 +17475,12 @@ ${stateFileRules}
17475
17475
  - Every package with tests has a \`## Tests\` section, one line per
17476
17476
  declared test, copied from the architecture entries:
17477
17477
  \`- unit: \\\`<path>\\\`\` or \`- e2e: \\\`<path>\\\`\`. Chore packages have
17478
- none
17478
+ none. List every test the package writes or changes; also list an
17479
+ existing test a criterion relies on, marked \`(unchanged)\` —
17480
+ \`- unit: \\\`<path>\\\` (unchanged)\` or
17481
+ \`- e2e: \\\`<path>\\\` (unchanged)\`; list no other untouched test;
17482
+ never mark a file the package also writes or changes — list it once,
17483
+ unmarked; only decompose places the marker
17479
17484
  - Leave everything uncommitted and finish
17480
17485
  `;
17481
17486
  const architectureGateAnswerMessage = () => `This gate stops on every process, even with no open question, so
@@ -17508,15 +17513,26 @@ const packagesItemBuildingPrompt = (pkg) => `${stateFileRules}
17508
17513
  never delete the package file (the declared-tests guard reads it
17509
17514
  after you)
17510
17515
  - The package to implement is \`${pkg}\`
17511
- - First check its acceptance criteria against the current tree —
17512
- an earlier fix turn may already satisfy them. If **every**
17513
- criterion is met, implement nothing: write \`.gtd/SATISFIED.md\`
17514
- with each criterion's concrete evidence (commit, file, or
17515
- symbol), change nothing else, and finish. Otherwise implement
17516
- normally and skip that file
17516
+ - First check the acceptance criteria against the current tree —
17517
+ an earlier fix turn may already meet them
17518
+ - **Every criterion already met in the tree**: write
17519
+ \`.gtd/SATISFIED.md\` with evidence per criterion — commit, file,
17520
+ symbol, issue URL, or command output — and change nothing else
17521
+ - **No \`## Tests\` and work outside the tree**: first check it was
17522
+ not already done by an earlier attempt (e.g. an existing issue
17523
+ with the same title via \`gh\`) and reuse that as evidence, or do
17524
+ the work; then write \`.gtd/SATISFIED.md\` with that evidence and
17525
+ change nothing else in the tree
17526
+ - **Any other package**: implement normally, write no
17527
+ \`.gtd/SATISFIED.md\`
17517
17528
  - Write the tests the package declares under \`## Tests\` first,
17518
17529
  then the code that makes them pass; the turn is refused unless
17519
- every declared test is in your diff
17530
+ every declared test is in your diff — except a line marked
17531
+ \`(unchanged)\`, which must exist before the turn and stay
17532
+ untouched — creating, editing or deleting it refuses the turn. If
17533
+ it does not exist yet, or the marker is wrong because the package
17534
+ must change that test, stop without creating it and leave it to
17535
+ a person
17520
17536
  - A declared e2e test is a scenario in the repository's own e2e
17521
17537
  framework for the behaviour its \`## E2E Scenarios\` entry names.
17522
17538
  Word it to fit the existing steps — reuse and generalise them
@@ -17544,7 +17560,7 @@ ${strays.map((path) => ` - \`${displayPath(path)}\``).join("\n")}
17544
17560
  - Introduce no new flag read and add no second read of an existing one:
17545
17561
  every match of \`${pattern}\` may move, never increase in number`;
17546
17562
  const withRules = (rules) => rules === void 0 ? "" : `${rules}\n`;
17547
- const refactorBuildingPrompt = (pkg, strays, pattern, reference, feedback = false) => `${stateFileRules}
17563
+ const refactorBuildingPrompt = ({ pkg, strays, pattern, reference, feedback = false }) => `${stateFileRules}
17548
17564
  - The package to implement is \`${pkg}\`; never delete it
17549
17565
  ${feedback ? `- The package carries a \`## Review feedback\` section: a human's review of the
17550
17566
  last refactor. Act on it, within the rules below
@@ -37955,34 +37971,86 @@ const deferNotes = (notes, round) => run("review.defer", deferNotesScript(notes,
37955
37971
  });
37956
37972
  //#endregion
37957
37973
  //#region src/workflows/feature/packages.ts
37958
- const TEST_LINE = /^\s*-\s+(unit|e2e):\s+`([^`]+)`/;
37959
- /** The `## Tests` entries of a package file; malformed lines are ignored. */
37974
+ const TEST_LINE = /^-\s+(unit|e2e):\s+`([^`]+)`(.*)$/;
37975
+ const MARKER = /^[ \t]+\(unchanged\)(?=\s|$)/i;
37976
+ const ITEM = /^\s*-\s/;
37977
+ /** The `## Tests` entries of a package file; malformed entries are ignored. */
37960
37978
  const declaredTests = (packageText) => {
37961
37979
  const found = [];
37962
37980
  let inTests = false;
37963
- for (const line of packageText.split("\n")) if (/^##\s/.test(line)) inTests = /^##\s+Tests\s*$/.test(line);
37964
- else if (inTests) {
37965
- const match = TEST_LINE.exec(line);
37981
+ let entry;
37982
+ const flush = () => {
37983
+ const match = entry === void 0 ? null : TEST_LINE.exec(entry);
37966
37984
  if (match) found.push({
37967
37985
  level: match[1],
37968
- path: match[2]
37986
+ path: match[2],
37987
+ unchanged: MARKER.test(match[3])
37969
37988
  });
37970
- }
37989
+ entry = void 0;
37990
+ };
37991
+ for (const line of packageText.replace(/\r\n?/g, "\n").split("\n")) if (/^##\s/.test(line)) {
37992
+ flush();
37993
+ inTests = /^##\s+Tests\s*$/.test(line);
37994
+ } else if (!inTests) continue;
37995
+ else if (ITEM.test(line)) {
37996
+ flush();
37997
+ entry = line.trim();
37998
+ } else if (entry !== void 0 && /^\s/.test(line) && line.trim() !== "") entry = `${entry} ${line.trim()}`;
37999
+ else flush();
38000
+ flush();
37971
38001
  return found;
37972
38002
  };
38003
+ const diffBucket = (test, status, turn, satisfied) => {
38004
+ if (test.unchanged) {
38005
+ if (turn !== "build") return void 0;
38006
+ if (status === "modified") return "modifiedMarked";
38007
+ return status === "added" ? "addedMarked" : void 0;
38008
+ }
38009
+ return !satisfied && (status === void 0 || status === "deleted") ? "notInDiff" : void 0;
38010
+ };
38011
+ const treeBucket = (test, status) => {
38012
+ if (read(test.path) !== void 0) return void 0;
38013
+ return test.unchanged && status === "deleted" ? "deletedMarked" : "notInTree";
38014
+ };
38015
+ const unmetBucket = (test, change, turn, satisfied) => diffBucket(test, change?.status, turn, satisfied) ?? treeBucket(test, change?.status);
37973
38016
  /**
37974
38017
  * Refuse a build or fix turn unless every test `pkg` declares exists and is in the
37975
38018
  * diff since `since`. The package text is read as of `since`, so a builder
37976
- * editing its own `## Tests` cannot dodge the guard; with `.gtd/SATISFIED.md`
37977
- * written, existing in the tree suffices.
38019
+ * editing its own `## Tests` (or adding `(unchanged)`) cannot dodge the guard;
38020
+ * with `.gtd/SATISFIED.md` written, or the line marked, existing suffices. The
38021
+ * build turn must neither modify nor add a marked test; a fix turn may edit it.
37978
38022
  */
37979
- const requireDeclaredTests = (pkg, since) => {
38023
+ const requireDeclaredTests = (pkg, since, turn = "fix") => {
37980
38024
  const range = changesSince(since);
37981
38025
  const text = range.get(pkg)?.before ?? read(pkg) ?? "";
37982
38026
  const satisfied = read(".gtd/SATISFIED.md") !== void 0;
37983
- const missing = declaredTests(text).map((test) => test.path).filter((path) => satisfied ? read(path) === void 0 : range.get(path) === void 0 || range.get(path)?.status === "deleted" || read(path) === void 0);
37984
- if (missing.length === 0) return;
37985
- refuse(`gtd land: declared-tests: ${pkg} declares tests that are ${satisfied ? "missing from the tree" : "not in the package's diff"} — write them first:\n${missing.map((path) => ` - ${path}`).join("\n")}`);
38027
+ const buckets = {
38028
+ notInDiff: [],
38029
+ modifiedMarked: [],
38030
+ addedMarked: [],
38031
+ deletedMarked: [],
38032
+ notInTree: []
38033
+ };
38034
+ const entries = declaredTests(text);
38035
+ const seen = /* @__PURE__ */ new Set();
38036
+ for (const test of entries) {
38037
+ if (test.unchanged && entries.some((e) => !e.unchanged && e.path === test.path)) continue;
38038
+ const bucket = unmetBucket(test, range.get(test.path), turn, satisfied);
38039
+ if (bucket && !seen.has(test.path)) {
38040
+ seen.add(test.path);
38041
+ buckets[bucket].push(test.path);
38042
+ }
38043
+ }
38044
+ const { notInDiff, modifiedMarked, addedMarked, deletedMarked, notInTree } = buckets;
38045
+ const reasons = [
38046
+ ["not in the package's diff — write them", notInDiff],
38047
+ ["marked (unchanged) but modified — revert them", modifiedMarked],
38048
+ ["marked (unchanged) but added by this turn — the marker promises an existing test; stop and leave it to a person", addedMarked],
38049
+ ["marked (unchanged) but deleted — restore them", deletedMarked],
38050
+ ["missing from the tree", notInTree]
38051
+ ].filter(([, paths]) => paths.length > 0);
38052
+ if (reasons.length === 0) return;
38053
+ refuse(`gtd land: declared-tests: ${pkg} declares tests this turn left unmet:\n${reasons.map(([reason, paths]) => `${reason}:\n${paths.map((path) => ` - ${path}`).join("\n")}`).join("\n")}`);
37986
38054
  };
37987
38055
  /**
37988
38056
  * Build `pkg`, check its declared tests, keep the full suite green, and close
@@ -37990,12 +38058,12 @@ const requireDeclaredTests = (pkg, since) => {
37990
38058
  */
37991
38059
  const packageItem = async (pkg) => {
37992
38060
  const from = head$1();
37993
- const declared = (turn) => async () => {
38061
+ const declared = (turn, kind) => async () => {
37994
38062
  await turn();
37995
- requireDeclaredTests(pkg, from);
38063
+ requireDeclaredTests(pkg, from, kind);
37996
38064
  };
37997
- await declared(() => build(pkg))();
37998
- await healthy(declared(fixSuite));
38065
+ await declared(() => build(pkg), "build")();
38066
+ await healthy(declared(fixSuite, "fix"));
37999
38067
  await run("closing", removeScript([
38000
38068
  pkg,
38001
38069
  ".gtd/SATISFIED.md",
@@ -38230,8 +38298,16 @@ const review$1 = (base, escalations = { rounds: 0 }, options = {}) => {
38230
38298
  }
38231
38299
  });
38232
38300
  };
38233
- /** The build tail: fix (when entered red), keep green unless `green`, the quality lap, then human review since `base`. */
38234
- const buildTail = (fixFirst, base, green = false, options = {}) => tail(fixFirst, (escalations) => review$1(base, escalations, options), green, options.guard, options.lenses, options.guardRests, options.lensBase, options.rules);
38301
+ function buildTail(first, second, third = false, fourth = {}) {
38302
+ if (typeof first !== "string" && typeof first !== "boolean" && typeof second !== "string") return Promise.reject(/* @__PURE__ */ new Error("buildTail takes buildTail(base, options?) or the deprecated buildTail(fixFirst, base, green?, options?); this call fits neither"));
38303
+ const [base, options] = typeof first === "string" ? [first, second ?? {}] : [second, {
38304
+ ...fourth,
38305
+ fixFirst: first,
38306
+ green: third
38307
+ }];
38308
+ const { fixFirst, green, ...reviewOptions } = options;
38309
+ return tail(fixFirst ?? false, (escalations) => review$1(base, escalations, reviewOptions), green ?? false, options.guard, options.lenses, options.guardRests, options.lensBase, options.rules);
38310
+ }
38235
38311
  /**
38236
38312
  * Undo the human's review-round code edits. A path changed since the human
38237
38313
  * left it is not overwritten; requireRevert names it instead.
@@ -38372,7 +38448,13 @@ const refactorLap = async (p, guard, feedback) => {
38372
38448
  label: "Deriving the refactor package",
38373
38449
  file: REFACTOR_PACKAGE
38374
38450
  });
38375
- await agentWithSkills("building", refactorBuildingPrompt(REFACTOR_PACKAGE, strays, pattern, reference, feedback !== void 0), {
38451
+ await agentWithSkills("building", refactorBuildingPrompt({
38452
+ pkg: REFACTOR_PACKAGE,
38453
+ strays,
38454
+ pattern,
38455
+ reference,
38456
+ feedback: feedback !== void 0
38457
+ }), {
38376
38458
  label: "Refactoring",
38377
38459
  model: coder(),
38378
38460
  system: builderSystem()
@@ -38428,7 +38510,8 @@ const reviewedRefactor = async (shape, options = {}) => {
38428
38510
  let collected;
38429
38511
  for (;;) {
38430
38512
  await refactorLap(p, guard, collected);
38431
- const outcome = await buildTail(false, entry, true, {
38513
+ const outcome = await buildTail(entry, {
38514
+ green: true,
38432
38515
  guard,
38433
38516
  guardRests: false,
38434
38517
  lenses: lensesOf(vars.refactorQualityReviews ?? ""),
@@ -38461,20 +38544,27 @@ const naivePass = (rejection) => agentWithSkills("naive", naivePassPrompt(reject
38461
38544
  model: coder(),
38462
38545
  system: builderSystem()
38463
38546
  });
38464
- const implement = (reference, tier, amend) => scope("implement", async () => {
38465
- await agentWithSkills("building", implementBuildingPrompt(reference, tier, amend), {
38466
- label: "Implementing",
38467
- model: coder(),
38468
- system: builderSystem()
38547
+ function implement(first, tier, amend) {
38548
+ const options = typeof first === "object" && first !== null ? first : {
38549
+ reference: first,
38550
+ tier,
38551
+ amend
38552
+ };
38553
+ return scope("implement", async () => {
38554
+ await agentWithSkills("building", implementBuildingPrompt(options.reference, options.tier, options.amend), {
38555
+ label: "Implementing",
38556
+ model: coder(),
38557
+ system: builderSystem()
38558
+ });
38559
+ await healthy(() => agentWithSkills("fix.suite.fixing", implementFixPrompt(), {
38560
+ label: "Fixing the check",
38561
+ file: FEEDBACK,
38562
+ model: coder(),
38563
+ system: builderSystem()
38564
+ }));
38565
+ await run("closing", removeScript([REQUIREMENTS]), { label: "Closing out the implementation" });
38469
38566
  });
38470
- await healthy(() => agentWithSkills("fix.suite.fixing", implementFixPrompt(), {
38471
- label: "Fixing the check",
38472
- file: FEEDBACK,
38473
- model: coder(),
38474
- system: builderSystem()
38475
- }));
38476
- await run("closing", removeScript([REQUIREMENTS]), { label: "Closing out the implementation" });
38477
- });
38567
+ }
38478
38568
  const decide = () => scope("seam", async () => {
38479
38569
  let rejection;
38480
38570
  let retries = 0;
@@ -38549,7 +38639,10 @@ const planAndBuild = async (firstBase, firstLap = "first") => {
38549
38639
  await design(base);
38550
38640
  await architecture();
38551
38641
  await packages();
38552
- const outcome = await buildTail(false, base, true, lap === "first" ? {} : { lap });
38642
+ const outcome = await buildTail(base, {
38643
+ green: true,
38644
+ ...lap === "first" ? {} : { lap }
38645
+ });
38553
38646
  if (outcome.verdict === "signoff") return;
38554
38647
  await reUnwind(outcome);
38555
38648
  base = outcome.base;
@@ -38561,8 +38654,14 @@ const naivePlanAndBuild = async (firstBase) => {
38561
38654
  const tier = riskTierOn() ? recordRisk() : void 0;
38562
38655
  const seam = await seamBuild(firstBase);
38563
38656
  await slice("feature", async () => {
38564
- await implement(seam.reference, tier);
38565
- await featureFeedback(await buildTail(false, seam.base, true, { lensBase: seam.base }), seam.base);
38657
+ await implement({
38658
+ reference: seam.reference,
38659
+ tier
38660
+ });
38661
+ await featureFeedback(await buildTail(seam.base, {
38662
+ green: true,
38663
+ lensBase: seam.base
38664
+ }), seam.base);
38566
38665
  });
38567
38666
  };
38568
38667
  const featureFeedback = async (first, lensBase) => {
@@ -38570,8 +38669,12 @@ const featureFeedback = async (first, lensBase) => {
38570
38669
  while (outcome.verdict === "feedback") {
38571
38670
  await reUnwind(outcome);
38572
38671
  await design(outcome.base);
38573
- await implement(void 0, riskTierOn() ? recordRisk() : void 0, true);
38574
- outcome = await buildTail(false, outcome.base, true, {
38672
+ await implement({
38673
+ tier: riskTierOn() ? recordRisk() : void 0,
38674
+ amend: true
38675
+ });
38676
+ outcome = await buildTail(outcome.base, {
38677
+ green: true,
38575
38678
  lap: "feedback",
38576
38679
  ...lensBase === void 0 ? {} : { lensBase }
38577
38680
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "29.1.0",
3
+ "version": "29.2.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": {
@@ -56,7 +56,7 @@ export {
56
56
  export default feature
57
57
 
58
58
  export async function hotfix() {
59
- return afterTail(await buildTail(true, start()))
59
+ return afterTail(await buildTail(start(), { fixFirst: true }))
60
60
  }
61
61
 
62
62
  export const doors = { hotfix: { workflow: "hotfix" } }
@@ -227,9 +227,11 @@ pass (which writes `.gtd/packages/`), then builds the packages. Add a human
227
227
  sign-off between the two:
228
228
 
229
229
  ```ts
230
- const planAndBuild = async (): Promise<void> => {
230
+ const planAndBuild = async (firstBase: string): Promise<void> => {
231
+ let base = firstBase
232
+ let lap: "first" | "feedback" = "first"
231
233
  for (;;) {
232
- await design()
234
+ await design(base)
233
235
  await architecture()
234
236
  await human("approve-plan", {
235
237
  message:
@@ -238,8 +240,14 @@ const planAndBuild = async (): Promise<void> => {
238
240
  acceptClean: true,
239
241
  })
240
242
  await packages()
241
- if ((await buildTail(false)) === "signoff") return
242
- await reUnwind()
243
+ const outcome = await buildTail(base, {
244
+ green: true,
245
+ ...(lap === "first" ? {} : { lap }),
246
+ })
247
+ if (outcome.verdict === "signoff") return
248
+ await reUnwind(outcome)
249
+ base = outcome.base
250
+ lap = "feedback"
243
251
  }
244
252
  }
245
253
  ```
@@ -131,7 +131,7 @@ describe("afterTail feedback lap", () => {
131
131
  `export { feature as default, defaults, envDefaults, summary, base, steering, skills, access } from "@pmelab/gtd/workflows"`,
132
132
  `export async function tail() {`,
133
133
  ` await gate("review-gate", "The baseline is red.")`,
134
- ` return afterTail(await buildTail(false, start()))`,
134
+ ` return afterTail(await buildTail(start()))`,
135
135
  `}`,
136
136
  ].join("\n")
137
137
 
@@ -39,7 +39,7 @@ export const planAndBuild = async (
39
39
  await design(base)
40
40
  await architecture()
41
41
  await packages()
42
- const outcome = await buildTail(false, base, true, lap === "first" ? {} : { lap })
42
+ const outcome = await buildTail(base, { green: true, ...(lap === "first" ? {} : { lap }) })
43
43
  if (outcome.verdict === "signoff") return
44
44
  await reUnwind(outcome)
45
45
  base = outcome.base
@@ -54,9 +54,9 @@ const naivePlanAndBuild = async (firstBase: string): Promise<void> => {
54
54
  const tier = riskTierOn() ? recordRisk() : undefined
55
55
  const seam = await seamBuild(firstBase)
56
56
  await slice("feature", async () => {
57
- await implement(seam.reference, tier)
57
+ await implement({ reference: seam.reference, tier })
58
58
  await featureFeedback(
59
- await buildTail(false, seam.base, true, { lensBase: seam.base }),
59
+ await buildTail(seam.base, { green: true, lensBase: seam.base }),
60
60
  seam.base,
61
61
  )
62
62
  })
@@ -68,8 +68,9 @@ const featureFeedback = async (first: ReviewOutcome, lensBase?: string): Promise
68
68
  await reUnwind(outcome)
69
69
  await design(outcome.base)
70
70
  const tier = riskTierOn() ? recordRisk() : undefined
71
- await implement(undefined, tier, true)
72
- outcome = await buildTail(false, outcome.base, true, {
71
+ await implement({ tier, amend: true })
72
+ outcome = await buildTail(outcome.base, {
73
+ green: true,
73
74
  lap: "feedback",
74
75
  ...(lensBase === undefined ? {} : { lensBase }),
75
76
  })
@@ -5,7 +5,7 @@ export * from "./planning.js"
5
5
  export * from "./packages.js"
6
6
  export * from "./refactor.js"
7
7
  export * from "./seam.js"
8
- export { buildTail, type ReviewOutcome } from "./review.js"
8
+ export { buildTail, type BuildTailOptions, type ReviewOutcome } from "./review.js"
9
9
  export * from "./feature.js"
10
10
  export * from "../shared/reserved.js"
11
11