@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtd",
3
- "version": "36.0.0",
3
+ "version": "37.0.0",
4
4
  "description": "Drive the gtd loop inside Claude Code: agent turns run as subagents, gtd decides every beat",
5
5
  "author": {
6
6
  "name": "Philipp Melab"
package/README.md CHANGED
@@ -307,6 +307,13 @@ Or skip writing one: **`gtd loop`** is that loop, shipped with gtd. It drives
307
307
  to real model names through per-machine settings — see
308
308
  [`gtd loop`](https://github.com/pmelab/gtd/blob/main/docs/driver.md#the-bundled-driver-gtd-loop).
309
309
 
310
+ Both it and the Claude Code mod stop an agent turn that outgrows its context:
311
+ past 60% of the model's window the agent is asked to wrap up with a handoff
312
+ note, past 70% (or when the context is about to be compacted) the turn stops and
313
+ nothing lands. A step's `context` option, or the bundled workflows'
314
+ `contextLimit` option, moves the two limits — see
315
+ [Context limits](https://github.com/pmelab/gtd/blob/main/docs/driver.md#context-limits).
316
+
310
317
  ### Or drive it from inside Claude Code
311
318
 
312
319
  The gtd mod runs the same loop inside an interactive Claude Code session
@@ -607,13 +614,18 @@ is sent back while a criterion's ID is missing from its tests, a test imports a
607
614
  file it added, or the suite is red on it. A judge checks whether it changed
608
615
  anything outside the expected set (tests, steering files; see `seamAllow`).
609
616
  Incidental changes land: the pass itself is the implementation, with no rebuild,
610
- straight through the health loop and the quality lap. A needed seam change is
611
- refactored first, in its own slice with its own quality lap
612
- (`refactorQualityReviews`) and review, before the feature is implemented afresh;
613
- a wrong approach is retried twice, then rests for you to steer or overrule it.
614
- `gtd slices` lists the `refactor` and `feature` slices. A refactor that already
615
- carried the whole feature leaves the implementation nothing to do: its empty
616
- turn passes, and the feature's quality lap covers the whole change. Once the
617
+ straight through the health loop and the quality lap. A needed seam change
618
+ resets: the pass is never applied. An agent reads its whole diff and writes a
619
+ refactor note (`.gtd/REFACTOR-NOTE.md`: which seams, which shape); the seam is
620
+ refactored first, flag-free with the existing suite green, in its own slice with
621
+ its own quality lap (`refactorQualityReviews`) and review; then the feature is
622
+ rebuilt on the refactored base, behind a release flag, with the note and the
623
+ naive diff as reference. The rebuild is refused until its tests carry every
624
+ criterion ID the naive pass's tests named, and the suite must be green with
625
+ them. A wrong approach is retried twice, then rests for you to steer or overrule
626
+ it. `gtd slices` lists the `refactor` and `feature` slices. A refactor that
627
+ already carried the whole feature leaves the rebuild only the carried-over
628
+ tests, and the feature's quality lap covers the whole change. Once the
617
629
  implementation is green, a judge checks every criterion ID has a covering test:
618
630
  an uncovered one goes back to the agent (twice, then to you) instead of ending
619
631
  the feature, and each covered ID is recorded as a `Gtd-Mark: covers=<id>`
@@ -755,8 +767,8 @@ install for any of these but `./testkit`: gtd resolves a `gtd.config.ts`'s
755
767
  The `gtd` bin's commands — `next`, `land`, `abandon`, `restore`, `validate`,
756
768
  `check`, `uncheck`, `install`, `summary`, `base`, `door`, `doors`, `slices`,
757
769
  `version`, `help`, `history list`, `history show`, `history fetch`, `strays`,
758
- and the tools `ui`, `lsp`, `loop`, `judge` (`judge answer`, `judge run`) — are
759
- listed in the
770
+ and the tools `ui`, `lsp`, `loop`, `context-hook`, `judge` (`judge answer`,
771
+ `judge run`) — are listed in the
760
772
  [CLI reference](https://github.com/pmelab/gtd/blob/main/docs/cli.md).
761
773
  `gtd slices` lists the spans a flow's `slice()` calls mark. It refuses (exit 1)
762
774
  when HEAD neither closes nor sits inside a process, or when today's config
@@ -0,0 +1,66 @@
1
+ import {
2
+ contextHook,
3
+ contextLimitFault,
4
+ contextWindowFor,
5
+ DEFAULT_CONTEXT_LIMIT,
6
+ parseContextWindow,
7
+ subagentTranscriptPath,
8
+ transcriptTokens,
9
+ } from "../../src/wire/ContextLimit.js"
10
+ import type { ContextDecision, ContextLimit } from "../../src/wire/ContextLimit.js"
11
+
12
+ // The mod's half of `gtd context-hook`: the same decision, for a worker
13
+ // subagent instead of a `claude -p` session.
14
+
15
+ export type Guard = { limit: ContextLimit; window: number }
16
+
17
+ /** A turn's limits from its beat's `context`, the model the subagent runs on and the window override; a bad window is ignored. */
18
+ export function guardOf(
19
+ context: { soft?: number; hard?: number } | undefined,
20
+ model: string | undefined,
21
+ windowEnv: string | undefined,
22
+ sessionWindow?: number,
23
+ ): Guard | undefined {
24
+ const limit = { ...DEFAULT_CONTEXT_LIMIT, ...context }
25
+ if (contextLimitFault(limit) !== undefined) return undefined
26
+ // A worker with no model of its own runs on the session's, window included.
27
+ const own = model === undefined ? sessionWindow : undefined
28
+ return { limit, window: parseContextWindow(windowEnv) ?? own ?? contextWindowFor(model) }
29
+ }
30
+
31
+ export type HookEvent = {
32
+ hook_event_name: string
33
+ transcript_path: string
34
+ agent_id?: string
35
+ }
36
+
37
+ /**
38
+ * A worker's hook event decided. It is handed the MAIN session's transcript,
39
+ * so the worker's own is read instead.
40
+ */
41
+ export async function decide(
42
+ e: HookEvent,
43
+ guard: Guard | undefined,
44
+ read: (path: string) => Promise<string | undefined>,
45
+ ): Promise<ContextDecision | undefined> {
46
+ const event = e.hook_event_name
47
+ if (!guard || !e.agent_id || (event !== "PostToolUse" && event !== "PreCompact")) return undefined
48
+ const transcript =
49
+ event === "PostToolUse" && e.transcript_path
50
+ ? await read(subagentTranscriptPath(e.transcript_path, e.agent_id))
51
+ : undefined
52
+ return contextHook({
53
+ event,
54
+ tokens: transcript === undefined ? undefined : transcriptTokens(transcript),
55
+ ...guard,
56
+ })
57
+ }
58
+
59
+ /**
60
+ * A decision as a classic PostToolUse result. A hard stop only blocks here:
61
+ * the mod stops the worker's task itself, since `continue: false` from a
62
+ * subagent's hook is not documented to stop just that subagent.
63
+ */
64
+ export function classicResult(decision: ContextDecision) {
65
+ return { block: decision.kind === "soft" ? decision.reason : decision.stopReason }
66
+ }
@@ -20,6 +20,7 @@ export type Beat = {
20
20
  skills?: string[]
21
21
  validate?: string
22
22
  access?: AccessDef
23
+ context?: { soft?: number; hard?: number }
23
24
  judge?: unknown
24
25
  changes?: { status: string; path: string }[]
25
26
  session?: { id?: string; resume?: boolean | string }
@@ -42,6 +43,7 @@ export type Turn = {
42
43
  skills: readonly string[]
43
44
  scope: string
44
45
  access?: AccessDef
46
+ context?: { soft?: number; hard?: number }
45
47
  }
46
48
 
47
49
  // `isLost`: the scope's subagent no longer takes messages.
@@ -183,6 +185,7 @@ function turnOf(b: Beat): Turn {
183
185
  skills: b.skills ?? [],
184
186
  scope: memory.split("#")[0]!,
185
187
  access: b.access,
188
+ context: b.context,
186
189
  }
187
190
  }
188
191
 
@@ -14,6 +14,9 @@ import { personaSpec, resumable } from "./persona"
14
14
  import { JUDGE_SYSTEM, judgePrompt, judgeQuestions, judgeRunEnv, toVerdicts } from "./judge"
15
15
  import type { Judgment } from "./judge"
16
16
  import { catchFrom, throwTo } from "./handoff"
17
+ import { classicResult, decide, guardOf } from "./context"
18
+ import { contextHook } from "../../src/wire/ContextLimit.js"
19
+ import type { Guard, HookEvent } from "./context"
17
20
  import { jobs } from "./jobs"
18
21
  import { documents, readDocument } from "./schema"
19
22
  import { READ_ONLY_GIT, ship } from "./ship"
@@ -70,6 +73,23 @@ const ours = new Set<string>()
70
73
  const accesses = new Map<string, AccessDef>()
71
74
  const setAccess = (id: string, a: AccessDef | undefined) =>
72
75
  void (a ? accesses.set(id, a) : accesses.delete(id))
76
+ // Each turn agent's context limit, reset with its access; and why a worker
77
+ // was stopped at it, for the turn's end to report.
78
+ const guards = new Map<string, Guard>()
79
+ const contextStops = new Map<string, string>()
80
+ async function workerModel($: $, hint: string | undefined) {
81
+ return subagentModel(hint, {
82
+ smart: await $.env.get("GTD_PLANNERMODEL"),
83
+ base: await $.env.get("GTD_CODERMODEL"),
84
+ })
85
+ }
86
+ async function setGuard($: $, id: string, t: Pick<Turn, "context" | "model">) {
87
+ const model = await workerModel($, t.model)
88
+ const session = model === undefined ? (await $.session.usage()).context.window : undefined
89
+ const guard = guardOf(t.context, model, await $.env.get("GTD_LOOP_CONTEXT_WINDOW"), session)
90
+ if (guard) guards.set(id, guard)
91
+ else guards.delete(id)
92
+ }
73
93
  const waiting = new Map<string, (end: TurnEnd) => void>()
74
94
  const ended = new Map<string, TurnEnd>()
75
95
  const personas = new Set<string>()
@@ -122,6 +142,8 @@ async function send($: $, agentId: string, text: string) {
122
142
  async function forget($: $, memory: string, agentId: string) {
123
143
  $.ui.log(lostLine(memory.split("#")[0]!))
124
144
  accesses.delete(agentId)
145
+ guards.delete(agentId)
146
+ contextStops.delete(agentId)
125
147
  await update($, scopes, (s) => {
126
148
  const rest = { ...s }
127
149
  if (rest[memory]?.agentId === agentId) delete rest[memory]
@@ -193,6 +215,7 @@ function io($: $): Io {
193
215
  if (t.resume && entry && !known) $.ui.log(restartLine(t.scope))
194
216
  if (t.resume && known) {
195
217
  setAccess(known, t.access)
218
+ await setGuard($, known, t)
196
219
  const end = send($, known, t.prompt)
197
220
  await update($, run, (r) => ({ ...r, inflight: { agentId: known, memory: t.memory } }))
198
221
  const ended = await end
@@ -205,16 +228,14 @@ function io($: $): Io {
205
228
  subagentType: await persona($, t),
206
229
  prompt: t.prompt,
207
230
  description: t.label || "gtd",
208
- model: subagentModel(t.model, {
209
- smart: await $.env.get("GTD_PLANNERMODEL"),
210
- base: await $.env.get("GTD_CODERMODEL"),
211
- }),
231
+ model: await workerModel($, t.model),
212
232
  cwd: root,
213
233
  })
214
234
  const id = spawned.agentId
215
235
  if (!id) return settle($, { ok: false, why: spawned.deny ?? "subagent spawn refused" })
216
236
  ours.add(id)
217
237
  setAccess(id, t.access)
238
+ await setGuard($, id, t)
218
239
  await update($, agents, (list) => [...list, id].slice(-200))
219
240
  await update($, scopes, (s) => ({ ...s, [t.memory]: { agentId: id, persona: name } }))
220
241
  await update($, run, (r) => ({ ...r, inflight: { agentId: id, memory: t.memory } }))
@@ -912,6 +933,40 @@ async function isOurs($: $, id: string) {
912
933
  return isWorker
913
934
  }
914
935
 
936
+ // `gtd context-hook`'s decision for a worker: past its soft limit it is asked
937
+ // for a handoff note; past its hard limit, or about to compact, its task is
938
+ // stopped and its turn ends as a failure carrying the stop reason. Stopped off
939
+ // the hook, which runs inside the worker's own tool call.
940
+ async function stopWorker($: $, id: string, stopReason: string) {
941
+ if (contextStops.has(id)) return
942
+ contextStops.set(id, stopReason)
943
+ $.clock.after(0, () => {
944
+ void $.tool.call({ tool: "TaskStop", task_id: id }).catch((err: unknown) => {
945
+ $.ui.log(`gtd could not stop a subagent at its context limit: ${String(err)}`)
946
+ })
947
+ })
948
+ }
949
+
950
+ async function contextGuard($: $, e: HookEvent) {
951
+ const id = e.agent_id
952
+ if (!id || !ours.has(id)) return undefined
953
+ const decision = await decide(e, guards.get(id), async (path) =>
954
+ (await $.fs.exists(path)) ? $.fs.read(path) : undefined,
955
+ )
956
+ if (decision?.kind === "hard") await stopWorker($, id, decision.stopReason)
957
+ return decision && classicResult(decision)
958
+ }
959
+
960
+ // A worker's own compaction carries its id here (`classic.PreCompact` is not
961
+ // documented to): compaction about to happen means its limit was missed.
962
+ async function compactGuard($: $, agentId: string | undefined) {
963
+ if (!agentId || !ours.has(agentId) || !guards.has(agentId)) return undefined
964
+ const decision = contextHook({ event: "PreCompact", tokens: undefined, ...guards.get(agentId)! })
965
+ if (decision?.kind !== "hard") return undefined
966
+ await stopWorker($, agentId, decision.stopReason)
967
+ return { skip: decision.stopReason }
968
+ }
969
+
915
970
  // What a subagent this mod spawned may not do: write outside its step's
916
971
  // access, or, answering into a file, anything but that.
917
972
  function subagentDenial(agentId: string, e: Record<string, unknown>) {
@@ -982,8 +1037,13 @@ export const register: Register = (on) => {
982
1037
  void launchPending($)
983
1038
  }
984
1039
  if (e.agentId && ours.has(e.agentId)) {
985
- const end: TurnEnd =
986
- e.reason === "answer" ? { ok: true } : { ok: false, why: `agent turn ended: ${e.reason}` }
1040
+ const stopped = contextStops.get(e.agentId)
1041
+ contextStops.delete(e.agentId)
1042
+ const end: TurnEnd = stopped
1043
+ ? { ok: false, why: stopped }
1044
+ : e.reason === "answer"
1045
+ ? { ok: true }
1046
+ : { ok: false, why: `agent turn ended: ${e.reason}` }
987
1047
  delegates.delete(e.agentId)
988
1048
  const resolve = waiting.get(e.agentId)
989
1049
  waiting.delete(e.agentId)
@@ -1014,6 +1074,9 @@ export const register: Register = (on) => {
1014
1074
  return next(e)
1015
1075
  })
1016
1076
 
1077
+ on("classic.PostToolUse", async ($, e, next) => (await contextGuard($, e)) ?? next(e))
1078
+ on("session.compact", async ($, e, next) => (await compactGuard($, e.agentId)) ?? next(e))
1079
+
1017
1080
  // A subagent's hand-back arrives as a prompt to the main session, whose model
1018
1081
  // would then act on it. gtd reads the turn's result from git instead.
1019
1082
  on("prompt.submit", async ($, e, next) => {
@@ -1,6 +1,6 @@
1
1
  import { accessFor, agent, answered, changesSince, check, codeThreads, defineWorkflow, displayPath, env, head, human, judge, numeric, quote, read, refuse, scope, skillsFor, start, vars } from "../flows.mjs";
2
2
  import { escalation as escalation$1, gate as gate$1, healthy as healthy$1, lensScopes, lensesOf, removedAssertions } from "../workflow-kit.mjs";
3
- import { c as String$, g as standardSchemaV1, h as optionalWith, l as Struct, n as Boolean$ } from "./Schema-DO5ttJaI.mjs";
3
+ import { c as String$, g as standardSchemaV1, h as optionalWith, l as Struct, n as Boolean$, s as Number$ } from "./Schema-DO5ttJaI.mjs";
4
4
  //#region src/workflows/shared/registry.ts
5
5
  const FILL = "";
6
6
  const commentLength = (rest) => {
@@ -635,9 +635,11 @@ ${strays.map((path) => ` - \`${displayPath(path)}\``).join("\n")}
635
635
  - Introduce no new flag read and add no second read of an existing one:
636
636
  every match of \`${pattern}\` may move, never increase in number`;
637
637
  const withRules = (rules) => rules === void 0 ? "" : `${rules}\n`;
638
- const refactorBuildingPrompt = ({ pkg, strays, pattern, reference, feedback = false }) => `${stateFileRules}
638
+ const refactorBuildingPrompt = ({ pkg, strays, pattern, reference, feedback = false, note = false }) => `${stateFileRules}
639
639
  - The package to implement is \`${pkg}\`; never delete it
640
- ${feedback ? `- The package carries a \`## Review feedback\` section: a human's review of the
640
+ ${note ? `- The package's \`## Refactor note\` section names the seams to open and the
641
+ shape each takes, written from the whole naive pass: build exactly those
642
+ ` : ""}${feedback ? `- The package carries a \`## Review feedback\` section: a human's review of the
641
643
  last refactor. Act on it, within the rules below
642
644
  ` : ""}${refactorRules(strays, pattern)}
643
645
  ${reference === void 0 ? "" : `- Reference only: \`git show ${reference} -- ${strays.map(quote).join(" ")}\` shows an
@@ -1256,21 +1258,50 @@ ${criteriaBinding(false)}
1256
1258
  - Leave \`.gtd/\` untouched
1257
1259
  - Leave everything uncommitted and finish your turn — do not commit
1258
1260
  ${retry === void 0 ? "" : naiveRetry(retry)}`;
1259
- const implementBuildingPrompt = (reference, tier, amend, afterRefactor, flagMode = true) => `${stateFileRules}
1261
+ const carries = (rebuild) => (rebuild.acceptance?.ids.length ?? 0) > 0;
1262
+ const rebuildLines = ({ note, acceptance }) => `${note === void 0 ? "" : `- The refactor note (\`.gtd/REFACTOR-NOTE.md\`) named the seams the refactor
1263
+ opened; plug the change into them:
1264
+
1265
+ ${note.trimEnd().split("\n").map((line) => line === "" ? "" : ` > ${line}`).join("\n")}
1266
+
1267
+ `}${acceptance === void 0 || !carries({ acceptance }) ? "" : `- Carry over the naive pass's acceptance tests for ${acceptance.ids.join(", ")}, the
1268
+ one part of it you take as it is: \`git show ${acceptance.hash} -- ${acceptance.tests.map(quote).join(" ")}\`.
1269
+ Copy each test with its \`[AC-<n>]\` name into the tree as the refactor left
1270
+ it — check a file out of the tip whole only where the refactor did not touch
1271
+ it — changing only what reaches the release flag. A turn whose changed tests
1272
+ leave an ID out is refused, and the suite must be green with them
1273
+ `}`;
1274
+ const refactorNotePrompt = (reference, strays) => `${stateFileRules}
1275
+ - The seam judge ruled the naive pass refactor-first: it is never applied.
1276
+ A behaviour-neutral refactor lands first, then the change is rebuilt on
1277
+ the refactored base. Both turns read the note you write
1278
+ - Read the whole naive pass: \`git show ${reference}\`. It changed these
1279
+ existing files outside the allowlist:
1280
+ ${listed(strays)}
1281
+ - Write \`.gtd/REFACTOR-NOTE.md\`: which seams the refactor opens (the existing
1282
+ code to restructure, by path and symbol) and the shape each takes — the
1283
+ signatures and where the new behaviour will plug in. Name only the files
1284
+ listed above: the refactor may change no other path. Never the feature's own
1285
+ behaviour, and no flag read: the refactor adds neither
1286
+ - Change nothing else — a turn that does is refused
1287
+ - Leave everything uncommitted and finish your turn — do not commit
1288
+ `;
1289
+ const afterRefactorLine = (rebuild) => `- A reviewed refactor has just landed and may already carry part of the change,
1290
+ though never a release flag. Build only what is still missing, held to every
1291
+ rule below; if nothing is, ${carries(rebuild) ? "carry over the acceptance tests below and change nothing else" : "change nothing and finish your turn"}
1292
+ `;
1293
+ const implementBuildingPrompt = (reference, tier, amend, afterRefactor, flagMode = true, rebuild = {}) => `${stateFileRules}
1260
1294
  - Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
1261
1295
  ${criteriaBinding(true)}
1262
- ${afterRefactor === true ? `- A reviewed refactor has just landed and may already carry part of the change,
1263
- though never a release flag. Build only what is still missing, held to every
1264
- rule below; if nothing is, change nothing and finish your turn
1265
- ` : ""}${amend === true ? `- The change already exists in the tree from an earlier lap; amend it to meet
1296
+ ${afterRefactor === true ? afterRefactorLine(rebuild) : ""}${amend === true ? `- The change already exists in the tree from an earlier lap; amend it to meet
1266
1297
  the re-derived requirements instead of rewriting it
1267
1298
  ` : flagMode ? `- Implement them properly, fitting the codebase's seams and conventions
1268
1299
  ` : `- Implement them the quickest way that works — the naive version. Do not
1269
1300
  restructure, abstract or refactor around the change
1270
1301
  `}${reference === void 0 || amend === true ? "" : `- Reference only: \`git show ${reference}\` shows an earlier naive attempt.
1271
1302
  Read it for ideas; never apply, cherry-pick or check it out — write
1272
- the change fresh
1273
- `}${!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
1303
+ the change fresh${carries(rebuild) ? ", its acceptance tests excepted (below)" : ""}
1304
+ `}${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
1274
1305
  repository's flag conventions`}
1275
1306
  - Write the tests first, then the code that makes them pass. Name each
1276
1307
  criterion's ID in the test that covers it (\`[AC-3] …\`): once the suite is
@@ -1379,6 +1410,7 @@ const ESCALATION = ".gtd/ESCALATION.md";
1379
1410
  const REQUIREMENTS = ".gtd/REQUIREMENTS.md";
1380
1411
  const REVIEW = ".gtd/REVIEW.md";
1381
1412
  const REFACTOR = ".gtd/REFACTOR.md";
1413
+ const REFACTOR_NOTE = ".gtd/REFACTOR-NOTE.md";
1382
1414
  const QUALITY = ".gtd/QUALITY.md";
1383
1415
  const planner = () => env.plannerModel ?? "";
1384
1416
  const coder = () => env.coderModel ?? "";
@@ -1578,7 +1610,11 @@ const ShippedOptions = Struct({
1578
1610
  testCommand: setting,
1579
1611
  plannerModel: setting,
1580
1612
  coderModel: setting,
1581
- flagMode: optionalWith(Boolean$, { exact: true })
1613
+ flagMode: optionalWith(Boolean$, { exact: true }),
1614
+ contextLimit: optionalWith(Struct({
1615
+ soft: optionalWith(Number$, { exact: true }),
1616
+ hard: optionalWith(Number$, { exact: true })
1617
+ }), { exact: true })
1582
1618
  });
1583
1619
  const over = (base, options) => ({
1584
1620
  ...base,
@@ -1590,12 +1626,13 @@ const flagModeOn = () => {
1590
1626
  if (value !== "true" && value !== "false") refuse(`gtd: flagMode must be "true" or "false", got ${JSON.stringify(value)}`);
1591
1627
  return value === "true";
1592
1628
  };
1629
+ const limited = (flow, context) => context === void 0 ? flow : () => scope({ context }, flow);
1593
1630
  /** A shipped flow as a factory: its options override the settings it declares, each in its own kind. */
1594
1631
  const shipped = (flow, doors, base) => defineWorkflow(standardSchemaV1(ShippedOptions, {
1595
1632
  onExcessProperty: "error",
1596
1633
  errors: "all"
1597
- }), (options) => ({
1598
- flow,
1634
+ }), ({ contextLimit, ...options }) => ({
1635
+ flow: limited(flow, contextLimit),
1599
1636
  defaults: over(defaults, options),
1600
1637
  envDefaults: over(envDefaults, options),
1601
1638
  doors,
@@ -1608,14 +1645,14 @@ const FlagOptions = Struct({
1608
1645
  const flagShipped = (flow, name) => defineWorkflow(standardSchemaV1(FlagOptions, {
1609
1646
  onExcessProperty: "error",
1610
1647
  errors: "all"
1611
- }), (options) => {
1648
+ }), ({ contextLimit, ...options }) => {
1612
1649
  const flagDefaults = {
1613
1650
  ...defaults,
1614
1651
  flag: "",
1615
1652
  flagRegistry: defaultFlagRegistry
1616
1653
  };
1617
1654
  return {
1618
- flow,
1655
+ flow: limited(flow, contextLimit),
1619
1656
  defaults: over(flagDefaults, options),
1620
1657
  envDefaults: over(envDefaults, options),
1621
1658
  doors: { [name]: {
@@ -1673,4 +1710,4 @@ const steering = {
1673
1710
  [REVIEW]: "review"
1674
1711
  };
1675
1712
  //#endregion
1676
- export { qualityUnresolvedMessage as $, buildReviewCollectingPrompt as A, deferNotesScript as B, planner as C, agentWithSkills as D, reviewing as E, clearCriteriaQuestion as F, fixReviewNotesPrompt as G, designSystem as H, coverageMessage as I, implementBuildingPrompt as J, handOffAwaitReviewMessage as K, coverageQuestion as L, buildReviewTriageMessage as M, builderSystem as N, buildReviewAnswerQuestionsPrompt as O, clearCriteriaMessage as P, normaliseCriteriaScript as Q, coveringPrompt as R, fixRisks as S, requireFlag as St, reviewQuality as T, designTriagePrompt as U, designGateAnswerMessage as V, finisherSystem as W, naiveApplyFailure as X, implementFixPrompt as Y, naivePassPrompt as Z, REVIEW as _, testFileGlobs as _t, flagModeOn as a, reviewEditsCapture as at, fixCheck as b, flipProblems as bt, baseline as c, reviewerSystem as ct, guarded as d, startGateBlockedMessage as dt, refactorBuildingPrompt as et, healthy as f, uncoveredMessage as ft, REQUIREMENTS as g, envDefaults as gt, REFACTOR as h, defaults as ht, skills as i, reviewEditNotesCapture as it, buildReviewFixNitsPrompt as j, buildReviewAwaitAfterDeferMessage as k, escalation as l, seamByHandMessage as lt, QUALITY as m, unwindFailure as mt, summary as n, refactorFixPrompt as nt, flagShipped as o, reviewGateBlockedMessage as ot, FEEDBACK as p, unwindFailedMessage as pt, idleMessage as q, access as r, refactorRules as rt, shipped as s, reviewNotesCapture as st, steering as t, refactorDroppedRefusal as tt, gate as u, seamSteerMessage as ut, awaitReview as v, flagMentions as vt, reviewMissing as w, fixQuality as x, registryEntry as xt, coder as y, flagReferences as yt, criterionLine as z };
1713
+ export { normaliseCriteriaScript as $, buildReviewAwaitAfterDeferMessage as A, criterionLine as B, fixRisks as C, registryEntry as Ct, reviewing as D, reviewQuality as E, clearCriteriaMessage as F, finisherSystem as G, designGateAnswerMessage as H, clearCriteriaQuestion as I, idleMessage as J, fixReviewNotesPrompt as K, coverageMessage as L, buildReviewFixNitsPrompt as M, buildReviewTriageMessage as N, agentWithSkills as O, builderSystem as P, naivePassPrompt as Q, coverageQuestion as R, fixQuality as S, flipProblems as St, reviewMissing as T, designSystem as U, deferNotesScript as V, designTriagePrompt as W, implementFixPrompt as X, implementBuildingPrompt as Y, naiveApplyFailure as Z, REQUIREMENTS as _, defaults as _t, flagModeOn as a, refactorRules as at, coder as b, flagMentions as bt, baseline as c, reviewGateBlockedMessage as ct, guarded as d, seamByHandMessage as dt, qualityUnresolvedMessage as et, healthy as f, seamSteerMessage as ft, REFACTOR_NOTE as g, unwindFailure as gt, REFACTOR as h, unwindFailedMessage as ht, skills as i, refactorNotePrompt as it, buildReviewCollectingPrompt as j, buildReviewAnswerQuestionsPrompt as k, escalation as l, reviewNotesCapture as lt, QUALITY as m, uncoveredMessage as mt, summary as n, refactorDroppedRefusal as nt, flagShipped as o, reviewEditNotesCapture as ot, FEEDBACK as p, startGateBlockedMessage as pt, handOffAwaitReviewMessage as q, access as r, refactorFixPrompt as rt, shipped as s, reviewEditsCapture as st, steering as t, refactorBuildingPrompt as tt, gate as u, reviewerSystem as ut, REVIEW as v, envDefaults as vt, planner as w, requireFlag as wt, fixCheck as x, flagReferences as xt, awaitReview as y, testFileGlobs as yt, coveringPrompt as z };
@@ -1,6 +1,6 @@
1
1
  import { read, refuse, removeScript, run, scope, showScript, vars } from "../flows.mjs";
2
2
  import { fixFindings, lensEntriesOf, lensOf, qualityLap as qualityLap$1, reviewToHandOff } from "../workflow-kit.mjs";
3
- import { E as reviewing, K as handOffAwaitReviewMessage, S as fixRisks, T as reviewQuality, _ as REVIEW, b as fixCheck, d as guarded, f as healthy, l as escalation, m as QUALITY, v as awaitReview, w as reviewMissing, x as fixQuality } from "./reserved-BAv71I4i.mjs";
3
+ import { C as fixRisks, D as reviewing, E as reviewQuality, S as fixQuality, T as reviewMissing, d as guarded, f as healthy, l as escalation, m as QUALITY, q as handOffAwaitReviewMessage, v as REVIEW, x as fixCheck, y as awaitReview } from "./reserved-zAfoJtR7.mjs";
4
4
  //#region src/workflows/shared/tail.ts
5
5
  /** The reviewer's own `.gtd/REVIEW.md`, kept beside the human's notes so `fix` can tell them apart. */
6
6
  const REVIEW_ORIG = ".gtd/REVIEW.orig.md";
@@ -116,6 +116,7 @@ const statusOf = (input) => {
116
116
  initial,
117
117
  model: rendered.model,
118
118
  system: rendered.system !== void 0 && rendered.system !== "" ? rendered.system : void 0,
119
+ context: rendered.context,
119
120
  log,
120
121
  state: rendered.state,
121
122
  actor: rendered.actor,
@@ -169,7 +170,7 @@ const dispatchFieldsOf = (demand) => {
169
170
  }
170
171
  };
171
172
  /**
172
- * Flatten a `Demand` plus its `BeatStatus` into the single 25-key document
173
+ * Flatten a `Demand` plus its `BeatStatus` into the single 26-key document
173
174
  * `gtd next --json` emits — the ONLY place the two are joined. `cost`/
174
175
  * `costByModel` are omitted together, exactly when no cost was recorded
175
176
  * (`cost <= 0`).
@@ -186,6 +187,7 @@ const beatDocument = (demand, status) => {
186
187
  session,
187
188
  model: status.model,
188
189
  system: status.system,
190
+ context: status.context,
189
191
  validate,
190
192
  log: status.log,
191
193
  state: status.state,
@@ -488,4 +490,117 @@ const slicesDocument = (slices) => ({
488
490
  });
489
491
  const renderSlicesJson = (document) => JSON.stringify(document) + "\n";
490
492
  //#endregion
491
- export { WIRE_SCHEMA as A, EXIT_RUNTIME_ERROR as C, EXIT_USAGE_ERROR as D, EXIT_STRAYS_FOUND as E, ACCESS_REFUSAL as O, EXIT_OK as S, EXIT_SIGTERM as T, BEAT_KINDS as _, selectPath as a, stallDiagnosis as b, landProseText as c, renderLandPlain as d, DEMAND_BRIEFING as f, statusOf as g, renderBeatJson as h, resolveSession as i, renderFormat as j, UNATTRIBUTED_MODEL as k, noopText as l, beatDocument as m, slicesDocument as n, judgeJson as o, renderBeatPlain as p, globMatches as r, landFields as s, renderSlicesJson as t, renderLandJson as u, beatKindOf as v, EXIT_SIGINT as w, EXIT_CODES as x, demandOf as y };
493
+ //#region src/wire/ContextLimit.ts
494
+ const DEFAULT_CONTEXT_LIMIT = {
495
+ soft: .6,
496
+ hard: .7
497
+ };
498
+ const DEFAULT_WINDOW = 2e5;
499
+ const LONG_WINDOW = 1e6;
500
+ /** Claude Code's `[1m]` suffix selects the long window; a transcript never names it, so the driver decides from the model it passed. */
501
+ const contextWindowFor = (model) => model !== void 0 && /\[1m\]/i.test(model) ? LONG_WINDOW : DEFAULT_WINDOW;
502
+ /** A positive whole number of tokens, or `undefined` for anything else. */
503
+ const parseContextWindow = (raw) => {
504
+ const n = Number(raw);
505
+ return raw !== void 0 && raw.trim() !== "" && Number.isInteger(n) && n > 0 ? n : void 0;
506
+ };
507
+ /**
508
+ * Where a driver asks `gtd context-hook` to record a hard stop's `stopReason`:
509
+ * Claude Code prints it nowhere a driver reads, and `hook_stopped` alone would
510
+ * also name any other hook that stopped the turn.
511
+ */
512
+ const CONTEXT_STOP_FILE_ENV = "GTD_LOOP_CONTEXT_STOP_FILE";
513
+ /**
514
+ * Why one layer of a limit (a scope's, a step's) is malformed, ignoring how its
515
+ * two shares relate: layers merge key by key, so only the merged limit has to
516
+ * keep `soft` below `hard`. Config is evaluated untyped, so anything may arrive.
517
+ */
518
+ const contextShapeFault = (limit) => {
519
+ if (typeof limit !== "object" || limit === null || Array.isArray(limit)) return `context must be an object { soft, hard }, got ${JSON.stringify(limit)}`;
520
+ const unknown = Object.keys(limit).filter((key) => key !== "soft" && key !== "hard");
521
+ if (unknown.length > 0) return `unknown key(s) ${unknown.join(", ")} in context`;
522
+ const shares = limit;
523
+ for (const key of ["soft", "hard"]) {
524
+ const value = shares[key];
525
+ if (value === void 0) continue;
526
+ if (typeof value !== "number" || !(value > 0 && value <= 1)) return `context.${key} must be a share of the context window in (0, 1], got ${JSON.stringify(value)}`;
527
+ }
528
+ };
529
+ /** Why a whole limit is unusable, or `undefined` when it is fine. */
530
+ const contextLimitFault = (limit) => {
531
+ const shape = contextShapeFault(limit);
532
+ if (shape !== void 0) return shape;
533
+ const shares = limit;
534
+ const soft = shares.soft ?? DEFAULT_CONTEXT_LIMIT.soft;
535
+ const hard = shares.hard ?? DEFAULT_CONTEXT_LIMIT.hard;
536
+ return soft < hard ? void 0 : `context.soft (${soft}) must be below context.hard (${hard})`;
537
+ };
538
+ const record = (value) => typeof value === "object" && value !== null ? value : void 0;
539
+ const count = (value) => typeof value === "number" && value > 0 ? value : 0;
540
+ /** What a message's usage puts in the context window: its whole input, cached or not. */
541
+ const usageTokens = (usage) => {
542
+ const u = record(usage);
543
+ if (u === void 0) return void 0;
544
+ return count(u["input_tokens"]) + count(u["cache_read_input_tokens"]) + count(u["cache_creation_input_tokens"]);
545
+ };
546
+ /**
547
+ * The context size of the latest assistant message in a Claude Code transcript
548
+ * (one JSON entry per line). Usage is per message, not a running sum, so the
549
+ * last entry alone is the window's fill.
550
+ */
551
+ const transcriptTokens = (transcript) => {
552
+ const lines = transcript.split("\n");
553
+ for (let i = lines.length - 1; i >= 0; i--) {
554
+ const line = lines[i].trim();
555
+ if (line === "") continue;
556
+ let entry;
557
+ try {
558
+ entry = JSON.parse(line);
559
+ } catch {
560
+ continue;
561
+ }
562
+ if (record(entry)?.["type"] !== "assistant") continue;
563
+ const tokens = usageTokens(record(record(entry)?.["message"])?.["usage"]);
564
+ if (tokens !== void 0) return tokens;
565
+ }
566
+ };
567
+ /**
568
+ * A worker subagent's own transcript. A hook its tool call fires is handed the
569
+ * MAIN session's `transcript_path`; the worker's sits beside it.
570
+ */
571
+ const subagentTranscriptPath = (transcriptPath, agentId) => `${transcriptPath.replace(/\.jsonl$/, "")}/subagents/agent-${agentId}.jsonl`;
572
+ /** Every hard stop's `stopReason` starts with this: the driver's context-limit marker. */
573
+ const CONTEXT_LIMIT_MARKER = "gtd: context limit";
574
+ const percent = (share) => `${Math.round(share * 100)}%`;
575
+ /**
576
+ * What a hook answers, or `undefined` to let the run go on. Compaction about
577
+ * to happen means the limit was missed, so `PreCompact` is a hard stop whatever
578
+ * the count says.
579
+ */
580
+ const contextHook = (input) => {
581
+ const { event, tokens, window, limit } = input;
582
+ if (event === "PreCompact") return {
583
+ kind: "hard",
584
+ stopReason: `${CONTEXT_LIMIT_MARKER}: the context is about to be compacted — the run is too big for one turn`
585
+ };
586
+ if (tokens === void 0) return void 0;
587
+ const used = `${tokens} of ${window} tokens`;
588
+ if (tokens >= limit.hard * window) return {
589
+ kind: "hard",
590
+ stopReason: `${CONTEXT_LIMIT_MARKER}: ${used}, past the ${percent(limit.hard)} hard limit`
591
+ };
592
+ if (tokens >= limit.soft * window) return {
593
+ kind: "soft",
594
+ reason: `Context limit: ${used} used, past the ${percent(limit.soft)} soft limit. Stop working now. Do not start anything new; end your turn with a short handoff note as your answer: what you touched, and what was next.`
595
+ };
596
+ };
597
+ /** A decision as Claude Code's settings-hook JSON output. */
598
+ const hookOutput = (decision) => decision.kind === "soft" ? {
599
+ decision: "block",
600
+ reason: decision.reason
601
+ } : {
602
+ continue: false,
603
+ stopReason: decision.stopReason
604
+ };
605
+ //#endregion
606
+ export { beatKindOf as A, ACCESS_REFUSAL as B, renderLandPlain as C, renderBeatJson as D, beatDocument as E, EXIT_RUNTIME_ERROR as F, WIRE_SCHEMA as H, EXIT_SIGINT as I, EXIT_SIGTERM as L, stallDiagnosis as M, EXIT_CODES as N, statusOf as O, EXIT_OK as P, EXIT_STRAYS_FOUND as R, renderLandJson as S, renderBeatPlain as T, renderFormat as U, UNATTRIBUTED_MODEL as V, selectPath as _, contextLimitFault as a, landProseText as b, hookOutput as c, transcriptTokens as d, usageTokens as f, resolveSession as g, globMatches as h, contextHook as i, demandOf as j, BEAT_KINDS as k, parseContextWindow as l, slicesDocument as m, CONTEXT_STOP_FILE_ENV as n, contextShapeFault as o, renderSlicesJson as p, DEFAULT_CONTEXT_LIMIT as r, contextWindowFor as s, CONTEXT_LIMIT_MARKER as t, subagentTranscriptPath as u, judgeJson as v, DEMAND_BRIEFING as w, noopText as x, landFields as y, EXIT_USAGE_ERROR as z };