@pmelab/gtd 36.0.0 → 37.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 (50) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +21 -9
  3. package/claude/hooks/context.ts +66 -0
  4. package/claude/hooks/drive.ts +3 -0
  5. package/claude/hooks/register.tsx +69 -6
  6. package/dist/chunks/{reserved-BAv71I4i.mjs → reserved-zAfoJtR7.mjs} +53 -16
  7. package/dist/chunks/{tail-kar1zhOb.mjs → tail-BRC4DGdV.mjs} +1 -1
  8. package/dist/chunks/{wire-D3Rc5r22.mjs → wire-CSsa3Wxd.mjs} +117 -2
  9. package/dist/gtd.bundle.mjs +498 -56
  10. package/dist/testkit.mjs +115 -3
  11. package/dist/wire.mjs +2 -2
  12. package/dist/workflows/cleanup.mjs +2 -2
  13. package/dist/workflows/feature.mjs +126 -33
  14. package/dist/workflows/fix.mjs +2 -2
  15. package/dist/workflows/review.mjs +2 -2
  16. package/dist/workflows/rollout.mjs +1 -1
  17. package/dist/workflows.mjs +174 -44
  18. package/package.json +1 -1
  19. package/src/cli/Cli.test.ts +10 -3
  20. package/src/cli/Cli.ts +76 -1
  21. package/src/cli/Program.test.ts +1 -0
  22. package/src/cli/Program.ts +28 -0
  23. package/src/cli/ToolCommands.ts +3 -0
  24. package/src/edge/Edge.ts +11 -0
  25. package/src/flows/runtime.ts +11 -0
  26. package/src/replay/Replay.ts +51 -2
  27. package/src/testkit/Layers.ts +1 -0
  28. package/src/wire/BeatDocument.ts +12 -2
  29. package/src/wire/BeatStatus.ts +3 -0
  30. package/src/wire/ContextLimit.fixture.ts +30 -0
  31. package/src/wire/ContextLimit.test.ts +96 -0
  32. package/src/wire/ContextLimit.ts +169 -0
  33. package/src/wire/index.ts +1 -0
  34. package/src/wire/types.ts +7 -0
  35. package/src/workflow/Workflow.ts +9 -1
  36. package/src/workflows/feature/context-limit.feature +37 -0
  37. package/src/workflows/feature/coverage.ts +8 -2
  38. package/src/workflows/feature/feature.ts +6 -2
  39. package/src/workflows/feature/refactor-absorbs.feature +12 -5
  40. package/src/workflows/feature/refactor-first.feature +11 -2
  41. package/src/workflows/feature/refactor-lens-models.feature +4 -0
  42. package/src/workflows/feature/refactor-rebuild.feature +269 -0
  43. package/src/workflows/feature/refactor-review.feature +45 -1
  44. package/src/workflows/feature/refactor.ts +9 -3
  45. package/src/workflows/feature/seam.test.ts +108 -3
  46. package/src/workflows/feature/seam.ts +120 -19
  47. package/src/workflows/shared/options.test.ts +11 -2
  48. package/src/workflows/shared/options.ts +18 -4
  49. package/src/workflows/shared/steps.ts +1 -0
  50. package/src/workflows/shared/text.ts +80 -14
@@ -17436,9 +17436,11 @@ ${strays.map((path) => ` - \`${displayPath(path)}\``).join("\n")}
17436
17436
  - Introduce no new flag read and add no second read of an existing one:
17437
17437
  every match of \`${pattern}\` may move, never increase in number`;
17438
17438
  const withRules = (rules) => rules === void 0 ? "" : `${rules}\n`;
17439
- const refactorBuildingPrompt = ({ pkg, strays, pattern, reference, feedback = false }) => `${stateFileRules}
17439
+ const refactorBuildingPrompt = ({ pkg, strays, pattern, reference, feedback = false, note = false }) => `${stateFileRules}
17440
17440
  - The package to implement is \`${pkg}\`; never delete it
17441
- ${feedback ? `- The package carries a \`## Review feedback\` section: a human's review of the
17441
+ ${note ? `- The package's \`## Refactor note\` section names the seams to open and the
17442
+ shape each takes, written from the whole naive pass: build exactly those
17443
+ ` : ""}${feedback ? `- The package carries a \`## Review feedback\` section: a human's review of the
17442
17444
  last refactor. Act on it, within the rules below
17443
17445
  ` : ""}${refactorRules(strays, pattern)}
