@pmelab/gtd 36.0.0 → 38.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +30 -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-sfMdy7v7.mjs} +83 -22
  7. package/dist/chunks/{tail-kar1zhOb.mjs → tail-Cgca5Gp-.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 +277 -58
  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 +355 -75
  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/testkit/steps.ts +35 -0
  29. package/src/wire/BeatDocument.ts +12 -2
  30. package/src/wire/BeatStatus.ts +3 -0
  31. package/src/wire/ContextLimit.fixture.ts +30 -0
  32. package/src/wire/ContextLimit.test.ts +96 -0
  33. package/src/wire/ContextLimit.ts +169 -0
  34. package/src/wire/index.ts +1 -0
  35. package/src/wire/types.ts +7 -0
  36. package/src/workflow/Workflow.ts +9 -1
  37. package/src/workflows/feature/context-limit.feature +37 -0
  38. package/src/workflows/feature/coverage.ts +22 -8
  39. package/src/workflows/feature/criteria.test.ts +17 -1
  40. package/src/workflows/feature/criteria.ts +26 -3
  41. package/src/workflows/feature/feature.ts +69 -16
  42. package/src/workflows/feature/index.ts +1 -0
  43. package/src/workflows/feature/refactor-absorbs.feature +12 -5
  44. package/src/workflows/feature/refactor-first.feature +11 -2
  45. package/src/workflows/feature/refactor-lens-models.feature +4 -0
  46. package/src/workflows/feature/refactor-rebuild.feature +269 -0
  47. package/src/workflows/feature/refactor-review.feature +45 -1
  48. package/src/workflows/feature/refactor.ts +9 -3
  49. package/src/workflows/feature/seam.test.ts +108 -3
  50. package/src/workflows/feature/seam.ts +169 -31
  51. package/src/workflows/feature/slicing-cap.feature +14 -0
  52. package/src/workflows/feature/slicing.feature +225 -0
  53. package/src/workflows/feature/slicing.test.ts +49 -0
  54. package/src/workflows/feature/slicing.ts +105 -0
  55. package/src/workflows/index.ts +1 -0
  56. package/src/workflows/shared/fences.ts +5 -0
  57. package/src/workflows/shared/options.test.ts +11 -2
  58. package/src/workflows/shared/options.ts +19 -4
  59. package/src/workflows/shared/steps.ts +2 -0
  60. package/src/workflows/shared/text.ts +129 -28
  61. package/src/workflows/shared/vars.ts +1 -0
  62. package/src/workflows/shared/workflows.steps.ts +13 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "36.0.0",
3
+ "version": "38.0.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
@@ -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
  >() {}
package/src/edge/Edge.ts CHANGED
@@ -54,6 +54,7 @@ import {
54
54
  type ModelCost,
55
55
  type SliceEntry,
56
56
  type StepAccess,
57
+ type StepContextLimit,
57
58
  } from "../wire/index.js"
58
59
 
59
60
  import { GTD_VERSION } from "../install/index.js"
@@ -722,6 +723,13 @@ type RequestOf<K extends ReachedStep["request"]["kind"]> = Extract<
722
723
  { kind: K }
723
724
  >
724
725
 
726
+ const stepContext = (
727
+ context: { readonly soft?: number | undefined; readonly hard?: number | undefined } | undefined,
728
+ ): StepContextLimit | undefined =>
729
+ context === undefined
730
+ ? undefined
731
+ : { ...optional("soft", context.soft), ...optional("hard", context.hard) }
732
+
725
733
  const promptDef = (
726
734
  common: StepCommon,
727
735
  request: RequestOf<"agent">,
@@ -735,6 +743,7 @@ const promptDef = (
735
743
  ...optional("system", request.options.system),
736
744
  ...optional("allowEmpty", request.options.allowEmpty),
737
745
  ...optional("keep", request.options.keep),
746
+ ...optional("context", stepContext(request.options.context)),
738
747
  ...optional("skills", skills),
739
748
  ...optional("access", access),
740
749
  })
@@ -850,6 +859,7 @@ export interface RestHints {
850
859
  readonly mode?: string
851
860
  readonly skills?: readonly string[]
852
861
  readonly access?: StepAccess
862
+ readonly context?: StepContextLimit
853
863
  }
854
864
 
855
865
  const hintsOf = (def: StepDef): RestHints => ({
@@ -861,6 +871,7 @@ const hintsOf = (def: StepDef): RestHints => ({
861
871
  ...optional("mode", def.mode),
862
872
  ...optional("skills", def.skills),
863
873
  ...optional("access", def.access),
874
+ ...optional("context", def.context),
864
875
  })
865
876
 
866
877
  const normalizeStatus = (raw: string): ChangeStatus => (raw === "A" ? "A" : raw === "D" ? "D" : "M")
@@ -16,9 +16,18 @@ export interface SteeringOptions {
16
16
  readonly base?: string | undefined
17
17
  }
18
18
 
19
+ /** Shares of the model's context window at which a driver stops the turn — `gtd next --json`'s `context`. */
20
+ export interface ContextLimitOptions {
21
+ /** Ask the agent to wrap up with a handoff note. Driver default 0.6. */
22
+ readonly soft?: number | undefined
23
+ /** Stop the turn outright. Driver default 0.7. */
24
+ readonly hard?: number | undefined
25
+ }
26
+
19
27
  export interface AgentOptions extends SteeringOptions {
20
28
  readonly model?: string | undefined
21
29
  readonly system?: string | undefined
30
+ readonly context?: ContextLimitOptions | undefined
22
31
  /**
23
32
  * A turn that changes nothing completes the step. Without this an empty turn
24
33
  * is an attempt: recorded, but the process stays at the step (a stall).
@@ -115,6 +124,8 @@ export interface ScopeOptions {
115
124
  readonly model?: string | undefined
116
125
  /** The system prompt every agent step inside runs with, unless it sets its own. */
117
126
  readonly system?: string | undefined
127
+ /** The context limit every agent step inside runs with; a nested scope's or a step's own replaces it key by key. */
128
+ readonly context?: ContextLimitOptions | undefined
118
129
  /** The skill names every agent step inside declares — `gtd next --json`'s `skills`. A nested scope inherits it unless it sets its own. */
119
130
  readonly skills?: readonly string[] | undefined
120
131
  /** The file access every agent step inside runs with — `gtd next --json`'s `access`. A nested scope inherits it; its own replaces it wholesale. */
@@ -8,6 +8,7 @@ import {
8
8
  type ScopeAccess,
9
9
  type AccessDef,
10
10
  type ScopeOptions,
11
+ type ContextLimitOptions,
11
12
  type StepRequest,
12
13
  type Flow,
13
14
  type Mark,
@@ -21,7 +22,7 @@ import {
21
22
  unansweredQuestions,
22
23
  } from "../steering/index.js"
23
24
  import { accessShapeFault, foldAccess, sameAccess } from "./Access.js"
24
- import { globMatches } from "../wire/index.js"
25
+ import { contextLimitFault, contextShapeFault, globMatches } from "../wire/index.js"
25
26
  import { refNameFault } from "./RefName.js"
26
27
  import { IMPLICIT_SLICE, SLICE_NAME, type SliceSpan } from "./Slices.js"
27
28
  import { diffTrees, isEmptyDiff, type TreeView } from "./Tree.js"
@@ -207,6 +208,28 @@ const budgeted = (
207
208
  return { evidence: bounded, truncated }
208
209
  }
209
210
 
211
+ // The innermost of `chain`, named by its full path.
212
+ const scopeLabel = (chain: readonly ScopeOptions[]): string => {
213
+ const path = chain.flatMap((o) => (o.name === undefined ? [] : [o.name])).join(".")
214
+ if (chain.at(-1)?.name !== undefined) return `scope "${path}"`
215
+ return path === "" ? "an unnamed scope" : `an unnamed scope inside "${path}"`
216
+ }
217
+
218
+ // Key by key, innermost last: a scope or step that sets only `hard` keeps the outer `soft`.
219
+ const inheritedContext = (
220
+ layers: readonly (ContextLimitOptions | undefined)[],
221
+ ): ContextLimitOptions | undefined =>
222
+ layers.reduce<ContextLimitOptions | undefined>(
223
+ (merged, layer) =>
224
+ layer === undefined
225
+ ? merged
226
+ : {
227
+ ...merged,
228
+ ...Object.fromEntries(Object.entries(layer).filter(([, value]) => value !== undefined)),
229
+ },
230
+ undefined,
231
+ )
232
+
210
233
  interface Identity {
211
234
  readonly model: string | undefined
212
235
  readonly system: string | undefined
@@ -243,7 +266,7 @@ const STEERING_OPTIONS = ["file", "mode", "label", "base"]
243
266
  // The option keys each step accepts. A gtd.config.ts is evaluated without a
244
267
  // type check, so a misspelt key would otherwise be silently ignored.
245
268
  const KNOWN_OPTIONS: Readonly<Record<StepKind, ReadonlySet<string>>> = {
246
- agent: new Set([...STEERING_OPTIONS, "model", "system", "allowEmpty", "keep"]),
269
+ agent: new Set([...STEERING_OPTIONS, "model", "system", "context", "allowEmpty", "keep"]),
247
270
  human: new Set([...STEERING_OPTIONS, "message", "acceptClean", "approve"]),
248
271
  run: new Set(STEERING_OPTIONS),
249
272
  judge: new Set([...STEERING_OPTIONS, "message"]),
@@ -426,11 +449,13 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
426
449
  if (s.model !== undefined) persona.model = s.model
427
450
  if (s.system !== undefined) persona.system = s.system
428
451
  }
452
+ const context = inheritedContext([...scopes.map((s) => s.context), request.options.context])
429
453
  return {
430
454
  ...request,
431
455
  options: {
432
456
  ...persona,
433
457
  ...request.options,
458
+ ...(context === undefined ? {} : { context }),
434
459
  },
435
460
  }
436
461
  }
@@ -575,6 +600,28 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
575
600
  return landPending(step, input.pending)
576
601
  }
577
602
 
603
+ // Each layer on its own first, so a bad scope or workflow option is named as
604
+ // such rather than blamed on the step it reached; then the merged limit.
605
+ const scopeContextFault = (): string | undefined => {
606
+ for (const [i, s] of scopes.entries()) {
607
+ const fault = s.context === undefined ? undefined : contextShapeFault(s.context)
608
+ if (fault !== undefined)
609
+ return `the context of ${scopeLabel(scopes.slice(0, i + 1))}: ${fault}`
610
+ }
611
+ return undefined
612
+ }
613
+
614
+ const resolvedContextFault = (
615
+ request: Extract<StepRequest, { kind: "agent" }>,
616
+ ): string | undefined => {
617
+ const own = request.options.context
618
+ const layered = scopeContextFault() ?? (own === undefined ? undefined : contextShapeFault(own))
619
+ if (layered !== undefined) return layered
620
+ const resolved = resolve(request)
621
+ const context = resolved.kind === "agent" ? resolved.options.context : undefined
622
+ return context === undefined ? undefined : contextLimitFault(context)
623
+ }
624
+
578
625
  // gtd.config.ts is loaded without a type check.
579
626
  const requestFault = (request: Exclude<StepRequest, { kind: "restart" }>): string | undefined => {
580
627
  if (request.kind === "run" && typeof request.body !== "string") {
@@ -582,6 +629,8 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
582
629
  }
583
630
  if (request.kind !== "agent") return undefined
584
631
  const name = scoped(request.name)
632
+ const contextFault = resolvedContextFault(request)
633
+ if (contextFault !== undefined) return `gtd: step "${name}": ${contextFault}`
585
634
  if (request.options.keep === true) {
586
635
  // A clean allowEmpty keep would land an ordinary commit with no Gtd-Kept: a silent skip.
587
636
  if (request.options.allowEmpty === true) {
@@ -206,6 +206,7 @@ const missingToolCommands: ToolCommands["Type"] = {
206
206
  ui: missingTool("ui"),
207
207
  lsp: missingTool("lsp"),
208
208
  loop: missingTool("loop"),
209
+ contextHook: missingTool("context-hook"),
209
210
  judge: () =>
210
211
  Promise.reject(
211
212
  new Error("gtd judge run: ToolCommands has no test double wired into testLayers()"),
@@ -26,6 +26,8 @@ export class TestkitWorld extends QuickPickleWorld {
26
26
  /** Per step, what its scripted actor does, in the order the scenario declared it. */
27
27
  actions = new Map<string, Actor[]>()
28
28
  verdicts = new Map<string, string>()
29
+ /** Per judge step, a verdict for each text its questions may contain; the first match answers. */
30
+ conditionalVerdicts = new Map<string, [text: string, verdict: string][]>()
29
31
  private created: TestRepo | undefined = undefined
30
32
 
31
33
  // The repository is created on first use and keeps the config and
@@ -79,6 +81,19 @@ export class TestkitWorld extends QuickPickleWorld {
79
81
  }
80
82
  actors[step] = () => verdict
81
83
  }
84
+ for (const [step, choices] of this.conditionalVerdicts) {
85
+ if (this.actions.has(step) || this.verdicts.has(step)) {
86
+ throw new Error(`step "${step}" has a conditional verdict and another script`)
87
+ }
88
+ // Matched against the pending questions, which `gtd judge` prints and the beat does not.
89
+ actors[step] = async (_, repo) => {
90
+ const { stdout } = await repo.gtd("judge")
91
+ const hit = choices.find(([text]) => stdout.includes(text))
92
+ if (hit === undefined)
93
+ throw new Error(`judge step "${step}" matched no verdict:\n${stdout}`)
94
+ return hit[1]
95
+ }
96
+ }
82
97
  await this.repo().drive(actors)
83
98
  }
84
99
  }
@@ -137,6 +152,16 @@ Given(
137
152
  },
138
153
  )
139
154
 
155
+ // Lets one step write differently per visit, told apart by what it is prompted with.
156
+ Given(
157
+ "step {string}, when prompted with {string}, writes {string} with:",
158
+ (world: TestkitWorld, step: string, text: string, path: string, content: string) => {
159
+ world.act(step, (beat, repo) => {
160
+ if (beat.content.includes(text)) repo.write(path, withNewline(content))
161
+ })
162
+ },
163
+ )
164
+
140
165
  // A standing assertion like any queued action: it applies on every later visit
141
166
  // to the step until `from now on` clears the list.
142
167
  Given(
@@ -169,6 +194,16 @@ Given("judge step {string} answers:", (world: TestkitWorld, step: string, verdic
169
194
  world.verdicts.set(step, String(verdict))
170
195
  })
171
196
 
197
+ Given(
198
+ "judge step {string}, when its questions contain {string}, answers:",
199
+ (world: TestkitWorld, step: string, text: string, verdict: string) => {
200
+ world.conditionalVerdicts.set(step, [
201
+ ...(world.conditionalVerdicts.get(step) ?? []),
202
+ [text, String(verdict)],
203
+ ])
204
+ },
205
+ )
206
+
172
207
  When("gtd starts workflow {string}", async (world: TestkitWorld, workflow: string) => {
173
208
  await world.repo().start(workflow)
174
209
  })
@@ -1,6 +1,14 @@
1
1
  import type { BeatKind, Demand, DemandSession } from "./Demand.js"
2
2
  import type { BeatStatus, NextMatch, StatusChange } from "./BeatStatus.js"
3
- import type { Actor, ApprovalEntry, ModelCost, StateMode, StateName, StepAccess } from "./types.js"
3
+ import type {
4
+ Actor,
5
+ ApprovalEntry,
6
+ ModelCost,
7
+ StateMode,
8
+ StateName,
9
+ StepAccess,
10
+ StepContextLimit,
11
+ } from "./types.js"
4
12
  import { WIRE_SCHEMA } from "./constants.js"
5
13
 
6
14
  /** `gtd next --json`'s `next` key — `null` when landing would commit nothing. */
@@ -56,6 +64,7 @@ export interface BeatDocument {
56
64
  readonly session: DemandSession | undefined
57
65
  readonly model: string | undefined
58
66
  readonly system: string | undefined
67
+ readonly context: StepContextLimit | undefined
59
68
  readonly validate: string | undefined
60
69
  readonly log: string
61
70
  readonly state: StateName
@@ -76,7 +85,7 @@ export interface BeatDocument {
76
85
  }
77
86
 
78
87
  /**
79
- * Flatten a `Demand` plus its `BeatStatus` into the single 25-key document
88
+ * Flatten a `Demand` plus its `BeatStatus` into the single 26-key document
80
89
  * `gtd next --json` emits — the ONLY place the two are joined. `cost`/
81
90
  * `costByModel` are omitted together, exactly when no cost was recorded
82
91
  * (`cost <= 0`).
@@ -93,6 +102,7 @@ export const beatDocument = (demand: Demand, status: BeatStatus): BeatDocument =
93
102
  session,
94
103
  model: status.model,
95
104
  system: status.system,
105
+ context: status.context,
96
106
  validate,
97
107
  log: status.log,
98
108
  state: status.state,
@@ -5,6 +5,7 @@ import type {
5
5
  RenderedDemandSource,
6
6
  StateMode,
7
7
  StateName,
8
+ StepContextLimit,
8
9
  } from "./types.js"
9
10
 
10
11
  /** One pending change — `gtd next --json`'s `changes` entries. */
@@ -32,6 +33,7 @@ export interface BeatStatus {
32
33
  readonly initial: boolean
33
34
  readonly model: string | undefined
34
35
  readonly system: string | undefined
36
+ readonly context: StepContextLimit | undefined
35
37
  readonly log: string
36
38
  readonly state: StateName
37
39
  readonly actor: Actor
@@ -74,6 +76,7 @@ export const statusOf = (input: {
74
76
  initial,
75
77
  model: rendered.model,
76
78
  system: rendered.system !== undefined && rendered.system !== "" ? rendered.system : undefined,
79
+ context: rendered.context,
77
80
  log,
78
81
  state: rendered.state,
79
82
  actor: rendered.actor,
@@ -0,0 +1,30 @@
1
+ /** A Claude Code transcript whose latest assistant message used `tokens` of context, as the hook reads it from `transcript_path`. */
2
+ export const transcriptAt = (tokens: number): string =>
3
+ [
4
+ { type: "user", message: { role: "user", content: "build it" } },
5
+ {
6
+ type: "assistant",
7
+ message: {
8
+ id: "msg_1",
9
+ role: "assistant",
10
+ usage: { input_tokens: 3, cache_read_input_tokens: 10, cache_creation_input_tokens: 7 },
11
+ },
12
+ },
13
+ { type: "user", message: { role: "user", content: [{ type: "tool_result" }] } },
14
+ {
15
+ type: "assistant",
16
+ message: {
17
+ id: "msg_2",
18
+ role: "assistant",
19
+ usage: {
20
+ input_tokens: 1,
21
+ cache_read_input_tokens: tokens - 1001,
22
+ cache_creation_input_tokens: 1000,
23
+ output_tokens: 500,
24
+ },
25
+ },
26
+ },
27
+ { type: "attachment", attachment: { type: "hook_success" } },
28
+ ]
29
+ .map((entry) => JSON.stringify(entry))
30
+ .join("\n") + "\n"