@pmelab/gtd 36.0.0 → 37.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +21 -9
- package/claude/hooks/context.ts +66 -0
- package/claude/hooks/drive.ts +3 -0
- package/claude/hooks/register.tsx +69 -6
- package/dist/chunks/{reserved-BAv71I4i.mjs → reserved-zAfoJtR7.mjs} +53 -16
- package/dist/chunks/{tail-kar1zhOb.mjs → tail-BRC4DGdV.mjs} +1 -1
- package/dist/chunks/{wire-D3Rc5r22.mjs → wire-CSsa3Wxd.mjs} +117 -2
- package/dist/gtd.bundle.mjs +498 -56
- package/dist/testkit.mjs +115 -3
- package/dist/wire.mjs +2 -2
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +126 -33
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +2 -2
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +174 -44
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +10 -3
- package/src/cli/Cli.ts +76 -1
- package/src/cli/Program.test.ts +1 -0
- package/src/cli/Program.ts +28 -0
- package/src/cli/ToolCommands.ts +3 -0
- package/src/edge/Edge.ts +11 -0
- package/src/flows/runtime.ts +11 -0
- package/src/replay/Replay.ts +51 -2
- package/src/testkit/Layers.ts +1 -0
- package/src/wire/BeatDocument.ts +12 -2
- package/src/wire/BeatStatus.ts +3 -0
- package/src/wire/ContextLimit.fixture.ts +30 -0
- package/src/wire/ContextLimit.test.ts +96 -0
- package/src/wire/ContextLimit.ts +169 -0
- package/src/wire/index.ts +1 -0
- package/src/wire/types.ts +7 -0
- package/src/workflow/Workflow.ts +9 -1
- package/src/workflows/feature/context-limit.feature +37 -0
- package/src/workflows/feature/coverage.ts +8 -2
- package/src/workflows/feature/feature.ts +6 -2
- package/src/workflows/feature/refactor-absorbs.feature +12 -5
- package/src/workflows/feature/refactor-first.feature +11 -2
- package/src/workflows/feature/refactor-lens-models.feature +4 -0
- package/src/workflows/feature/refactor-rebuild.feature +269 -0
- package/src/workflows/feature/refactor-review.feature +45 -1
- package/src/workflows/feature/refactor.ts +9 -3
- package/src/workflows/feature/seam.test.ts +108 -3
- package/src/workflows/feature/seam.ts +120 -19
- package/src/workflows/shared/options.test.ts +11 -2
- package/src/workflows/shared/options.ts +18 -4
- package/src/workflows/shared/steps.ts +1 -0
- package/src/workflows/shared/text.ts +80 -14
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")
|
package/src/flows/runtime.ts
CHANGED
|
@@ -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. */
|
package/src/replay/Replay.ts
CHANGED
|
@@ -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) {
|
package/src/testkit/Layers.ts
CHANGED
|
@@ -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()"),
|
package/src/wire/BeatDocument.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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,
|
package/src/wire/BeatStatus.ts
CHANGED
|
@@ -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"
|
|
@@ -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
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
|
}
|
package/src/workflow/Workflow.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { WorkflowBase, Flow, ScopeAccess, Summary } from "../flows/index.js"
|
|
2
|
-
import type {
|
|
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
|
+
|
|
@@ -23,7 +23,7 @@ const COVERING_RETRIES = 2
|
|
|
23
23
|
|
|
24
24
|
const frozen = (): void => requireCriteriaFrozen(REQUIREMENTS)
|
|
25
25
|
|
|
26
|
-
export const green = (): Promise<void> =>
|
|
26
|
+
export const green = (guard?: () => void): Promise<void> =>
|
|
27
27
|
healthy(
|
|
28
28
|
() =>
|
|
29
29
|
t.agentWithSkills("fix.suite.fixing", t.implementFixPrompt(), {
|
|
@@ -32,7 +32,13 @@ export const green = (): Promise<void> =>
|
|
|
32
32
|
model: coder(),
|
|
33
33
|
system: t.builderSystem(),
|
|
34
34
|
}),
|
|
35
|
-
{
|
|
35
|
+
{
|
|
36
|
+
guard: () => {
|
|
37
|
+
frozen()
|
|
38
|
+
guard?.()
|
|
39
|
+
},
|
|
40
|
+
guardRests: false,
|
|
41
|
+
},
|
|
36
42
|
)
|
|
37
43
|
|
|
38
44
|
// A budget cut keeps the end, so the files naming a criterion ID go last.
|
|
@@ -47,10 +47,14 @@ export const buildFeature = async (firstBase: string): Promise<void> => {
|
|
|
47
47
|
tier,
|
|
48
48
|
afterRefactor: seam.refactored,
|
|
49
49
|
naive: seam.land === true ? seam.reference : undefined,
|
|
50
|
+
rebuild: seam.rebuild,
|
|
50
51
|
})
|
|
51
|
-
//
|
|
52
|
+
// A feature slice with no code beyond the carried-over tests: the refactor carried the
|
|
53
|
+
// feature, so the lap covers the whole change.
|
|
54
|
+
const carried = seam.rebuild?.acceptance?.tests ?? []
|
|
52
55
|
const base =
|
|
53
|
-
seam.refactored === true &&
|
|
56
|
+
seam.refactored === true &&
|
|
57
|
+
!changesSince(seam.base).some((c) => isCode(c.path) && !carried.includes(c.path))
|
|
54
58
|
? firstBase
|
|
55
59
|
: seam.base
|
|
56
60
|
await tail(settleQuality, { green: true, lensBase: base })
|