@pmelab/gtd 52.0.0 → 53.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 (108) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +96 -131
  3. package/claude/hooks/drive.ts +0 -3
  4. package/claude/hooks/register.tsx +3 -68
  5. package/claude/hooks/ship.ts +85 -1595
  6. package/dist/chunks/{reserved-D0MKVFFa.mjs → reserved-CQ7amcKG.mjs} +87 -133
  7. package/dist/chunks/{review-ZzUQH1KZ.mjs → review-CNgN_V8T.mjs} +2 -2
  8. package/dist/chunks/{tail-I1-vVq0P.mjs → tail-JZ9PAOwp.mjs} +2 -14
  9. package/dist/chunks/{wire-BJuZWR3C.mjs → wire-C12dahrH.mjs} +2 -130
  10. package/dist/flows.mjs +1 -16
  11. package/dist/gtd.bundle.mjs +476 -982
  12. package/dist/testkit.mjs +58 -417
  13. package/dist/wire.mjs +2 -2
  14. package/dist/workflows/cleanup.mjs +2 -2
  15. package/dist/workflows/feature.mjs +159 -363
  16. package/dist/workflows/fix.mjs +2 -2
  17. package/dist/workflows/review.mjs +3 -3
  18. package/dist/workflows/rollout.mjs +1 -1
  19. package/dist/workflows.mjs +400 -675
  20. package/package.json +5 -4
  21. package/src/cli/Cli.test.ts +1 -86
  22. package/src/cli/Cli.ts +13 -133
  23. package/src/cli/Metrics.test.ts +21 -31
  24. package/src/cli/Metrics.ts +25 -36
  25. package/src/cli/Program.test.ts +1 -35
  26. package/src/cli/Program.ts +5 -64
  27. package/src/cli/ToolCommands.ts +0 -3
  28. package/src/edge/Edge.test.ts +20 -322
  29. package/src/edge/Edge.ts +10 -181
  30. package/src/edge/index.ts +0 -1
  31. package/src/flows/runtime.test.ts +0 -27
  32. package/src/flows/runtime.ts +1 -49
  33. package/src/install/Install.ts +15 -24
  34. package/src/replay/Replay.test.ts +0 -169
  35. package/src/replay/Replay.ts +4 -93
  36. package/src/replay/index.ts +0 -1
  37. package/src/testkit/Layers.ts +0 -1
  38. package/src/testkit/steps.ts +13 -0
  39. package/src/wire/BeatDocument.ts +2 -12
  40. package/src/wire/BeatStatus.ts +0 -3
  41. package/src/wire/WireSchema.test.ts +0 -18
  42. package/src/wire/index.ts +0 -2
  43. package/src/wire/types.ts +0 -7
  44. package/src/workflow/Workflow.ts +1 -9
  45. package/src/workflows/feature/acceptance-criteria.feature +159 -3
  46. package/src/workflows/feature/criteria.test.ts +23 -15
  47. package/src/workflows/feature/criteria.ts +72 -39
  48. package/src/workflows/feature/fast-path.feature +4 -0
  49. package/src/workflows/feature/feature.test.ts +6 -5
  50. package/src/workflows/feature/feature.ts +34 -118
  51. package/src/workflows/feature/flag-mode.feature +4 -0
  52. package/src/workflows/feature/hands-on-skip.feature +37 -65
  53. package/src/workflows/feature/index.ts +0 -1
  54. package/src/workflows/feature/lens-models.feature +4 -0
  55. package/src/workflows/feature/lens-review.feature +4 -0
  56. package/src/workflows/feature/naive-acceptance.feature +4 -0
  57. package/src/workflows/feature/one-step-land.feature +4 -4
  58. package/src/workflows/feature/planning.ts +2 -1
  59. package/src/workflows/feature/prototype-rounds.feature +5 -1
  60. package/src/workflows/feature/prototype.feature +11 -3
  61. package/src/workflows/feature/prototype.ts +18 -29
  62. package/src/workflows/feature/refactor-absorbs.feature +7 -8
  63. package/src/workflows/feature/refactor-first.feature +4 -5
  64. package/src/workflows/feature/refactor-lens-models.feature +4 -0
  65. package/src/workflows/feature/refactor-rebuild.feature +4 -6
  66. package/src/workflows/feature/refactor-review.feature +31 -11
  67. package/src/workflows/feature/refactor.ts +30 -64
  68. package/src/workflows/feature/review-answers.feature +4 -0
  69. package/src/workflows/feature/review-gate.feature +13 -1
  70. package/src/workflows/feature/reviewer-walkthrough.feature +4 -0
  71. package/src/workflows/feature/risk-tier.feature +32 -0
  72. package/src/workflows/feature/seam-rejection.feature +8 -4
  73. package/src/workflows/feature/seam.feature +8 -0
  74. package/src/workflows/feature/seam.test.ts +11 -28
  75. package/src/workflows/feature/seam.ts +52 -165
  76. package/src/workflows/feature/severity-budget.feature +4 -0
  77. package/src/workflows/feature/severity-sorted-review.feature +4 -0
  78. package/src/workflows/feature/standards.feature +4 -0
  79. package/src/workflows/feature/tier-pin.feature +4 -0
  80. package/src/workflows/index.ts +0 -1
  81. package/src/workflows/shared/lenses.ts +0 -13
  82. package/src/workflows/shared/options.test.ts +2 -11
  83. package/src/workflows/shared/options.ts +4 -21
  84. package/src/workflows/shared/review.test.ts +3 -3
  85. package/src/workflows/shared/steps.ts +0 -2
  86. package/src/workflows/shared/text.test.ts +41 -71
  87. package/src/workflows/shared/text.ts +82 -212
  88. package/src/workflows/shared/vars.ts +2 -8
  89. package/src/workflows/shared/workflows.steps.ts +0 -13
  90. package/claude/hooks/context.ts +0 -66
  91. package/src/replay/Slices.test.ts +0 -73
  92. package/src/replay/Slices.ts +0 -45
  93. package/src/wire/ContextLimit.fixture.ts +0 -30
  94. package/src/wire/ContextLimit.test.ts +0 -96
  95. package/src/wire/ContextLimit.ts +0 -169
  96. package/src/wire/Slices.ts +0 -25
  97. package/src/wire/golden/1/slices.json +0 -11
  98. package/src/workflows/feature/context-limit.feature +0 -37
  99. package/src/workflows/feature/continuation-cap.feature +0 -65
  100. package/src/workflows/feature/continuation-rebuild.feature +0 -88
  101. package/src/workflows/feature/continuation-slices.feature +0 -64
  102. package/src/workflows/feature/continuation.feature +0 -205
  103. package/src/workflows/feature/slice-findings.feature +0 -252
  104. package/src/workflows/feature/slicing-cap.feature +0 -23
  105. package/src/workflows/feature/slicing.feature +0 -265
  106. package/src/workflows/feature/slicing.test.ts +0 -49
  107. package/src/workflows/feature/slicing.ts +0 -105
  108. package/src/workflows/shared/continuation.ts +0 -92