17444
17446
  ${reference === void 0 ? "" : `- Reference only: \`git show ${reference} -- ${strays.map(quote).join(" ")}\` shows an
@@ -18057,21 +18059,50 @@ ${criteriaBinding(false)}
18057
18059
  - Leave \`.gtd/\` untouched
18058
18060
  - Leave everything uncommitted and finish your turn — do not commit
18059
18061
  ${retry === void 0 ? "" : naiveRetry(retry)}`;
18060
- const implementBuildingPrompt = (reference, tier, amend, afterRefactor, flagMode = true) => `${stateFileRules}
18062
+ const carries = (rebuild) => (rebuild.acceptance?.ids.length ?? 0) > 0;
18063
+ const rebuildLines = ({ note, acceptance }) => `${note === void 0 ? "" : `- The refactor note (\`.gtd/REFACTOR-NOTE.md\`) named the seams the refactor
18064
+ opened; plug the change into them:
18065
+
18066
+ ${note.trimEnd().split("\n").map((line) => line === "" ? "" : ` > ${line}`).join("\n")}
18067
+
18068
+ `}${acceptance === void 0 || !carries({ acceptance }) ? "" : `- Carry over the naive pass's acceptance tests for ${acceptance.ids.join(", ")}, the
18069
+ one part of it you take as it is: \`git show ${acceptance.hash} -- ${acceptance.tests.map(quote).join(" ")}\`.
18070
+ Copy each test with its \`[AC-<n>]\` name into the tree as the refactor left
18071
+ it — check a file out of the tip whole only where the refactor did not touch
18072
+ it — changing only what reaches the release flag. A turn whose changed tests
18073
+ leave an ID out is refused, and the suite must be green with them
18074
+ `}`;
18075
+ const refactorNotePrompt = (reference, strays) => `${stateFileRules}
18076
+ - The seam judge ruled the naive pass refactor-first: it is never applied.
18077
+ A behaviour-neutral refactor lands first, then the change is rebuilt on
18078
+ the refactored base. Both turns read the note you write
18079
+ - Read the whole naive pass: \`git show ${reference}\`. It changed these
18080
+ existing files outside the allowlist:
18081
+ ${listed(strays)}
18082
+ - Write \`.gtd/REFACTOR-NOTE.md\`: which seams the refactor opens (the existing
18083
+ code to restructure, by path and symbol) and the shape each takes — the
18084
+ signatures and where the new behaviour will plug in. Name only the files
18085
+ listed above: the refactor may change no other path. Never the feature's own
18086
+ behaviour, and no flag read: the refactor adds neither
18087
+ - Change nothing else — a turn that does is refused
18088
+ - Leave everything uncommitted and finish your turn — do not commit
18089
+ `;
18090
+ const afterRefactorLine = (rebuild) => `- A reviewed refactor has just landed and may already carry part of the change,
18091
+ though never a release flag. Build only what is still missing, held to every
18092
+ rule below; if nothing is, ${carries(rebuild) ? "carry over the acceptance tests below and change nothing else" : "change nothing and finish your turn"}
18093
+ `;
18094
+ const implementBuildingPrompt = (reference, tier, amend, afterRefactor, flagMode = true, rebuild = {}) => `${stateFileRules}
18061
18095
  - Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
18062
18096
  ${criteriaBinding(true)}
18063
- ${afterRefactor === true ? `- A reviewed refactor has just landed and may already carry part of the change,
18064
- though never a release flag. Build only what is still missing, held to every
18065
- rule below; if nothing is, change nothing and finish your turn
18066
- ` : ""}${amend === true ? `- The change already exists in the tree from an earlier lap; amend it to meet
18097
+ ${afterRefactor === true ? afterRefactorLine(rebuild) : ""}${amend === true ? `- The change already exists in the tree from an earlier lap; amend it to meet
18067
18098
  the re-derived requirements instead of rewriting it
18068
18099
  ` : flagMode ? `- Implement them properly, fitting the codebase's seams and conventions
18069
18100
  ` : `- Implement them the quickest way that works — the naive version. Do not
18070
18101
  restructure, abstract or refactor around the change
18071
18102
  `}${reference === void 0 || amend === true ? "" : `- Reference only: \`git show ${reference}\` shows an earlier naive attempt.
18072
18103
  Read it for ideas; never apply, cherry-pick or check it out — write
18073
- the change fresh
18074
- `}${!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
18104
+ 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
18075
18106
  repository's flag conventions`}
18076
18107
  - Write the tests first, then the code that makes them pass. Name each
18077
18108
  criterion's ID in the test that covers it (\`[AC-3] …\`): once the suite is
@@ -18180,6 +18211,7 @@ const ESCALATION = ".gtd/ESCALATION.md";
18180
18211
  const REQUIREMENTS = ".gtd/REQUIREMENTS.md";
18181
18212
  const REVIEW = ".gtd/REVIEW.md";
18182
18213
  const REFACTOR = ".gtd/REFACTOR.md";
18214
+ const REFACTOR_NOTE = ".gtd/REFACTOR-NOTE.md";
18183
18215
  const QUALITY = ".gtd/QUALITY.md";
18184
18216
  const planner = () => env.plannerModel ?? "";
18185
18217
  const coder = () => env.coderModel ?? "";
@@ -37697,7 +37729,11 @@ const ShippedOptions = Struct({
37697
37729
  testCommand: setting,
37698
37730
  plannerModel: setting,
37699
37731
  coderModel: setting,
37700
- flagMode: optionalWith(Boolean$, { exact: true })
37732
+ flagMode: optionalWith(Boolean$, { exact: true }),
37733
+ contextLimit: optionalWith(Struct({
37734
+ soft: optionalWith(Number$, { exact: true }),
37735
+ hard: optionalWith(Number$, { exact: true })
37736
+ }), { exact: true })
37701
37737
  });
37702
37738
  const over = (base, options) => ({
37703
37739
  ...base,
@@ -37709,12 +37745,13 @@ const flagModeOn = () => {
37709
37745
  if (value !== "true" && value !== "false") refuse(`gtd: flagMode must be "true" or "false", got ${JSON.stringify(value)}`);
37710
37746
  return value === "true";
37711
37747
  };
37748
+ const limited = (flow, context) => context === void 0 ? flow : () => scope({ context }, flow);
37712
37749
  /** A shipped flow as a factory: its options override the settings it declares, each in its own kind. */
37713
37750
  const shipped = (flow, doors, base) => defineWorkflow(standardSchemaV1(ShippedOptions, {
37714
37751
  onExcessProperty: "error",
37715
37752
  errors: "all"
37716
- }), (options) => ({
37717
- flow,
37753
+ }), ({ contextLimit, ...options }) => ({
37754
+ flow: limited(flow, contextLimit),
37718
37755
  defaults: over(defaults, options),
37719
37756
  envDefaults: over(envDefaults, options),
37720
37757
  doors,
@@ -37727,14 +37764,14 @@ const FlagOptions = Struct({
37727
37764
  const flagShipped = (flow, name) => defineWorkflow(standardSchemaV1(FlagOptions, {
37728
37765
  onExcessProperty: "error",
37729
37766
  errors: "all"
37730
- }), (options) => {
37767
+ }), ({ contextLimit, ...options }) => {
37731
37768
  const flagDefaults = {
37732
37769
  ...defaults,
37733
37770
  flag: "",
37734
37771
  flagRegistry: defaultFlagRegistry
37735
37772
  };
37736
37773
  return {
37737
- flow,
37774
+ flow: limited(flow, contextLimit),
37738
37775
  defaults: over(flagDefaults, options),
37739
37776
  envDefaults: over(envDefaults, options),
37740
37777
  doors: { [name]: {
@@ -38473,7 +38510,8 @@ const plan = (shape, options) => {
38473
38510
  return {
38474
38511
  pattern,
38475
38512
  strays,
38476
- reference: options.reference?.trim() ? options.reference : void 0
38513
+ reference: options.reference?.trim() ? options.reference : void 0,
38514
+ note: options.note?.trim() ? options.note : void 0
38477
38515
  };
38478
38516
  };
38479
38517
  const guardOf = ({ pattern, strays }, entry) => () => {
@@ -38482,13 +38520,22 @@ const guardOf = ({ pattern, strays }, entry) => () => {
38482
38520
  };
38483
38521
  /** Derive, build, check and close one refactor lap; `feedback` is the collected review text of the lap before. */
38484
38522
  const refactorLap = async (p, guard, feedback) => {
38485
- const { pattern, strays, reference } = p;
38486
- await run("deriving", deriveScript([...packageLines(strays, pattern, reference), ...feedback === void 0 ? [] : [
38487
- "## Review feedback",
38488
- "",
38489
- ...demoteHeadings(feedback),
38490
- ""
38491
- ]]), {
38523
+ const { pattern, strays, reference, note } = p;
38524
+ await run("deriving", deriveScript([
38525
+ ...packageLines(strays, pattern, reference),
38526
+ ...note === void 0 ? [] : [
38527
+ "## Refactor note",
38528
+ "",
38529
+ ...demoteHeadings(note),
38530
+ ""
38531
+ ],
38532
+ ...feedback === void 0 ? [] : [
38533
+ "## Review feedback",
38534
+ "",
38535
+ ...demoteHeadings(feedback),
38536
+ ""
38537
+ ]
38538
+ ]), {
38492
38539
  label: "Deriving the refactor package",
38493
38540
  file: REFACTOR_PACKAGE
38494
38541
  });
@@ -38497,7 +38544,8 @@ const refactorLap = async (p, guard, feedback) => {
38497
38544
  strays,
38498
38545
  pattern,
38499
38546
  reference,
38500
- feedback: feedback !== void 0
38547
+ feedback: feedback !== void 0,
38548
+ note: note !== void 0
38501
38549
  }), {
38502
38550
  label: "Refactoring",
38503
38551
  model: coder(),
@@ -38583,13 +38631,16 @@ const COVERED_MIN_P = .7;
38583
38631
  /** Covering turns before the process rests at `uncovered`; a human edit there refills them. */
38584
38632
  const COVERING_RETRIES = 2;
38585
38633
  const frozen = () => requireCriteriaFrozen(REQUIREMENTS);
38586
- const green = () => healthy(() => agentWithSkills("fix.suite.fixing", implementFixPrompt(), {
38634
+ const green = (guard) => healthy(() => agentWithSkills("fix.suite.fixing", implementFixPrompt(), {
38587
38635
  label: "Fixing the check",
38588
38636
  file: FEEDBACK,
38589
38637
  model: coder(),
38590
38638
  system: builderSystem()
38591
38639
  }), {
38592
- guard: frozen,
38640
+ guard: () => {
38641
+ frozen();
38642
+ guard?.();
38643
+ },
38593
38644
  guardRests: false
38594
38645
  });
38595
38646
  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");
@@ -38703,16 +38754,20 @@ const addedLines = (change) => {
38703
38754
  const before = new Set((change.before ?? "").split("\n"));
38704
38755
  return change.after.split("\n").filter((line) => !before.has(line)).join("\n");
38705
38756
  };
38757
+ 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 !== "");
38759
+ const unnamed = (ids, tests) => {
38760
+ const lines = tests.map(addedLines);
38761
+ return ids.filter((id) => !lines.some((text) => named(id, text)));
38762
+ };
38706
38763
  /**
38707
38764
  * Why `tip`'s tests fall short, or `undefined`: a criterion ID of `file` no
38708
38765
  * test line of the pass names, or a test importing a file the pass added —
38709
38766
  * naive structure a reset and refactor replaces, which the tests must survive.
38710
38767
  */
38711
38768
  const acceptanceFault = (file, tip) => {
38712
- const ids = (criteriaOf(read(file) ?? "") ?? []).map((c) => c.id).filter((id) => id !== "");
38713
- const tests = tip.changes.filter((c) => c.after !== void 0 && isCode$1(c.path) && isTestFile(c.path));
38714
- const lines = tests.map(addedLines);
38715
- const untested = ids.filter((id) => !lines.some((text) => named(id, text)));
38769
+ const tests = acceptanceTests(tip.changes);
38770
+ const untested = unnamed(idsOf(file), tests);
38716
38771
  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]}] …"`;
38717
38772
  const added = tip.changes.filter((c) => c.status === "added" && isCode$1(c.path) && !isTestFile(c.path)).map((c) => c.path);
38718
38773
  const reaching = tests.filter((test) => holdsCallSites(test.path)).flatMap((test) => {
@@ -38721,6 +38776,41 @@ const acceptanceFault = (file, tip) => {
38721
38776
  });
38722
38777
  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")}`;
38723
38778
  };
38779
+ /** 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);
38782
+ const perTest = acceptanceTests(tip.changes).map((c) => {
38783
+ const lines = addedLines(c);
38784
+ return {
38785
+ path: c.path,
38786
+ ids: ids.filter((id) => named(id, lines))
38787
+ };
38788
+ });
38789
+ return {
38790
+ hash: tip.hash,
38791
+ tests: perTest.filter((c) => c.ids.length > 0).map((c) => c.path),
38792
+ ids: ids.filter((id) => perTest.some((c) => c.ids.includes(id)))
38793
+ };
38794
+ };
38795
+ /**
38796
+ * Refuse a rebuild whose test changes since `since` leave out an ID the naive
38797
+ * pass's tests named. A skipped test still counts: the gate reads text, never runs it.
38798
+ */
38799
+ const requireAcceptance = (acceptance, since) => {
38800
+ const missing = unnamed(acceptance.ids, acceptanceTests(changesSince(since)));
38801
+ if (missing.length === 0) return;
38802
+ refuse(`gtd land: rebuild-acceptance: the rebuild's tests leave out ${missing.join(", ")} — carry over the naive pass's acceptance tests (\`git show ${acceptance.hash} -- ${acceptance.tests.map(quote).join(" ")}\`), each with its ID in the test or scenario name`);
38803
+ };
38804
+ /**
38805
+ * Refuse a fix turn that, on its own, drops an ID the naive pass's tests named
38806
+ * from a test file. Turn-local, so a person's edit at an escalation rest never
38807
+ * wedges later fix turns.
38808
+ */
38809
+ const requireAcceptanceKept = (acceptance) => {
38810
+ const dropped = acceptance.ids.filter((id) => changes().filter((c) => isCode$1(c.path) && isTestFile(c.path)).some((c) => named(id, c.before ?? "") && !named(id, c.after ?? "")));
38811
+ if (dropped.length === 0) return;
38812
+ refuse(`gtd land: rebuild-acceptance: this turn drops ${dropped.join(", ")} from the tests — the naive pass's acceptance tests stay; fix the code, not them`);
38813
+ };
38724
38814
  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`;
38725
38815
  /**
38726
38816
  * Run the suite with `tip`'s diff applied, then take it out again; resolves
@@ -38764,16 +38854,20 @@ function implement(first, tier, amend) {
38764
38854
  amend
38765
38855
  };
38766
38856
  return scope("implement", async () => {
38767
- if (options.naive === void 0 || !await applied(options.naive)) await agentWithSkills("building", implementBuildingPrompt(options.reference, options.tier, options.amend, options.afterRefactor, flagModeOn()), {
38857
+ const since = head$1();
38858
+ const acceptance = options.rebuild?.acceptance;
38859
+ 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), {
38768
38861
  label: "Implementing",
38769
38862
  model: coder(),
38770
38863
  system: builderSystem(),
38771
38864
  ...options.afterRefactor === true ? { allowEmpty: true } : {}
38772
38865
  });
38773
38866
  requireCriteriaFrozen(REQUIREMENTS);
38774
- await green();
38867
+ if (built && acceptance !== void 0) requireAcceptance(acceptance, since);
38868
+ await green(built && acceptance !== void 0 ? () => requireAcceptanceKept(acceptance) : void 0);
38775
38869
  await requireCoverage();
38776
- await run("closing", removeScript([REQUIREMENTS]), { label: "Closing out the implementation" });
38870
+ await run("closing", removeScript([REQUIREMENTS, REFACTOR_NOTE]), { label: "Closing out the implementation" });
38777
38871
  });
38778
38872
  }
38779
38873
  /** Resolves `true` when the human steered (edited), `false` on a clean overrule. */
@@ -38823,16 +38917,23 @@ const vetted = async (tip, vetting) => {
38823
38917
  /** The judge's ruling on a vetted pass: the decision, or the next attempt's verdict. */
38824
38918
  const ruled = async (tip, again) => {
38825
38919
  const shape = await judgeSeam(tip);
38826
- if (shape.choice === "land") return { reference: tip.hash };
38920
+ if (shape.choice === "land") return {
38921
+ reference: tip.hash,
38922
+ tip
38923
+ };
38827
38924
  if (shape.choice === "refactor-first") return {
38828
38925
  reference: tip.hash,
38926
+ tip,
38829
38927
  shape
38830
38928
  };
38831
38929
  const next = await again({
38832
38930
  strays: shape.strays,
38833
38931
  p: shape.judgment?.answers.shape?.p ?? 0
38834
38932
  });
38835
- return next.kind === "overruled" ? { reference: tip.hash } : next;
38933
+ return next.kind === "overruled" ? {
38934
+ reference: tip.hash,
38935
+ tip
38936
+ } : next;
38836
38937
  };
38837
38938
  const decide = () => scope("seam", async () => {
38838
38939
  const vetting = {
@@ -38843,13 +38944,29 @@ const decide = () => scope("seam", async () => {
38843
38944
  for (;;) {
38844
38945
  await naivePass(retry);
38845
38946
  const tip = kept("naive");
38846
- const outcome = await vetted(tip, vetting) ?? (flagModeOn() ? await ruled(tip, vetting.again) : { reference: tip.hash });
38947
+ const outcome = await vetted(tip, vetting) ?? (flagModeOn() ? await ruled(tip, vetting.again) : {
38948
+ reference: tip.hash,
38949
+ tip
38950
+ });
38847
38951
  if (!("kind" in outcome)) return outcome;
38848
38952
  retry = outcome.kind === "retry" ? outcome.retry : void 0;
38849
38953
  }
38850
38954
  });
38955
+ /** Write the refactor note from the whole naive pass; a turn that changes anything else is refused. */
38956
+ const refactorNote = async (reference, strays) => {
38957
+ await agent("note", refactorNotePrompt(reference, strays), {
38958
+ label: "Writing the refactor note",
38959
+ file: REFACTOR_NOTE,
38960
+ model: coder(),
38961
+ system: builderSystem()
38962
+ });
38963
+ const others = changes().map((c) => c.path).filter((path) => path !== REFACTOR_NOTE);
38964
+ const note = read(".gtd/REFACTOR-NOTE.md") ?? "";
38965
+ 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
+ return note;
38967
+ };
38851
38968
  const seamBuild = async (base) => {
38852
- const { reference, shape } = await decide();
38969
+ const { reference, tip, shape } = await decide();
38853
38970
  if (shape === void 0) return {
38854
38971
  reference,
38855
38972
  base,
@@ -38863,18 +38980,29 @@ const seamBuild = async (base) => {
38863
38980
  }));
38864
38981
  return {
38865
38982
  reference,
38866
- base
38983
+ base,
38984
+ rebuild: { acceptance: naiveAcceptance(tip) }
38867
38985
  };
38868
38986
  }
38869
- if (refactorStrays(shape).length === 0) return {
38987
+ const strays = refactorStrays(shape);
38988
+ if (strays.length === 0) return {
38870
38989
  reference,
38871
- base
38990
+ base,
38991
+ rebuild: { acceptance: naiveAcceptance(tip) }
38872
38992
  };
38873
- const refactored = await slice("refactor", () => reviewedRefactor(shape, { reference }));
38993
+ const note = await scope("seam", () => refactorNote(reference, strays));
38994
+ const refactored = await slice("refactor", () => reviewedRefactor(shape, {
38995
+ reference,
38996
+ note
38997
+ }));
38874
38998
  return {
38875
38999
  reference,
38876
39000
  base: head$1(),
38877
- refactored
39001
+ refactored,
39002
+ rebuild: {
39003
+ note,
39004
+ acceptance: naiveAcceptance(tip)
39005
+ }
38878
39006
  };
38879
39007
  };
38880
39008
  //#endregion
@@ -38893,9 +39021,11 @@ const buildFeature = async (firstBase) => {
38893
39021
  reference: seam.reference,
38894
39022
  tier,
38895
39023
  afterRefactor: seam.refactored,
38896
- naive: seam.land === true ? seam.reference : void 0
39024
+ naive: seam.land === true ? seam.reference : void 0,
39025
+ rebuild: seam.rebuild
38897
39026
  });
38898
- const base = seam.refactored === true && !changesSince(seam.base).some((c) => isCode$1(c.path)) ? firstBase : seam.base;
39027
+ 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;
38899
39029
  await tail(settleQuality, {
38900
39030
  green: true,
38901
39031
  lensBase: base
@@ -39138,4 +39268,4 @@ var workflows_default = feature;
39138
39268
  */
39139
39269
  const doors = {};
39140
39270
  //#endregion
39141
- 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 };
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "36.0.0",
3
+ "version": "37.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": {
@@ -1106,6 +1106,7 @@ describe("standaloneKinds / needsOf", () => {
1106
1106
  "uncheck",
1107
1107
  "install",
1108
1108
  "loop",
1109
+ "contextHook",
1109
1110
  "judgeRun",
1110
1111
  "strays",
1111
1112
  ])
@@ -1117,6 +1118,7 @@ describe("standaloneKinds / needsOf", () => {
1117
1118
  expect(needsOf("uncheck")).toBe("fs")
1118
1119
  expect(needsOf("install")).toBe("none")
1119
1120
  expect(needsOf("loop")).toBe("none")
1121
+ expect(needsOf("contextHook")).toBe("none")
1120
1122
  expect(needsOf("judgeRun")).toBe("none")
1121
1123
  expect(needsOf("strays")).toBe("none")
1122
1124
  for (const kind of [
@@ -1202,19 +1204,24 @@ describe("renderHelp", () => {
1202
1204
 
1203
1205
  it("README.md and COMPATIBILITY.md list exactly the commands", () => {
1204
1206
  const commands = new Set(
1205
- [...renderHelp().matchAll(/^ {2}([a-z]+(?: [a-z]+)?)(?= )/gm)].map(([, name]) => name),
1207
+ [...renderHelp().matchAll(/^ {2}([a-z]+(?:-[a-z]+)?(?: [a-z]+)?)(?= )/gm)].map(
1208
+ ([, name]) => name,
1209
+ ),
1206
1210
  )
1207
1211
  expect(commands).toContain("judge answer")
1212
+ expect(commands).toContain("context-hook")
1208
1213
  const listed = (file: string, list: RegExp): Set<string> => {
1209
1214
  const doc = readFileSync(resolve(import.meta.dirname, file), "utf8")
1210
1215
  const match = doc.match(list)
1211
1216
  expect(match, file).not.toBeNull()
1212
- return new Set([...match![1]!.matchAll(/`([a-z]+(?: [a-z]+)?)`/g)].map(([, name]) => name!))
1217
+ return new Set(
1218
+ [...match![1]!.matchAll(/`([a-z]+(?:-[a-z]+)?(?: [a-z]+)?)`/g)].map(([, name]) => name!),
1219
+ )
1213
1220
  }
1214
1221
  expect(
1215
1222
  listed("../../README.md", /The `gtd` bin's commands — ([\s\S]*?) — are\s+listed/),
1216
1223
  ).toEqual(commands)
1217
- expect(listed("../../COMPATIBILITY.md", /every command \(([\s\S]*?)\), option/)).toEqual(
1224
+ expect(listed("../../COMPATIBILITY.md", /every command \(([\s\S]*?)\),\s+option/)).toEqual(
1218
1225
  commands,
1219
1226
  )
1220
1227
  })
package/src/cli/Cli.ts CHANGED
@@ -35,7 +35,7 @@ import {
35
35
  type JsonMode,
36
36
  } from "./Program.js"
37
37
  import type { ToolCommands } from "./ToolCommands.js"
38
- import { EXIT_RUNTIME_ERROR, EXIT_USAGE_ERROR } from "../wire/index.js"
38
+ import { EXIT_RUNTIME_ERROR, EXIT_USAGE_ERROR, parseContextWindow } from "../wire/index.js"
39
39
 
40
40
  export type { Command, CommandRequirements } from "./Program.js"
41
41
  export { needsOf, standaloneKinds } from "./Program.js"
@@ -90,6 +90,13 @@ const nonNegativeNumber = (raw: string, flag: string): Either.Either<number, str
90
90
  return Either.right(n)
91
91
  }
92
92
 
93
+ const share = (raw: string, flag: string): Either.Either<number, string> => {
94
+ const n = Number(raw)
95
+ return raw.trim() !== "" && n > 0 && n <= 1
96
+ ? Either.right(n)
97
+ : Either.left(`gtd: ${flag} must be a share of the context window in (0, 1] — got "${raw}"`)
98
+ }
99
+
93
100
  const FLAGS: readonly FlagRow[] = [
94
101
  {
95
102
  name: "--json",
@@ -245,6 +252,41 @@ const FLAGS: readonly FlagRow[] = [
245
252
  "GTD_JUDGE_MODEL, then judge.json, then sonnet",
246
253
  ],
247
254
  },
255
+ {
256
+ name: "--soft",
257
+ arity: 1,
258
+ repeatable: false,
259
+ scope: (kind) => kind === "contextHook",
260
+ decode: ([raw]) => share(raw ?? "", "--soft"),
261
+ scopeError: "gtd: --soft is only valid for `gtd context-hook`",
262
+ valueHint: "<share>",
263
+ help: ["(gtd context-hook only) the soft limit; default 0.6"],
264
+ },
265
+ {
266
+ name: "--hard",
267
+ arity: 1,
268
+ repeatable: false,
269
+ scope: (kind) => kind === "contextHook",
270
+ decode: ([raw]) => share(raw ?? "", "--hard"),
271
+ scopeError: "gtd: --hard is only valid for `gtd context-hook`",
272
+ valueHint: "<share>",
273
+ help: ["(gtd context-hook only) the hard limit; default 0.7"],
274
+ },
275
+ {
276
+ name: "--window",
277
+ arity: 1,
278
+ repeatable: false,
279
+ scope: (kind) => kind === "contextHook",
280
+ decode: ([raw]) => {
281
+ const window = parseContextWindow(raw)
282
+ return window !== undefined
283
+ ? Either.right(window)
284
+ : Either.left(`gtd: --window must be a positive integer — got "${raw ?? ""}"`)
285
+ },
286
+ scopeError: "gtd: --window is only valid for `gtd context-hook`",
287
+ valueHint: "<tokens>",
288
+ help: ["(gtd context-hook only) the model's context window in", "tokens; default 200000"],
289
+ },
248
290
  {
249
291
  name: "--slice",
250
292
  arity: 1,
@@ -593,6 +635,22 @@ const COMMAND_ROWS: readonly CommandRow[] = [
593
635
  "~/.config/gtd/loop.json); see docs/driver.md",
594
636
  ],
595
637
  },
638
+ {
639
+ token: "context-hook",
640
+ kind: "contextHook",
641
+ arity: "none",
642
+ details: [
643
+ "A Claude Code PostToolUse and PreCompact hook that stops an",
644
+ "agent turn at a share of its context window. Reads the",
645
+ "hook's input on stdin and the context the transcript's",
646
+ "latest assistant message used. Past --soft it blocks with a",
647
+ "request to end the turn with a handoff note; past --hard,",
648
+ "or at PreCompact, it stops the turn with a stopReason",
649
+ 'starting "gtd: context limit". Prints nothing otherwise,',
650
+ "and for a subagent's tool call. Always exits 0 on valid",
651
+ "input; see docs/driver.md",
652
+ ],
653
+ },
596
654
  {
597
655
  token: "summary",
598
656
  kind: "summary",
@@ -1182,6 +1240,9 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1182
1240
  readonly "--provider"?: "fixed" | "jev" | "llm"
1183
1241
  readonly "--answers"?: string
1184
1242
  readonly "--slice"?: string
1243
+ readonly "--soft"?: number
1244
+ readonly "--hard"?: number
1245
+ readonly "--window"?: number
1185
1246
  }
1186
1247
 
1187
1248
  // `present.has("--json")` alone can't distinguish bare `--json` (no value
@@ -1285,6 +1346,20 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1285
1346
  }
1286
1347
  }
1287
1348
 
1349
+ if (kind === "contextHook") {
1350
+ return {
1351
+ kind: "command",
1352
+ command: {
1353
+ kind: "contextHook",
1354
+ ...(bag["--soft"] !== undefined ? { soft: bag["--soft"] } : {}),
1355
+ ...(bag["--hard"] !== undefined ? { hard: bag["--hard"] } : {}),
1356
+ ...(bag["--window"] !== undefined ? { window: bag["--window"] } : {}),
1357
+ },
1358
+ json,
1359
+ verbose,
1360
+ }
1361
+ }
1362
+
1288
1363
  if (kind === "summary") {
1289
1364
  return {
1290
1365
  kind: "command",
@@ -1686,6 +1686,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
1686
1686
  uncheck: { kind: "uncheck", file: ".gtd/REVIEW.md" },
1687
1687
  install: { kind: "install" },
1688
1688
  loop: { kind: "loop" },
1689
+ contextHook: { kind: "contextHook" },
1689
1690
  summary: { kind: "summary" },
1690
1691
  base: { kind: "base" },
1691
1692
  judge: { kind: "judge" },
@@ -162,6 +162,12 @@ export type Command =
162
162
  | { readonly kind: "uncheck"; readonly file: string }
163
163
  | { readonly kind: "install" }
164
164
  | { readonly kind: "loop" }
165
+ | {
166
+ readonly kind: "contextHook"
167
+ readonly soft?: number
168
+ readonly hard?: number
169
+ readonly window?: number
170
+ }
165
171
  | { readonly kind: "summary"; readonly slice?: string }
166
172
  | { readonly kind: "base" }
167
173
  | { readonly kind: "judge" }
@@ -434,6 +440,24 @@ const readStdin = (): Effect.Effect<string, Error> =>
434
440
  catch: (e) => (e instanceof Error ? e : new Error(String(e))),
435
441
  })
436
442
 
443
+ /** Stdout is written once: a hook reads one JSON document, or none. */
444
+ const runContextHookCommand = (
445
+ command: Extract<Command, { kind: "contextHook" }>,
446
+ out: ArtifactOut,
447
+ ): Effect.Effect<void, Error, ToolCommands> =>
448
+ Effect.gen(function* () {
449
+ const tools = yield* ToolCommands
450
+ const input = yield* readStdin()
451
+ out.write(
452
+ yield* tools.contextHook({
453
+ input,
454
+ ...(command.soft !== undefined ? { soft: command.soft } : {}),
455
+ ...(command.hard !== undefined ? { hard: command.hard } : {}),
456
+ ...(command.window !== undefined ? { window: command.window } : {}),
457
+ }),
458
+ )
459
+ })
460
+
437
461
  /** Stdout is written once, after the answerer resolves — a rejection leaves it empty. */
438
462
  const runJudgeRunCommand = (
439
463
  command: Extract<Command, { kind: "judgeRun" }>,
@@ -1189,6 +1213,7 @@ export const needsOf = (kind: Command["kind"]): Needs => {
1189
1213
  case "lsp":
1190
1214
  case "install":
1191
1215
  case "loop":
1216
+ case "contextHook":
1192
1217
  case "judgeRun":
1193
1218
  case "strays":
1194
1219
  return "none"
@@ -1207,6 +1232,7 @@ export const standaloneKinds = (): readonly Command["kind"][] => [
1207
1232
  "uncheck",
1208
1233
  "install",
1209
1234
  "loop",
1235
+ "contextHook",
1210
1236
  "judgeRun",
1211
1237
  "strays",
1212
1238
  ]
@@ -1278,6 +1304,8 @@ const dispatchVoidCommand = (
1278
1304
  return runInstallCommand(out)
1279
1305
  case "loop":
1280
1306
  return runLoopCommand()
1307
+ case "contextHook":
1308
+ return runContextHookCommand(command, out)
1281
1309
  case "summary":
1282
1310
  return runSummaryCommand(out, command.slice)
1283
1311
  case "base":
@@ -1,6 +1,7 @@
1
1
  import { Context } from "effect"
2
2
  import type { Effect } from "effect"
3
3
  import type { UiSettings } from "../flows/index.js"
4
+ import type { ContextHookRequest } from "../wire/index.js"
4
5
 
5
6
  /** How to run THIS gtd — a driver (the loop, the LSP) never reads a different version's state than the one it ships with. */
6
7
  export type SelfGtd = readonly [string, ...string[]]
@@ -38,6 +39,8 @@ export class ToolCommands extends Context.Tag("ToolCommands")<
38
39
  ) => Effect.Effect<void, Error>
39
40
  readonly lsp: (gtd: SelfGtd) => Effect.Effect<void, Error>
40
41
  readonly loop: (gtd: SelfGtd) => Effect.Effect<void, Error>
42
+ /** The hook's stdout: a JSON document, or empty to let the turn go on. */
43
+ readonly contextHook: (request: ContextHookRequest) => Effect.Effect<string, Error>
41
44
  readonly judge: (request: JudgeRequest) => Promise<string>
42
45
  }
43
46
  >() {}