@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
@@ -0,0 +1,96 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import {
3
+ CONTEXT_LIMIT_MARKER,
4
+ DEFAULT_CONTEXT_LIMIT,
5
+ contextHook,
6
+ contextLimitFault,
7
+ contextWindowFor,
8
+ hookOutput,
9
+ subagentTranscriptPath,
10
+ transcriptTokens,
11
+ } from "./ContextLimit.js"
12
+ import { transcriptAt } from "./ContextLimit.fixture.js"
13
+
14
+ const at = (tokens: number, event: "PostToolUse" | "PreCompact" = "PostToolUse") =>
15
+ contextHook({
16
+ event,
17
+ tokens: transcriptTokens(transcriptAt(tokens)),
18
+ window: 200_000,
19
+ limit: DEFAULT_CONTEXT_LIMIT,
20
+ })
21
+
22
+ describe("transcriptTokens", () => {
23
+ it("counts the latest assistant message's input, cache-read and cache-creation tokens", () => {
24
+ expect(transcriptTokens(transcriptAt(50_000))).toBe(50_000)
25
+ })
26
+
27
+ it("is undefined for a transcript with no assistant usage yet", () => {
28
+ expect(transcriptTokens('{"type":"user"}\nnot json\n')).toBeUndefined()
29
+ })
30
+ })
31
+
32
+ describe("contextHook", () => {
33
+ it("lets a run below the soft limit go on", () => {
34
+ expect(at(119_999)).toBeUndefined()
35
+ })
36
+
37
+ it("blocks with a handoff-note request at the soft limit", () => {
38
+ const decision = at(120_000)
39
+ expect(decision?.kind).toBe("soft")
40
+ expect(hookOutput(decision!)).toEqual({
41
+ decision: "block",
42
+ reason: expect.stringContaining("short handoff note"),
43
+ })
44
+ })
45
+
46
+ it("stops the run at the hard limit, behind the marker", () => {
47
+ const decision = at(140_000)
48
+ expect(hookOutput(decision!)).toEqual({
49
+ continue: false,
50
+ stopReason: expect.stringMatching(new RegExp(`^${CONTEXT_LIMIT_MARKER}: 140000 of 200000`)),
51
+ })
52
+ })
53
+
54
+ it("stops the run on PreCompact, whatever the count", () => {
55
+ expect(hookOutput(at(1_000, "PreCompact")!)).toEqual({
56
+ continue: false,
57
+ stopReason: expect.stringContaining(
58
+ `${CONTEXT_LIMIT_MARKER}: the context is about to be compacted`,
59
+ ),
60
+ })
61
+ })
62
+
63
+ it("lets a run with no usage yet go on", () => {
64
+ const limit = DEFAULT_CONTEXT_LIMIT
65
+ expect(
66
+ contextHook({ event: "PostToolUse", tokens: undefined, window: 1, limit }),
67
+ ).toBeUndefined()
68
+ })
69
+ })
70
+
71
+ describe("contextLimitFault", () => {
72
+ it("accepts shares in (0, 1] with soft below hard", () => {
73
+ expect(contextLimitFault({ soft: 0.5, hard: 1 })).toBeUndefined()
74
+ expect(contextLimitFault({ hard: 0.9 })).toBeUndefined()
75
+ })
76
+
77
+ it("refuses a share outside (0, 1] and a soft limit at or above the hard one", () => {
78
+ expect(contextLimitFault({ soft: 0 })).toMatch(/context\.soft must be/)
79
+ expect(contextLimitFault({ hard: 70 })).toMatch(/context\.hard must be/)
80
+ expect(contextLimitFault({ soft: 0.8 })).toMatch(/below context\.hard \(0\.7\)/)
81
+ const misspelt: { hard: number } = Object.assign({ hard: 0.8 }, { sotf: 0.5 })
82
+ expect(contextLimitFault(misspelt)).toBe("unknown key(s) sotf in context")
83
+ })
84
+ })
85
+
86
+ it("reads the long window from Claude Code's [1m] suffix only", () => {
87
+ expect(contextWindowFor("opus[1m]")).toBe(1_000_000)
88
+ expect(contextWindowFor("opus")).toBe(200_000)
89
+ expect(contextWindowFor(undefined)).toBe(200_000)
90
+ })
91
+
92
+ it("finds a worker's own transcript beside the main session's", () => {
93
+ expect(subagentTranscriptPath("/p/session.jsonl", "a1")).toBe(
94
+ "/p/session/subagents/agent-a1.jsonl",
95
+ )
96
+ })
@@ -0,0 +1,169 @@
1
+ // The context-limit decision every driver shares: the loop's `gtd context-hook`
2
+ // and the Claude Code mod both answer a hook event through `contextHook`, so
3
+ // the two cannot drift on thresholds, wording or which tokens count.
4
+
5
+ /** A share of the model's context window, each in (0, 1]; `soft` below `hard`. */
6
+ export interface ContextLimit {
7
+ readonly soft: number
8
+ readonly hard: number
9
+ }
10
+
11
+ export const DEFAULT_CONTEXT_LIMIT: ContextLimit = { soft: 0.6, hard: 0.7 }
12
+
13
+ const DEFAULT_WINDOW = 200_000
14
+ const LONG_WINDOW = 1_000_000
15
+
16
+ /** Claude Code's `[1m]` suffix selects the long window; a transcript never names it, so the driver decides from the model it passed. */
17
+ export const contextWindowFor = (model: string | undefined): number =>
18
+ model !== undefined && /\[1m\]/i.test(model) ? LONG_WINDOW : DEFAULT_WINDOW
19
+
20
+ /** A positive whole number of tokens, or `undefined` for anything else. */
21
+ export const parseContextWindow = (raw: string | undefined): number | undefined => {
22
+ const n = Number(raw)
23
+ return raw !== undefined && raw.trim() !== "" && Number.isInteger(n) && n > 0 ? n : undefined
24
+ }
25
+
26
+ /** `gtd context-hook`'s request: the hook's stdin and the limits its flags set. */
27
+ export interface ContextHookRequest {
28
+ readonly input: string
29
+ readonly soft?: number
30
+ readonly hard?: number
31
+ readonly window?: number
32
+ }
33
+
34
+ /**
35
+ * Where a driver asks `gtd context-hook` to record a hard stop's `stopReason`:
36
+ * Claude Code prints it nowhere a driver reads, and `hook_stopped` alone would
37
+ * also name any other hook that stopped the turn.
38
+ */
39
+ export const CONTEXT_STOP_FILE_ENV = "GTD_LOOP_CONTEXT_STOP_FILE"
40
+
41
+ /**
42
+ * Why one layer of a limit (a scope's, a step's) is malformed, ignoring how its
43
+ * two shares relate: layers merge key by key, so only the merged limit has to
44
+ * keep `soft` below `hard`. Config is evaluated untyped, so anything may arrive.
45
+ */
46
+ export const contextShapeFault = (limit: unknown): string | undefined => {
47
+ if (typeof limit !== "object" || limit === null || Array.isArray(limit)) {
48
+ return `context must be an object { soft, hard }, got ${JSON.stringify(limit)}`
49
+ }
50
+ const unknown = Object.keys(limit).filter((key) => key !== "soft" && key !== "hard")
51
+ if (unknown.length > 0) return `unknown key(s) ${unknown.join(", ")} in context`
52
+ const shares = limit as { readonly soft?: unknown; readonly hard?: unknown }
53
+ for (const key of ["soft", "hard"] as const) {
54
+ const value = shares[key]
55
+ if (value === undefined) continue
56
+ if (typeof value !== "number" || !(value > 0 && value <= 1)) {
57
+ return `context.${key} must be a share of the context window in (0, 1], got ${JSON.stringify(value)}`
58
+ }
59
+ }
60
+ return undefined
61
+ }
62
+
63
+ /** Why a whole limit is unusable, or `undefined` when it is fine. */
64
+ export const contextLimitFault = (limit: unknown): string | undefined => {
65
+ const shape = contextShapeFault(limit)
66
+ if (shape !== undefined) return shape
67
+ const shares = limit as { readonly soft?: unknown; readonly hard?: unknown }
68
+ const soft = (shares.soft as number | undefined) ?? DEFAULT_CONTEXT_LIMIT.soft
69
+ const hard = (shares.hard as number | undefined) ?? DEFAULT_CONTEXT_LIMIT.hard
70
+ return soft < hard ? undefined : `context.soft (${soft}) must be below context.hard (${hard})`
71
+ }
72
+
73
+ const record = (value: unknown): Record<string, unknown> | undefined =>
74
+ typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined
75
+
76
+ const count = (value: unknown): number => (typeof value === "number" && value > 0 ? value : 0)
77
+
78
+ /** What a message's usage puts in the context window: its whole input, cached or not. */
79
+ export const usageTokens = (usage: unknown): number | undefined => {
80
+ const u = record(usage)
81
+ if (u === undefined) return undefined
82
+ return (
83
+ count(u["input_tokens"]) +
84
+ count(u["cache_read_input_tokens"]) +
85
+ count(u["cache_creation_input_tokens"])
86
+ )
87
+ }
88
+
89
+ /**
90
+ * The context size of the latest assistant message in a Claude Code transcript
91
+ * (one JSON entry per line). Usage is per message, not a running sum, so the
92
+ * last entry alone is the window's fill.
93
+ */
94
+ export const transcriptTokens = (transcript: string): number | undefined => {
95
+ const lines = transcript.split("\n")
96
+ for (let i = lines.length - 1; i >= 0; i--) {
97
+ const line = lines[i]!.trim()
98
+ if (line === "") continue
99
+ let entry: unknown
100
+ try {
101
+ entry = JSON.parse(line)
102
+ } catch {
103
+ continue
104
+ }
105
+ if (record(entry)?.["type"] !== "assistant") continue
106
+ const tokens = usageTokens(record(record(entry)?.["message"])?.["usage"])
107
+ if (tokens !== undefined) return tokens
108
+ }
109
+ return undefined
110
+ }
111
+
112
+ /**
113
+ * A worker subagent's own transcript. A hook its tool call fires is handed the
114
+ * MAIN session's `transcript_path`; the worker's sits beside it.
115
+ */
116
+ export const subagentTranscriptPath = (transcriptPath: string, agentId: string): string =>
117
+ `${transcriptPath.replace(/\.jsonl$/, "")}/subagents/agent-${agentId}.jsonl`
118
+
119
+ /** Every hard stop's `stopReason` starts with this: the driver's context-limit marker. */
120
+ export const CONTEXT_LIMIT_MARKER = "gtd: context limit"
121
+
122
+ export type ContextEvent = "PostToolUse" | "PreCompact"
123
+
124
+ export type ContextDecision =
125
+ | { readonly kind: "soft"; readonly reason: string }
126
+ | { readonly kind: "hard"; readonly stopReason: string }
127
+
128
+ const percent = (share: number): string => `${Math.round(share * 100)}%`
129
+
130
+ /**
131
+ * What a hook answers, or `undefined` to let the run go on. Compaction about
132
+ * to happen means the limit was missed, so `PreCompact` is a hard stop whatever
133
+ * the count says.
134
+ */
135
+ export const contextHook = (input: {
136
+ readonly event: ContextEvent
137
+ readonly tokens: number | undefined
138
+ readonly window: number
139
+ readonly limit: ContextLimit
140
+ }): ContextDecision | undefined => {
141
+ const { event, tokens, window, limit } = input
142
+ if (event === "PreCompact") {
143
+ return {
144
+ kind: "hard",
145
+ stopReason: `${CONTEXT_LIMIT_MARKER}: the context is about to be compacted — the run is too big for one turn`,
146
+ }
147
+ }
148
+ if (tokens === undefined) return undefined
149
+ const used = `${tokens} of ${window} tokens`
150
+ if (tokens >= limit.hard * window) {
151
+ return {
152
+ kind: "hard",
153
+ stopReason: `${CONTEXT_LIMIT_MARKER}: ${used}, past the ${percent(limit.hard)} hard limit`,
154
+ }
155
+ }
156
+ if (tokens >= limit.soft * window) {
157
+ return {
158
+ kind: "soft",
159
+ 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.`,
160
+ }
161
+ }
162
+ return undefined
163
+ }
164
+
165
+ /** A decision as Claude Code's settings-hook JSON output. */
166
+ export const hookOutput = (decision: ContextDecision): Record<string, unknown> =>
167
+ decision.kind === "soft"
168
+ ? { decision: "block", reason: decision.reason }
169
+ : { continue: false, stopReason: decision.stopReason }
package/src/wire/index.ts CHANGED
@@ -11,3 +11,4 @@ export { selectPath } from "./Select.js"
11
11
  export { resolveSession } from "./Sessions.js"
12
12
  export { globMatches } from "./Glob.js"
13
13
  export * from "./Slices.js"
14
+ export * from "./ContextLimit.js"
package/src/wire/types.ts CHANGED
@@ -38,6 +38,12 @@ export interface ApprovalEntry {
38
38
  readonly commit: string
39
39
  }
40
40
 
41
+ /** The context-limit shares a step sets; a driver fills an absent one from its defaults. */
42
+ export interface StepContextLimit {
43
+ readonly soft?: number
44
+ readonly hard?: number
45
+ }
46
+
41
47
  export interface RenderedDemandSource {
42
48
  readonly state: StateName
43
49
  readonly actor: Actor
@@ -51,4 +57,5 @@ export interface RenderedDemandSource {
51
57
  readonly judge?: string
52
58
  readonly skills?: readonly string[]
53
59
  readonly access?: StepAccess
60
+ readonly context?: StepContextLimit
54
61
  }
@@ -1,5 +1,12 @@
1
1
  import type { WorkflowBase, Flow, ScopeAccess, Summary } from "../flows/index.js"
2
- import type { Actor, ContentKind, StateMode, StateName, StepAccess } from "../wire/index.js"
2
+ import type {
3
+ Actor,
4
+ ContentKind,
5
+ StateMode,
6
+ StateName,
7
+ StepAccess,
8
+ StepContextLimit,
9
+ } from "../wire/index.js"
3
10
 
4
11
  export type { Actor, ContentKind, StateMode, StateName }
5
12
 
@@ -44,6 +51,7 @@ export interface StepDef {
44
51
  readonly approve?: boolean
45
52
  readonly skills?: readonly string[]
46
53
  readonly access?: StepAccess
54
+ readonly context?: StepContextLimit
47
55
  }
48
56
 
49
57
  /**
@@ -0,0 +1,37 @@
1
+ @inmem
2
+ Feature: the shipped workflows take a context limit as an option
3
+
4
+ `contextLimit: { soft, hard }` is a factory option: every agent step of the
5
+ workflow runs under it, carried as `gtd next --json`'s `context`. Without it a
6
+ step carries none and the driver's defaults apply.
7
+
8
+ Scenario Outline: every agent step carries the configured limit, or none
9
+ Given a gtd config with:
10
+ """
11
+ import { feature } from "@pmelab/gtd/workflows/feature"
12
+ export { summary, steering, skills, access } from "@pmelab/gtd/workflows/feature"
13
+ export default feature(<options>)
14
+ """
15
+ And a file "src/calc.ts" with:
16
+ """
17
+ export const add = (a: number, b: number) => a + b
18
+ """
19
+ And the working tree is committed as "feat: add calculator"
20
+ And a file "TODO.md" with:
21
+ """
22
+ # Add subtract
23
+
24
+ subtract(a, b) returns the difference.
25
+ """
26
+ And step "unwind" changes nothing
27
+ And step "start-gate.check" changes nothing
28
+ When the process is driven
29
+ Then the process rests at "design.triage"
30
+ And the output of gtd "next --json" <carries>
31
+
32
+ Examples:
33
+ | options | carries |
34
+ | { contextLimit: { soft: 0.5, hard: 0.65 } } | contains "\"context\":{\"soft\":0.5,\"hard\":0.65}" |
35
+ | { contextLimit: { hard: 0.8 } } | contains "\"context\":{\"hard\":0.8}" |
36
+ | {} | does not contain "\"context\"" |
37
+
@@ -11,7 +11,13 @@ import {
11
11
  import { healthy } from "../shared/health.js"
12
12
  import { coder, FEEDBACK, REQUIREMENTS } from "../shared/steps.js"
13
13
  import * as t from "../shared/text.js"
14
- import { criteriaOf, requireCriteriaFrozen } from "./criteria.js"
14
+ import {
15
+ criteriaOf,
16
+ criterionIds,
17
+ requireCriteriaFrozen,
18
+ sliceIds,
19
+ type CriteriaSlice,
20
+ } from "./criteria.js"
15
21
  import { isTestFile } from "./refactor.js"
16
22
  import { isCode } from "./review.js"
17
23
 
@@ -23,7 +29,7 @@ const COVERING_RETRIES = 2
23
29
 
24
30
  const frozen = (): void => requireCriteriaFrozen(REQUIREMENTS)
25
31
 
26
- export const green = (): Promise<void> =>
32
+ export const green = (guard?: () => void): Promise<void> =>
27
33
  healthy(
28
34
  () =>
29
35
  t.agentWithSkills("fix.suite.fixing", t.implementFixPrompt(), {
@@ -32,7 +38,13 @@ export const green = (): Promise<void> =>
32
38
  model: coder(),
33
39
  system: t.builderSystem(),
34
40
  }),
35
- { guard: frozen, guardRests: false },
41
+ {
42
+ guard: () => {
43
+ frozen()
44
+ guard?.()
45
+ },
46
+ guardRests: false,
47
+ },
36
48
  )
37
49
 
38
50
  // A budget cut keeps the end, so the files naming a criterion ID go last.
@@ -45,13 +57,15 @@ const testsSince = (base: string): string =>
45
57
 
46
58
  /**
47
59
  * The criterion IDs of `.gtd/REQUIREMENTS.md` — read afresh, never carried
48
- * over — no passing test since `base` covers, by the judge.
60
+ * over, only `slice`'s when given — no passing test since `base` covers, by the judge.
49
61
  */
50
62
  const uncoveredSince = async (
51
63
  base: string,
64
+ slice: CriteriaSlice | undefined,
52
65
  ): Promise<{ readonly covered: string[]; readonly uncovered: string[] }> => {
53
66
  const requirements = read(REQUIREMENTS) ?? ""
54
- const criteria = (criteriaOf(requirements) ?? []).filter((c) => c.id !== "")
67
+ const own = criterionIds(requirements, slice)
68
+ const criteria = (criteriaOf(requirements) ?? []).filter((c) => own.includes(c.id))
55
69
  if (criteria.length === 0) return { covered: [], uncovered: [] }
56
70
  const { answers } = await judge("coverage", {
57
71
  questions: criteria.map(t.coverageQuestion),
@@ -75,14 +89,14 @@ const record = (covered: readonly string[], uncovered: readonly string[] = []):
75
89
  * sending uncovered IDs back to a covering turn; marks the IDs covered on the
76
90
  * landing that ends it. A human may overrule once the retries are spent.
77
91
  */
78
- export const requireCoverage = async (base = start()): Promise<void> => {
92
+ export const requireCoverage = async (base = start(), slice?: CriteriaSlice): Promise<void> => {
79
93
  let retries = 0
80
94
  for (;;) {
81
- const { covered, uncovered } = await uncoveredSince(base)
95
+ const { covered, uncovered } = await uncoveredSince(base, slice)
82
96
  if (uncovered.length === 0) return record(covered)
83
97
  if (retries < COVERING_RETRIES) {
84
98
  retries++
85
- await t.agentWithSkills("covering", t.coveringPrompt(uncovered), {
99
+ await t.agentWithSkills("covering", t.coveringPrompt(uncovered, sliceIds(slice)), {
86
100
  label: "Covering the acceptance criteria",
87
101
  model: coder(),
88
102
  system: t.builderSystem(),
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
  import type { Change } from "../../flows/index.js"
3
- import { criteriaOf, statedCriteria } from "./criteria.js"
3
+ import { criteriaOf, criterionIds, statedCriteria } from "./criteria.js"
4
4
 
5
5
  const added = (path: string, after: string): Change => ({
6
6
  path,
@@ -104,3 +104,19 @@ describe("statedCriteria", () => {
104
104
  ).toBeUndefined()
105
105
  })
106
106
  })
107
+
108
+ describe("criterionIds", () => {
109
+ const requirements = "## Acceptance Criteria\n\n- AC-1: a\n- AC-2: b\n- AC-4: added\n- unnamed\n"
110
+
111
+ it("lists every ID'd criterion of an unsliced change", () => {
112
+ expect(criterionIds(requirements)).toEqual(["AC-1", "AC-2", "AC-4"])
113
+ })
114
+
115
+ it("keeps `only` to the IDs the file still lists", () => {
116
+ expect(criterionIds(requirements, { only: ["AC-1", "AC-3"] })).toEqual(["AC-1"])
117
+ })
118
+
119
+ it("gives `except` every other ID, one added since included", () => {
120
+ expect(criterionIds(requirements, { except: ["AC-1", "AC-3"] })).toEqual(["AC-2", "AC-4"])
121
+ })
122
+ })
@@ -8,7 +8,7 @@ import {
8
8
  scope,
9
9
  type Change,
10
10
  } from "../../flows/index.js"
11
- import { markFenced } from "../shared/fences.js"
11
+ import { HEADING, ITEM, markFenced } from "../shared/fences.js"
12
12
  import { REQUIREMENTS } from "../shared/steps.js"
13
13
  import * as t from "../shared/text.js"
14
14
 
@@ -29,8 +29,6 @@ export interface StatedCriteria {
29
29
  /** Below this the judge's `yes` is not trusted: a wrong skip builds against criteria nobody can check. */
30
30
  const CLEAR_MIN_P = 0.8
31
31
 
32
- const HEADING = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/
33
- const ITEM = /^ ?(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?(.*)$/
34
32
  const ID = /^(AC-\d+):\s*(.*)$/
35
33
 
36
34
  /** The list items under the first heading `title` matches (at `level` when given), up to the next heading; wrapped lines joined, fenced lines ignored. */
@@ -71,6 +69,31 @@ export const criteriaOf = (text: string): readonly Criterion[] | undefined =>
71
69
  return id === null ? { id: "", text: item } : { id: id[1]!, text: id[2]! }
72
70
  })
73
71
 
72
+ /**
73
+ * Which criteria one slice builds, resolved against the file whenever read:
74
+ * `only` drops an ID a steer deleted, `except` (the last slice) takes one a
75
+ * steer added.
76
+ */
77
+ export interface CriteriaSlice {
78
+ readonly only?: readonly string[]
79
+ readonly except?: readonly string[]
80
+ }
81
+
82
+ /** The ID'd criteria of `text`, only `slice`'s when given. */
83
+ export const criterionIds = (text: string, slice?: CriteriaSlice): string[] =>
84
+ (criteriaOf(text) ?? [])
85
+ .map((c) => c.id)
86
+ .filter(
87
+ (id) =>
88
+ id !== "" &&
89
+ (slice?.only === undefined || slice.only.includes(id)) &&
90
+ !(slice?.except ?? []).includes(id),
91
+ )
92
+
93
+ /** `slice`'s IDs in `.gtd/REQUIREMENTS.md` as it is now; `undefined` for an unsliced change. */
94
+ export const sliceIds = (slice: CriteriaSlice | undefined): string[] | undefined =>
95
+ slice === undefined ? undefined : criterionIds(read(REQUIREMENTS) ?? "", slice)
96
+
74
97
  /** Items numbered `AC-1`…, unless every one already carries its own distinct ID. */
75
98
  const numbered = (items: readonly string[]): Criterion[] => {
76
99
  const ids = items.map((item) => ID.exec(item))
@@ -11,21 +11,29 @@ import {
11
11
  } from "../../flows/index.js"
12
12
  import { gate, healthy } from "../shared/health.js"
13
13
  import { flagModeOn, shipped } from "../shared/options.js"
14
- import { FEEDBACK, fixCheck, QUALITY } from "../shared/steps.js"
14
+ import { FEEDBACK, fixCheck, QUALITY, REQUIREMENTS } from "../shared/steps.js"
15
15
  import { refusePendingHandOff, tail } from "../shared/tail.js"
16
16
  import * as t from "../shared/text.js"
17
- import { skipDesign, statedCriteria, type StatedCriteria } from "./criteria.js"
17
+ import {
18
+ requireCriteriaFrozen,
19
+ skipDesign,
20
+ sliceIds,
21
+ statedCriteria,
22
+ type CriteriaSlice,
23
+ type StatedCriteria,
24
+ } from "./criteria.js"
18
25
  import { design } from "./planning.js"
19
26
  import { buildTail, isCode, reUnwind, type ReviewOutcome } from "./review.js"
20
- import { implement, seamBuild } from "./seam.js"
21
- import { recordRisk, riskTierOn } from "./tier.js"
27
+ import { closeOut, implement, seamBuild } from "./seam.js"
28
+ import { sliceCap, sliceCriteria, slicing } from "./slicing.js"
29
+ import { recordRisk, riskTierOn, type RiskTier } from "./tier.js"
22
30
 
23
31
  // The ordinary start. Any change to the tree starts a process: `idle` →
24
32
  // `unwind` reverts the sketch (its intent survives in history) → a
25
- // green-baseline gate → design → a naive pass, judged with `flagMode` on: it
26
- // lands as the implementation, or (refactoring first when the judge asks) is
27
- // built fresh from → the health loop → the quality lap, and the episode ends
28
- // back at `idle`. Human review is the `review` door's.
33
+ // green-baseline gate → design → per slice of the criteria, a naive pass,
34
+ // judged with `flagMode` on: it lands as the implementation, or (refactoring
35
+ // first when the judge asks) is built fresh from → the health loop → the
36
+ // quality lap, and the episode ends back at `idle`. Human review is the `review` door's.
29
37
 
30
38
  /** Revert the sketch that started the process out of the working tree; its intent survives in history. */
31
39
  export const unwind = (): Promise<void> => {
@@ -37,23 +45,67 @@ export const unwind = (): Promise<void> => {
37
45
 
38
46
  export { reUnwind }
39
47
 
40
- /** A naive pass (judged only with `flagMode` on), then implement and the quality lap; design must have run since `firstBase`. */
48
+ /**
49
+ * The slices `design.slicing` grouped the criteria into, each built in turn
50
+ * from where the previous one landed; design must have run since `firstBase`.
51
+ */
41
52
  export const buildFeature = async (firstBase: string): Promise<void> => {
42
53
  const tier = riskTierOn() ? recordRisk() : undefined
43
- const seam = await seamBuild(firstBase)
44
- await slice("feature", async () => {
54
+ const slices = await slicing()
55
+ // A single slice keeps the unnumbered names and builds against every criterion.
56
+ const one = slices.length === 1
57
+ for (const i of slices.keys()) {
58
+ const more = i < slices.length - 1
59
+ const criteria = sliceCriteria(slices, i)
60
+ // A steer may have deleted every criterion of a later slice: nothing to build.
61
+ if (sliceIds(criteria)?.length === 0) {
62
+ if (!more) await closeOut()
63
+ continue
64
+ }
65
+ const n = one ? "" : `-${i + 1}`
66
+ await buildSlice(i === 0 ? firstBase : head(), {
67
+ tier,
68
+ criteria,
69
+ refactor: `refactor${n}`,
70
+ feature: `feature${n}`,
71
+ more,
72
+ })
73
+ }
74
+ }
75
+
76
+ interface SliceBuild {
77
+ readonly tier: RiskTier | undefined
78
+ readonly criteria: CriteriaSlice | undefined
79
+ readonly refactor: string
80
+ readonly feature: string
81
+ readonly more: boolean
82
+ }
83
+
84
+ /** A naive pass (judged only with `flagMode` on), then implement and the quality lap. */
85
+ const buildSlice = async (sliceBase: string, build: SliceBuild): Promise<void> => {
86
+ const seam = await seamBuild(sliceBase, { criteria: build.criteria, refactor: build.refactor })
87
+ await slice(build.feature, async () => {
45
88
  await implement({
46
89
  reference: seam.reference,
47
- tier,
90
+ tier: build.tier,
48
91
  afterRefactor: seam.refactored,
49
92
  naive: seam.land === true ? seam.reference : undefined,
93
+ rebuild: seam.rebuild,
94
+ slice: build.criteria,
95
+ base: build.criteria === undefined ? undefined : sliceBase,
96
+ more: build.more,
50
97
  })
51
- // An empty feature slice: the refactor carried the feature, so the lap covers the whole change.
98
+ // A feature slice with no code beyond the carried-over tests: the refactor carried the
99
+ // feature, so the lap covers the whole slice.
100
+ const carried = seam.rebuild?.acceptance?.tests ?? []
52
101
  const base =
53
- seam.refactored === true && !changesSince(seam.base).some((c) => isCode(c.path))
54
- ? firstBase
102
+ seam.refactored === true &&
103
+ !changesSince(seam.base).some((c) => isCode(c.path) && !carried.includes(c.path))
104
+ ? sliceBase
55
105
  : seam.base
56
- await tail(settleQuality, { green: true, lensBase: base })
106
+ // The criteria stay in the tree for the next slice; no quality fix may move them.
107
+ const guard = build.more ? () => requireCriteriaFrozen(REQUIREMENTS) : undefined
108
+ await tail(settleQuality, { green: true, lensBase: base, guard, guardRests: false })
57
109
  })
58
110
  }
59
111
 
@@ -99,6 +151,7 @@ export const ordinaryStart = async (): Promise<void> => {
99
151
  refusePendingHandOff()
100
152
  // Read right after idle, so a bad value refuses the landing that would pin it.
101
153
  flagModeOn()
154
+ sliceCap()
102
155
  const stated = statedCriteria(changes(), head())
103
156
  await unwind()
104
157
  if (changes(FEEDBACK).some((c) => c.status !== "deleted")) {
@@ -5,6 +5,7 @@ export * from "./criteria.js"
5
5
  export * from "./planning.js"
6
6
  export * from "./refactor.js"
7
7
  export * from "./seam.js"
8
+ export * from "./slicing.js"
8
9
  export {
9
10
  buildTail,
10
11
  type BuildTailOptions,