package/src/edge/index.ts CHANGED
@@ -14,5 +14,4 @@ export {
14
14
  snapshotFromRest,
15
15
  summaryFor,
16
16
  historyTrail,
17
- processSlices,
18
17
  } from "./Edge.js"
@@ -7,7 +7,6 @@ import {
7
7
  kept,
8
8
  mark,
9
9
  protocolMismatch,
10
- slice,
11
10
  type FlowContext,
12
11
  } from "./runtime.js"
13
12
 
@@ -128,29 +127,3 @@ describe("kept()", () => {
128
127
  expect(FLOWS_PROTOCOL).toBe(1)
129
128
  })
130
129
  })
131
-
132
- describe("slice()", () => {
133
- const withSlices = () => {
134
- const calls: string[] = []
135
- installContext({
136
- enterSlice: (n: string) => calls.push(`enter ${n}`),
137
- leaveSlice: () => calls.push("leave"),
138
- } as unknown as FlowContext)
139
- return calls
140
- }
141
-
142
- it("brackets fn with enter and leave and resolves to its value", async () => {
143
- const calls = withSlices()
144
- const value = await slice("refactor", async () => {
145
- calls.push("fn")
146
- return 42
147
- })
148
- expect(value).toBe(42)
149
- expect(calls).toEqual(["enter refactor", "fn", "leave"])
150
- })
151
-
152
- it("runs fn unsliced on a context without enterSlice", async () => {
153
- installContext(context)
154
- expect(await slice("refactor", async () => "ran")).toBe("ran")
155
- })
156
- })
@@ -16,18 +16,9 @@ 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
-
27
19
  export interface AgentOptions extends SteeringOptions {
28
20
  readonly model?: string | undefined
29
21
  readonly system?: string | undefined
30
- readonly context?: ContextLimitOptions | undefined
31
22
  /**
32
23
  * A turn that changes nothing completes the step. Without this an empty turn
33
24
  * is an attempt: recorded, but the process stays at the step (a stall).
@@ -124,8 +115,6 @@ export interface ScopeOptions {
124
115
  readonly model?: string | undefined
125
116
  /** The system prompt every agent step inside runs with, unless it sets its own. */
126
117
  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
129
118
  /** The skill names every agent step inside declares — `gtd next --json`'s `skills`. A nested scope inherits it unless it sets its own. */
130
119
  readonly skills?: readonly string[] | undefined
131
120
  /** The file access every agent step inside runs with — `gtd next --json`'s `access`. A nested scope inherits it; its own replaces it wholesale. */
@@ -161,9 +150,6 @@ export interface FlowContext {
161
150
  readonly refuse: (message: string) => never
162
151
  readonly pushScope: (scope: ScopeOptions) => void
163
152
  readonly popScope: () => void
164
- /** Absent on an engine older than `slice`; FLOWS_PROTOCOL stays 1. */
165
- readonly enterSlice?: (name: string) => void
166
- readonly leaveSlice?: () => void
167
153
  readonly read: (path: string) => string | undefined
168
154
  readonly glob: (pattern: string) => readonly string[]
169
155
  /** The last step's changes, content read on demand. */
@@ -298,21 +284,6 @@ export const scope = async <T>(scope: string | ScopeOptions, fn: () => Promise<T
298
284
  }
299
285
  }
300
286
 
301
- /**
302
- * Mark the span `fn` covers as one named slice of the process. Lands no
303
- * commit, prefixes no step name, opens no memory scope.
304
- */
305
- export const slice = async <T>(name: string, fn: () => Promise<T>): Promise<T> => {
306
- const context = ctx()
307
- if (typeof context.enterSlice !== "function") return fn()
308
- context.enterSlice(name)
309
- try {
310
- return await fn()
311
- } finally {
312
- context.leaveSlice?.()
313
- }
314
- }
315
-
316
287
  // ── Reads of the commit replay stands on ────────────────────────────────────
317
288
 
318
289
  export const read = (path: string): string | undefined => ctx().read(path)
@@ -501,36 +472,17 @@ export const openQuestions = (text: string): readonly OpenQuestion[] => ctx().op
501
472
  /** A workflow's one flow. */
502
473
  export type Flow = () => Promise<void>
503
474
 
504
- /** The slice `gtd summary --slice` writes the closing message for. */
505
- export interface SummarySlice {
506
- readonly name: string
507
- /** Exclusive, as in git's `base..tip`. */
508
- readonly base: string
509
- readonly tip: string
510
- /** 1-based stack position: `index === count` is the last slice. */
511
- readonly index: number
512
- readonly count: number
513
- }
514
-
515
475
  export interface SummaryContext {
516
- /** Whole process, even under a slice. */
517
476
  readonly entryCommit: string
518
- /** The slice's `base` under `--slice`. */
519
477
  readonly processBase: string
520
- /** The slice's `tip` under `--slice`. */
521
478
  readonly processTip: string
522
- /** The slice's own under `--slice`. */
523
479
  readonly humanCommits: readonly { readonly hash: string; readonly state: string }[]
524
- /** The slice's own under `--slice`. */
525
480
  readonly processCost: number
526
- /** The slice's own under `--slice`. */
527
481
  readonly processCostByModel: readonly { readonly model: string; readonly cost: number }[]
528
- /** Every `Gtd-Mark` of the process's commits, oldest first — whole process, even under a slice. */
482
+ /** Every `Gtd-Mark` of the process's commits, oldest first. */
529
483
  readonly marks: readonly Mark[]
530
484
  readonly vars: Readonly<Record<string, string>>
531
485
  readonly env: Readonly<Record<string, string>>
532
- /** Present only under `gtd summary --slice`; the key is absent, not undefined, otherwise. */
533
- readonly slice?: SummarySlice
534
486
  }
535
487
 
536
488
  /** `gtd summary`'s prompt — a workflow module's optional `summary` export. */
@@ -81,10 +81,7 @@ while :; do
81
81
  model="$(gtd next --json=model)"
82
82
  system="$(gtd next --json=system)"
83
83
  validate="$(gtd next --json=validate)"
84
- context="$(gtd next --json=context)"
85
84
  log="$(gtd next --json=log)"
86
- # Absolute, since the hook runs wherever the agent last cd'ed to.
87
- case "$log" in /*) ;; *) log="$(git rev-parse --show-toplevel)/$log" ;; esac
88
85
  # \`model\` is a hint, not a model name — the bundled workflow ships
89
86
  # \`smart\` and \`base\`, which \`claude --model\` rejects. Resolve them
90
87
  # from your environment; unset, the hint is dropped and the agent runs
@@ -93,18 +90,6 @@ while :; do
93
90
  smart) model="\${GTD_LOOP_MODEL_SMART:-}" ;;
94
91
  base) model="\${GTD_LOOP_MODEL_BASE:-}" ;;
95
92
  esac
96
- # The step's context limit, as a Claude Code hook after every tool
97
- # call and before compacting. Past \`hard\` it stops the turn and
98
- # appends why to the stop file, so we land nothing and stop too.
99
- soft="$(printf '%s' "$context" | sed -n 's/.*"soft":\\([0-9.]*\\).*/\\1/p')"
100
- hard="$(printf '%s' "$context" | sed -n 's/.*"hard":\\([0-9.]*\\).*/\\1/p')"
101
- window="\${GTD_LOOP_CONTEXT_WINDOW:-}"
102
- case "$model" in *'[1m]'* | *'[1M]'*) window="\${window:-1000000}" ;; esac
103
- hook="gtd context-hook\${soft:+ --soft $soft}\${hard:+ --hard $hard}\${window:+ --window $window}"
104
- hooks="[{\\"hooks\\":[{\\"type\\":\\"command\\",\\"command\\":\\"$hook\\"}]}]"
105
- settings="{\\"hooks\\":{\\"PostToolUse\\":$hooks,\\"PreCompact\\":$hooks}}"
106
- stop="$log.context-stop"
107
- rm -f "$stop"
108
93
  # One turn, its prompt on stdin. The prompt embeds a full diff, so it
109
94
  # never goes on argv — argv is capped (~1 MB on macOS, and POSIX
110
95
  # guarantees only 4 KB, ARG_MAX), and a diff crosses that far sooner
@@ -112,15 +97,15 @@ while :; do
112
97
  # whether \`claude --resume\` re-applies the original session's is a
113
98
  # harness detail gtd cannot verify from outside. Its JSON envelope
114
99
  # goes to the log, and its \`total_cost_usd\` adds up into $cost. An
115
- # \`is_error\` envelope (max turns, an error mid-turn) or one another
116
- # hook stopped fails the turn even at exit 0: landing it would land
117
- # the failure as work. Only a turn that never ran (no envelope: the
100
+ # \`is_error\` envelope (max turns, an error mid-turn) or one a hook
101
+ # stopped fails the turn even at exit 0: landing it would land the
102
+ # failure as work. Only a turn that never ran (no envelope: the
118
103
  # session flag was refused) is worth retrying under the other flag.
119
104
  turn() {
120
- out="$(GTD_LOOP_CONTEXT_STOP_FILE="$stop" claude -p "$@" \\
105
+ out="$(claude -p "$@" \\
121
106
  \${model:+--model "$model"} \\
122
107
  \${system:+--system-prompt "$system"} \\
123
- --settings "$settings" --output-format json \\
108
+ --output-format json \\
124
109
  --dangerously-skip-permissions 2>>"$log")" && ok=0 || ok=$?
125
110
  printf '%s\\n' "$out" >>"$log"
126
111
  case "$out" in *'"type":"result"'*) ran=true ;; *) ran=false ;; esac
@@ -130,10 +115,6 @@ while :; do
130
115
  usd="$(printf '%s\\n' "$out" | sed -n 's/.*"total_cost_usd":\\([0-9.eE+-]*\\).*/\\1/p' | tail -n 1)"
131
116
  [ -z "$usd" ] || cost="$(awk -v a="\${cost:-0}" -v b="$usd" 'BEGIN { printf "%.6f", a + b }')"
132
117
  [ "\${cost:-}" != 0.000000 ] || cost=
133
- if [ -s "$stop" ]; then
134
- cat "$stop" >&2
135
- exit 1
136
- fi
137
118
  return "$ok"
138
119
  }
139
120
  # Plain \`gtd next\` (not --json=content) renders the diff once.
@@ -619,6 +600,16 @@ way — an existing \`gtd-loop\` survives untouched beside the new
619
600
  \`gtd-door review [base]\`). When either is still present beside the suite,
620
601
  name it to the user as superseded and ask before removing it — never remove
621
602
  one unasked.
603
+
604
+ An older \`gtd-build\` passed a \`gtd context-hook\` hook to every turn, and
605
+ an older setup guide had it registered in Claude Code's own settings
606
+ (\`PostToolUse\`/\`PreCompact\`). That command no longer exists: a hook
607
+ still calling it fails on every tool call. Replace such a \`gtd-build\` as a
608
+ differing file above, and look for a \`gtd context-hook\` entry in the user's
609
+ Claude Code settings files (\`~/.claude/settings.json\`, the repository's
610
+ \`.claude/settings.json\` and \`.claude/settings.local.json\`): name each file
611
+ to the user, then remove that entry alone, leaving every other key
612
+ byte-identical.
622
613
  `
623
614
 
624
615
  const EDITOR_INTEGRATION = `
@@ -15,7 +15,6 @@ import {
15
15
  restart,
16
16
  run,
17
17
  scope,
18
- slice,
19
18
  threads,
20
19
  type Flow,
21
20
  type ScopeAccess,
@@ -1207,171 +1206,3 @@ describe("human({ approve })", () => {
1207
1206
  expect(outcome.kind).toBe("rest")
1208
1207
  })
1209
1208
  })
1210
-
1211
- describe("slice()", () => {
1212
- const spansOf = (o: ReplayOutcome) => (o as { slices: unknown }).slices
1213
-
1214
- it("records enteredAt and leftAt as the commit replay stood on", async () => {
1215
- const wf = async () => {
1216
- await human("a")
1217
- await slice("refactor", async () => {
1218
- await human("b")
1219
- })
1220
- await human("c")
1221
- }
1222
- const h = new History().land("human", "a", 1, "b").land("human", "b", 1, "c")
1223
- const outcome = await replayOf(wf, h)
1224
- expect(outcome).toMatchObject({ kind: "rest" })
1225
- expect(spansOf(outcome)).toEqual([
1226
- { name: "refactor", enteredAt: hashOf(1), leftAt: hashOf(2) },
1227
- ])
1228
- })
1229
-
1230
- it("an ended outcome carries every span of the episode that just ended", async () => {
1231
- const wf = async () => {
1232
- await slice("one", async () => {
1233
- await human("a")
1234
- })
1235
- await slice("two", async () => {
1236
- await human("b")
1237
- })
1238
- }
1239
- const h = new History().land("human", "a", 1, "b").land("human", "b", 1, "done")
1240
- const outcome = await replayOf(wf, h)
1241
- expect(outcome).toMatchObject({ kind: "ended", via: "return" })
1242
- expect(spansOf(outcome)).toEqual([
1243
- { name: "one", enteredAt: hashOf(0), leftAt: hashOf(1) },
1244
- { name: "two", enteredAt: hashOf(1), leftAt: hashOf(2) },
1245
- ])
1246
- })
1247
-
1248
- it("restart() inside a slice ends via restart with that span's leftAt undefined", async () => {
1249
- const wf = async () => {
1250
- await human("a")
1251
- await slice("risky", async () => {
1252
- await human("b")
1253
- await restart()
1254
- })
1255
- }
1256
- const h = new History().land("human", "a", 1, "b").land("human", "b", 1, "done")
1257
- const outcome = await replayOf(wf, h)
1258
- expect(outcome).toMatchObject({ kind: "ended", via: "restart" })
1259
- expect(spansOf(outcome)).toEqual([{ name: "risky", enteredAt: hashOf(1), leftAt: undefined }])
1260
- })
1261
-
1262
- it("keeps leftAt undefined when the process rests inside the slice", async () => {
1263
- const wf = async () => {
1264
- await slice("refactor", async () => {
1265
- await human("a")
1266
- })
1267
- }
1268
- const outcome = await replayOf(wf, new History())
1269
- expect(spansOf(outcome)).toEqual([
1270
- { name: "refactor", enteredAt: hashOf(0), leftAt: undefined },
1271
- ])
1272
- })
1273
-
1274
- it("records enteredAt === leftAt for a slice that landed no commit", async () => {
1275
- const wf = async () => {
1276
- await human("a")
1277
- await slice("empty", async () => undefined)
1278
- await human("b")
1279
- }
1280
- const h = new History().land("human", "a", 1, "b")
1281
- expect(spansOf(await replayOf(wf, h))).toEqual([
1282
- { name: "empty", enteredAt: hashOf(1), leftAt: hashOf(1) },
1283
- ])
1284
- })
1285
-
1286
- it("prefixes no step name and ignores an enclosing scope", async () => {
1287
- const wf = async () => {
1288
- await scope("build", async () => {
1289
- await slice("refactor", async () => {
1290
- await human("gate")
1291
- })
1292
- })
1293
- }
1294
- const outcome = await replayOf(wf, new History())
1295
- expect(outcome).toMatchObject({ kind: "rest", rest: { name: "build.gate" } })
1296
- expect((spansOf(outcome) as { name: string }[])[0]!.name).toBe("refactor")
1297
- })
1298
-
1299
- const failed = async (wf: Flow) => {
1300
- const outcome = await replayOf(wf, new History())
1301
- expect(outcome.kind).toBe("failed")
1302
- return (outcome as { message: string }).message
1303
- }
1304
-
1305
- it.each(["Refactor", "step_1", "v2.api", ""])("refuses the name %j", async (name) => {
1306
- expect(await failed(async () => slice(name, async () => human("a")))).toBe(
1307
- `gtd: slice "${name}": name must match /^[a-z][a-z0-9-]*$/`,
1308
- )
1309
- })
1310
-
1311
- it("accepts refactor-2", async () => {
1312
- const outcome = await replayOf(
1313
- async () => slice("refactor-2", async () => human("a")),
1314
- new History(),
1315
- )
1316
- expect(outcome.kind).toBe("rest")
1317
- })
1318
-
1319
- it("refuses the reserved name main", async () => {
1320
- expect(await failed(async () => slice("main", async () => human("a")))).toBe(
1321
- 'gtd: slice "main" is reserved for the implicit slice',
1322
- )
1323
- })
1324
-
1325
- it("refuses nesting, also through scope()", async () => {
1326
- const message = 'gtd: slice "inner" is entered inside slice "outer" — slices do not nest'
1327
- expect(
1328
- await failed(async () => slice("outer", async () => slice("inner", async () => human("a")))),
1329
- ).toBe(message)
1330
- expect(
1331
- await failed(async () =>
1332
- slice("outer", async () => scope("s", async () => slice("inner", async () => human("a")))),
1333
- ),
1334
- ).toBe(message)
1335
- })
1336
-
1337
- it("checks the name rule before main and nesting", async () => {
1338
- expect(
1339
- await failed(async () => slice("outer", async () => slice("Main", async () => human("a")))),
1340
- ).toBe('gtd: slice "Main": name must match /^[a-z][a-z0-9-]*$/')
1341
- })
1342
-
1343
- it("lets a flow that caught an error from a slice enter another", async () => {
1344
- const wf = async () => {
1345
- try {
1346
- await slice("a", async () => {
1347
- throw new Error("boom")
1348
- })
1349
- } catch {}
1350
- await slice("b", async () => human("x"))
1351
- }
1352
- expect(await replayOf(wf, new History())).toMatchObject({ kind: "rest" })
1353
- })
1354
-
1355
- it("refuses a non-string name", async () => {
1356
- expect(
1357
- await failed(async () => slice(undefined as unknown as string, async () => human("a"))),
1358
- ).toContain("name must match")
1359
- })
1360
-
1361
- it("refuses a name entered twice, sequentially and from a loop", async () => {
1362
- const message =
1363
- 'gtd: slice "again" is entered twice in this episode — a loop must sit inside its slice'
1364
- expect(
1365
- await failed(async () => {
1366
- await slice("again", async () => undefined)
1367
- await slice("again", async () => human("a"))
1368
- }),
1369
- ).toBe(message)
1370
- expect(
1371
- await failed(async () => {
1372
- for (let i = 0; i < 2; i++) await slice("again", async () => undefined)
1373
- await human("a")
1374
- }),
1375
- ).toBe(message)
1376
- })
1377
- })
@@ -8,7 +8,6 @@ import {
8
8
  type ScopeAccess,
9
9
  type AccessDef,
10
10
  type ScopeOptions,
11
- type ContextLimitOptions,
12
11
  type StepRequest,
13
12
  type Flow,
14
13
  type Mark,
@@ -22,9 +21,8 @@ import {
22
21
  unansweredQuestions,
23
22
  } from "../steering/index.js"
24
23
  import { accessShapeFault, foldAccess, sameAccess } from "./Access.js"
25
- import { contextLimitFault, contextShapeFault, globMatches } from "../wire/index.js"
24
+ import { globMatches } from "../wire/index.js"
26
25
  import { refNameFault } from "./RefName.js"
27
- import { IMPLICIT_SLICE, SLICE_NAME, type SliceSpan } from "./Slices.js"
28
26
  import { diffTrees, isEmptyDiff, type TreeView } from "./Tree.js"
29
27
  import {
30
28
  formatStepId,
@@ -119,7 +117,6 @@ export type ReplayOutcome =
119
117
  readonly landed: ReachedStep | undefined
120
118
  /** Marks the flow made after the pending turn's step resolved; [] without a pending turn. */
121
119
  readonly marks: readonly Mark[]
122
- readonly slices: readonly SliceSpan[]
123
120
  }
124
121
  | {
125
122
  readonly kind: "ended"
@@ -127,8 +124,6 @@ export type ReplayOutcome =
127
124
  readonly trace: readonly ReachedStep[]
128
125
  readonly landed: ReachedStep | undefined
129
126
  readonly marks: readonly Mark[]
130
- /** Spans of the episode that just ended; a restart() inside a slice leaves its leftAt undefined. */
131
- readonly slices: readonly SliceSpan[]
132
127
  }
133
128
  | { readonly kind: "divergence"; readonly message: string }
134
129
  | { readonly kind: "refused"; readonly message: string }
@@ -238,28 +233,6 @@ const budgeted = (
238
233
  return { evidence: bounded, truncated }
239
234
  }
240
235
 
241
- // The innermost of `chain`, named by its full path.
242
- const scopeLabel = (chain: readonly ScopeOptions[]): string => {
243
- const path = chain.flatMap((o) => (o.name === undefined ? [] : [o.name])).join(".")
244
- if (chain.at(-1)?.name !== undefined) return `scope "${path}"`
245
- return path === "" ? "an unnamed scope" : `an unnamed scope inside "${path}"`
246
- }
247
-
248
- // Key by key, innermost last: a scope or step that sets only `hard` keeps the outer `soft`.
249
- const inheritedContext = (
250
- layers: readonly (ContextLimitOptions | undefined)[],
251
- ): ContextLimitOptions | undefined =>
252
- layers.reduce<ContextLimitOptions | undefined>(
253
- (merged, layer) =>
254
- layer === undefined
255
- ? merged
256
- : {
257
- ...merged,
258
- ...Object.fromEntries(Object.entries(layer).filter(([, value]) => value !== undefined)),
259
- },
260
- undefined,
261
- )
262
-
263
236
  interface Identity {
264
237
  readonly model: string | undefined
265
238
  readonly system: string | undefined
@@ -296,7 +269,7 @@ const STEERING_OPTIONS = ["file", "mode", "label", "base"]
296
269
  // The option keys each step accepts. A gtd.config.ts is evaluated without a
297
270
  // type check, so a misspelt key would otherwise be silently ignored.
298
271
  const KNOWN_OPTIONS: Readonly<Record<StepKind, ReadonlySet<string>>> = {
299
- agent: new Set([...STEERING_OPTIONS, "model", "system", "context", "allowEmpty", "keep"]),
272
+ agent: new Set([...STEERING_OPTIONS, "model", "system", "allowEmpty", "keep"]),
300
273
  human: new Set([...STEERING_OPTIONS, "message", "acceptClean", "approve"]),
301
274
  run: new Set(STEERING_OPTIONS),
302
275
  judge: new Set([...STEERING_OPTIONS, "message"]),
@@ -354,7 +327,6 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
354
327
  >()
355
328
  let landed: ReachedStep | undefined
356
329
  const marks: Mark[] = []
357
- const slices: { name: string; enteredAt: string; leftAt: string | undefined }[] = []
358
330
  let outcome: ReplayOutcome | undefined
359
331
  let signal: (() => void) | undefined
360
332
  const settled = new Promise<void>((resolve) => {
@@ -479,15 +451,7 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
479
451
  if (s.model !== undefined) persona.model = s.model
480
452
  if (s.system !== undefined) persona.system = s.system
481
453
  }
482
- const context = inheritedContext([...scopes.map((s) => s.context), request.options.context])
483
- return {
484
- ...request,
485
- options: {
486
- ...persona,
487
- ...request.options,
488
- ...(context === undefined ? {} : { context }),
489
- },
490
- }
454
+ return { ...request, options: { ...persona, ...request.options } }
491
455
  }
492
456
  if (request.kind === "judge") {
493
457
  const { evidence, truncated } = budgeted(request.evidence, input.budgetBytes)
@@ -626,34 +590,11 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
626
590
  trace,
627
591
  landed,
628
592
  marks,
629
- slices: slices.map((s) => ({ ...s })),
630
593
  })
631
594
  }
632
595
  return landPending(step, input.pending)
633
596
  }
634
597
 
635
- // Each layer on its own first, so a bad scope or workflow option is named as
636
- // such rather than blamed on the step it reached; then the merged limit.
637
- const scopeContextFault = (): string | undefined => {
638
- for (const [i, s] of scopes.entries()) {
639
- const fault = s.context === undefined ? undefined : contextShapeFault(s.context)
640
- if (fault !== undefined)
641
- return `the context of ${scopeLabel(scopes.slice(0, i + 1))}: ${fault}`
642
- }
643
- return undefined
644
- }
645
-
646
- const resolvedContextFault = (
647
- request: Extract<StepRequest, { kind: "agent" }>,
648
- ): string | undefined => {
649
- const own = request.options.context
650
- const layered = scopeContextFault() ?? (own === undefined ? undefined : contextShapeFault(own))
651
- if (layered !== undefined) return layered
652
- const resolved = resolve(request)
653
- const context = resolved.kind === "agent" ? resolved.options.context : undefined
654
- return context === undefined ? undefined : contextLimitFault(context)
655
- }
656
-
657
598
  // gtd.config.ts is loaded without a type check.
658
599
  const requestFault = (request: Exclude<StepRequest, { kind: "restart" }>): string | undefined => {
659
600
  if (request.kind === "run" && typeof request.body !== "string") {
@@ -661,8 +602,6 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
661
602
  }
662
603
  if (request.kind !== "agent") return undefined
663
604
  const name = scoped(request.name)
664
- const contextFault = resolvedContextFault(request)
665
- if (contextFault !== undefined) return `gtd: step "${name}": ${contextFault}`
666
605
  if (request.options.keep === true) {
667
606
  // A clean allowEmpty keep would land an ordinary commit with no Gtd-Kept: a silent skip.
668
607
  if (request.options.allowEmpty === true) {
@@ -696,7 +635,7 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
696
635
  kind: "divergence",
697
636
  message: `gtd: the flow ended, but commit ${short(commits[cursor]!.hash)} continues the episode — ${DIVERGED}`,
698
637
  }
699
- : { kind: "ended", via, trace, landed, marks, slices: slices.map((s) => ({ ...s })) }
638
+ : { kind: "ended", via, trace, landed, marks }
700
639
 
701
640
  const changesSince = (hash: string): readonly Change[] => {
702
641
  const tree = treeAt(hash)
@@ -723,34 +662,6 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
723
662
  scopes.pop()
724
663
  scopeCallIds.pop()
725
664
  },
726
- enterSlice: (name) => {
727
- if (outcome !== undefined) return
728
- const fail = (message: string): never => {
729
- void finish({ kind: "failed", message })
730
- throw new Stop()
731
- }
732
- if (typeof name !== "string" || !SLICE_NAME.test(name)) {
733
- fail(`gtd: slice "${name}": name must match ${SLICE_NAME}`)
734
- }
735
- if (name === IMPLICIT_SLICE) {
736
- fail(`gtd: slice "${IMPLICIT_SLICE}" is reserved for the implicit slice`)
737
- }
738
- const open = slices.find((s) => s.leftAt === undefined)
739
- if (open !== undefined) {
740
- fail(`gtd: slice "${name}" is entered inside slice "${open.name}" — slices do not nest`)
741
- }
742
- if (slices.some((s) => s.name === name)) {
743
- fail(
744
- `gtd: slice "${name}" is entered twice in this episode — a loop must sit inside its slice`,
745
- )
746
- }
747
- slices.push({ name, enteredAt: position.hash, leftAt: undefined })
748
- },
749
- leaveSlice: () => {
750
- if (outcome !== undefined) return
751
- const open = slices.find((s) => s.leftAt === undefined)
752
- if (open !== undefined) open.leftAt = position.hash
753
- },
754
665
  read: (path) => position.tree.read(path),
755
666
  glob: (pattern) => position.tree.paths().filter((path) => globMatches(path, pattern)),
756
667
  changes: () => changesBetween(previousPosition.tree, position.tree),
@@ -18,4 +18,3 @@ export {
18
18
  export { accessShapeFault, foldAccess } from "./Access.js"
19
19
  export { formatFault } from "./Format.js"
20
20
  export { refNameFault } from "./RefName.js"
21
- export { sliceRanges } from "./Slices.js"
@@ -205,7 +205,6 @@ const missingTool = (name: string) => () =>
205
205
  const missingToolCommands: ToolCommands["Type"] = {
206
206
  ui: missingTool("ui"),
207
207
  lsp: missingTool("lsp"),
208
- contextHook: missingTool("context-hook"),
209
208
  judge: () =>
210
209
  Promise.reject(
211
210
  new Error("gtd judge run: ToolCommands has no test double wired into testLayers()"),
@@ -149,6 +149,10 @@ Given("the working tree is committed as {string}", (world: TestkitWorld, message
149
149
  world.repo().commit(message)
150
150
  })
151
151
 
152
+ Given("the working tree is committed with the message:", (world: TestkitWorld, message: string) => {
153
+ world.repo().commit(message)
154
+ })
155
+
152
156
  Given("step {string} changes nothing", (world: TestkitWorld, step: string) => {
153
157
  world.act(step, () => undefined)
154
158
  })
@@ -180,6 +184,15 @@ Given(
180
184
  },
181
185
  )
182
186
 
187
+ Given(
188
+ "step {string} appends to {string}:",
189
+ (world: TestkitWorld, step: string, path: string, text: string) => {
190
+ world.act(step, (_, repo) => {
191
+ repo.write(path, `${(repo.read(path) ?? "").trimEnd()}\n\n${withNewline(text)}`)
192
+ })
193
+ },
194
+ )
195
+
183
196
  Given("step {string} deletes {string}", (world: TestkitWorld, step: string, path: string) => {
184
197
  world.act(step, (_, repo) => repo.remove(path))
185
198
  })
@@ -1,14 +1,6 @@
1
1
  import type { BeatKind, Demand, DemandSession } from "./Demand.js"
2
2
  import type { BeatStatus, NextMatch, StatusChange } from "./BeatStatus.js"
3
- import type {
4
- Actor,
5
- ApprovalEntry,
6
- ModelCost,
7
- StateMode,
8
- StateName,
9
- StepAccess,
10
- StepContextLimit,
11
- } from "./types.js"
3
+ import type { Actor, ApprovalEntry, ModelCost, StateMode, StateName, StepAccess } from "./types.js"
12
4
  import { WIRE_SCHEMA } from "./constants.js"
13
5
 
14
6
  /** `gtd next --json`'s `next` key — `null` when landing would commit nothing. */
@@ -64,7 +56,6 @@ export interface BeatDocument {
64
56
  readonly session: DemandSession | undefined
65
57
  readonly model: string | undefined
66
58
  readonly system: string | undefined
67
- readonly context: StepContextLimit | undefined
68
59
  readonly validate: string | undefined
69
60
  readonly log: string
70
61
  readonly state: StateName
@@ -85,7 +76,7 @@ export interface BeatDocument {
85
76
  }
86
77
 
87
78
  /**
88
- * Flatten a `Demand` plus its `BeatStatus` into the single 26-key document
79
+ * Flatten a `Demand` plus its `BeatStatus` into the single 25-key document
89
80
  * `gtd next --json` emits — the ONLY place the two are joined. `cost`/
90
81
  * `costByModel` are omitted together, exactly when no cost was recorded
91
82
  * (`cost <= 0`).
@@ -102,7 +93,6 @@ export const beatDocument = (demand: Demand, status: BeatStatus): BeatDocument =
102
93
  session,
103
94
  model: status.model,
104
95
  system: status.system,
105
- context: status.context,
106
96
  validate,
107
97
  log: status.log,
108
98
  state: status.state,