@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +96 -131
- package/claude/hooks/drive.ts +0 -3
- package/claude/hooks/register.tsx +3 -68
- package/claude/hooks/ship.ts +85 -1595
- package/dist/chunks/{reserved-D0MKVFFa.mjs → reserved-CQ7amcKG.mjs} +87 -133
- package/dist/chunks/{review-ZzUQH1KZ.mjs → review-CNgN_V8T.mjs} +2 -2
- package/dist/chunks/{tail-I1-vVq0P.mjs → tail-JZ9PAOwp.mjs} +2 -14
- package/dist/chunks/{wire-BJuZWR3C.mjs → wire-C12dahrH.mjs} +2 -130
- package/dist/flows.mjs +1 -16
- package/dist/gtd.bundle.mjs +476 -982
- package/dist/testkit.mjs +58 -417
- package/dist/wire.mjs +2 -2
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +159 -363
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +3 -3
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +400 -675
- package/package.json +5 -4
- package/src/cli/Cli.test.ts +1 -86
- package/src/cli/Cli.ts +13 -133
- package/src/cli/Metrics.test.ts +21 -31
- package/src/cli/Metrics.ts +25 -36
- package/src/cli/Program.test.ts +1 -35
- package/src/cli/Program.ts +5 -64
- package/src/cli/ToolCommands.ts +0 -3
- package/src/edge/Edge.test.ts +20 -322
- package/src/edge/Edge.ts +10 -181
- package/src/edge/index.ts +0 -1
- package/src/flows/runtime.test.ts +0 -27
- package/src/flows/runtime.ts +1 -49
- package/src/install/Install.ts +15 -24
- package/src/replay/Replay.test.ts +0 -169
- package/src/replay/Replay.ts +4 -93
- package/src/replay/index.ts +0 -1
- package/src/testkit/Layers.ts +0 -1
- package/src/testkit/steps.ts +13 -0
- package/src/wire/BeatDocument.ts +2 -12
- package/src/wire/BeatStatus.ts +0 -3
- package/src/wire/WireSchema.test.ts +0 -18
- package/src/wire/index.ts +0 -2
- package/src/wire/types.ts +0 -7
- package/src/workflow/Workflow.ts +1 -9
- package/src/workflows/feature/acceptance-criteria.feature +159 -3
- package/src/workflows/feature/criteria.test.ts +23 -15
- package/src/workflows/feature/criteria.ts +72 -39
- package/src/workflows/feature/fast-path.feature +4 -0
- package/src/workflows/feature/feature.test.ts +6 -5
- package/src/workflows/feature/feature.ts +34 -118
- package/src/workflows/feature/flag-mode.feature +4 -0
- package/src/workflows/feature/hands-on-skip.feature +37 -65
- package/src/workflows/feature/index.ts +0 -1
- package/src/workflows/feature/lens-models.feature +4 -0
- package/src/workflows/feature/lens-review.feature +4 -0
- package/src/workflows/feature/naive-acceptance.feature +4 -0
- package/src/workflows/feature/one-step-land.feature +4 -4
- package/src/workflows/feature/planning.ts +2 -1
- package/src/workflows/feature/prototype-rounds.feature +5 -1
- package/src/workflows/feature/prototype.feature +11 -3
- package/src/workflows/feature/prototype.ts +18 -29
- package/src/workflows/feature/refactor-absorbs.feature +7 -8
- package/src/workflows/feature/refactor-first.feature +4 -5
- package/src/workflows/feature/refactor-lens-models.feature +4 -0
- package/src/workflows/feature/refactor-rebuild.feature +4 -6
- package/src/workflows/feature/refactor-review.feature +31 -11
- package/src/workflows/feature/refactor.ts +30 -64
- package/src/workflows/feature/review-answers.feature +4 -0
- package/src/workflows/feature/review-gate.feature +13 -1
- package/src/workflows/feature/reviewer-walkthrough.feature +4 -0
- package/src/workflows/feature/risk-tier.feature +32 -0
- package/src/workflows/feature/seam-rejection.feature +8 -4
- package/src/workflows/feature/seam.feature +8 -0
- package/src/workflows/feature/seam.test.ts +11 -28
- package/src/workflows/feature/seam.ts +52 -165
- package/src/workflows/feature/severity-budget.feature +4 -0
- package/src/workflows/feature/severity-sorted-review.feature +4 -0
- package/src/workflows/feature/standards.feature +4 -0
- package/src/workflows/feature/tier-pin.feature +4 -0
- package/src/workflows/index.ts +0 -1
- package/src/workflows/shared/lenses.ts +0 -13
- package/src/workflows/shared/options.test.ts +2 -11
- package/src/workflows/shared/options.ts +4 -21
- package/src/workflows/shared/review.test.ts +3 -3
- package/src/workflows/shared/steps.ts +0 -2
- package/src/workflows/shared/text.test.ts +41 -71
- package/src/workflows/shared/text.ts +82 -212
- package/src/workflows/shared/vars.ts +2 -8
- package/src/workflows/shared/workflows.steps.ts +0 -13
- package/claude/hooks/context.ts +0 -66
- package/src/replay/Slices.test.ts +0 -73
- package/src/replay/Slices.ts +0 -45
- package/src/wire/ContextLimit.fixture.ts +0 -30
- package/src/wire/ContextLimit.test.ts +0 -96
- package/src/wire/ContextLimit.ts +0 -169
- package/src/wire/Slices.ts +0 -25
- package/src/wire/golden/1/slices.json +0 -11
- package/src/workflows/feature/context-limit.feature +0 -37
- package/src/workflows/feature/continuation-cap.feature +0 -65
- package/src/workflows/feature/continuation-rebuild.feature +0 -88
- package/src/workflows/feature/continuation-slices.feature +0 -64
- package/src/workflows/feature/continuation.feature +0 -205
- package/src/workflows/feature/slice-findings.feature +0 -252
- package/src/workflows/feature/slicing-cap.feature +0 -23
- package/src/workflows/feature/slicing.feature +0 -265
- package/src/workflows/feature/slicing.test.ts +0 -49
- package/src/workflows/feature/slicing.ts +0 -105
- package/src/workflows/shared/continuation.ts +0 -92
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest"
|
|
2
|
-
import { sliceRanges, type SliceSpan } from "./Slices.js"
|
|
3
|
-
|
|
4
|
-
const span = (name: string, enteredAt: string, leftAt?: string): SliceSpan => ({
|
|
5
|
-
name,
|
|
6
|
-
enteredAt,
|
|
7
|
-
leftAt,
|
|
8
|
-
})
|
|
9
|
-
const running = { base: "B", tip: "T" }
|
|
10
|
-
|
|
11
|
-
describe("sliceRanges", () => {
|
|
12
|
-
it("two adjacent spans: first from episode.base to its leftAt, second from there to episode.tip", () => {
|
|
13
|
-
expect(sliceRanges([span("a", "B", "c2"), span("b", "c2", "T")], running)).toEqual([
|
|
14
|
-
{ name: "a", base: "B", tip: "c2", open: false },
|
|
15
|
-
{ name: "b", base: "c2", tip: "T", open: true },
|
|
16
|
-
])
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
it("a leading gap folds into the first range via episode.base", () => {
|
|
20
|
-
expect(sliceRanges([span("a", "c1", "c2")], running)).toEqual([
|
|
21
|
-
{ name: "a", base: "B", tip: "T", open: true },
|
|
22
|
-
])
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
it("an inner gap folds into the next range: its base is the previous leftAt", () => {
|
|
26
|
-
expect(sliceRanges([span("a", "B", "c1"), span("b", "c3", "T")], running)).toEqual([
|
|
27
|
-
{ name: "a", base: "B", tip: "c1", open: false },
|
|
28
|
-
{ name: "b", base: "c1", tip: "T", open: true },
|
|
29
|
-
])
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
it("a trailing gap folds back: the last range's tip is the episode tip, and it stays open", () => {
|
|
33
|
-
expect(sliceRanges([span("a", "B", "c1")], running)).toEqual([
|
|
34
|
-
{ name: "a", base: "B", tip: "T", open: true },
|
|
35
|
-
])
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
it("drops a closed empty span, first, middle and last, its neighbours absorbing the gap", () => {
|
|
39
|
-
expect(sliceRanges([span("e", "B", "B"), span("a", "B", "c1")], running)).toEqual([
|
|
40
|
-
{ name: "a", base: "B", tip: "T", open: true },
|
|
41
|
-
])
|
|
42
|
-
expect(
|
|
43
|
-
sliceRanges([span("a", "B", "c1"), span("e", "c2", "c2"), span("b", "c2", "c4")], running),
|
|
44
|
-
).toEqual([
|
|
45
|
-
{ name: "a", base: "B", tip: "c1", open: false },
|
|
46
|
-
{ name: "b", base: "c1", tip: "T", open: true },
|
|
47
|
-
])
|
|
48
|
-
expect(sliceRanges([span("a", "B", "c1"), span("e", "c1", "c1")], running)).toEqual([
|
|
49
|
-
{ name: "a", base: "B", tip: "T", open: true },
|
|
50
|
-
])
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
it("no spans, and only empty closed spans, each yield one open main range over the whole episode", () => {
|
|
54
|
-
const main = [{ name: "main", base: "B", tip: "T", open: true }]
|
|
55
|
-
expect(sliceRanges([], running)).toEqual(main)
|
|
56
|
-
expect(sliceRanges([span("e", "c1", "c1")], running)).toEqual(main)
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
it("an unleft empty span is kept and open", () => {
|
|
60
|
-
expect(sliceRanges([span("a", "B", "c1"), span("b", "c1")], running)).toEqual([
|
|
61
|
-
{ name: "a", base: "B", tip: "c1", open: false },
|
|
62
|
-
{ name: "b", base: "c1", tip: "T", open: true },
|
|
63
|
-
])
|
|
64
|
-
expect(sliceRanges([span("b", "T")], running)).toEqual([
|
|
65
|
-
{ name: "b", base: "B", tip: "T", open: true },
|
|
66
|
-
])
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
it("rejects a pending turn's empty hash", () => {
|
|
70
|
-
expect(() => sliceRanges([span("a", "", "c1")], running)).toThrow("not a commit")
|
|
71
|
-
expect(() => sliceRanges([span("a", "c1", "")], running)).toThrow("not a commit")
|
|
72
|
-
})
|
|
73
|
-
})
|
package/src/replay/Slices.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
export const IMPLICIT_SLICE = "main"
|
|
2
|
-
|
|
3
|
-
/** Same shape as mark()'s name rule; kept local so @pmelab/gtd/flows publishes no regex. */
|
|
4
|
-
export const SLICE_NAME = /^[a-z][a-z0-9-]*$/
|
|
5
|
-
|
|
6
|
-
/** One slice() call as replay saw it. Hashes are the commit replay stood on; "" is a pending turn. */
|
|
7
|
-
export interface SliceSpan {
|
|
8
|
-
readonly name: string
|
|
9
|
-
readonly enteredAt: string
|
|
10
|
-
/** undefined: the flow never left it — the process rests inside, or restart() fired inside. */
|
|
11
|
-
readonly leftAt: string | undefined
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface SliceRange {
|
|
15
|
-
readonly name: string
|
|
16
|
-
/** Exclusive: the range is base..tip in git's sense. */
|
|
17
|
-
readonly base: string
|
|
18
|
-
readonly tip: string
|
|
19
|
-
/** The tip can still move: only the last range, since a rest is never past the episode's end. */
|
|
20
|
-
readonly open: boolean
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Folds boundaries into ranges: each kept span's base is the previous kept
|
|
25
|
-
* span's `leftAt`, so a gap before a span belongs to it, and the last span
|
|
26
|
-
* runs to the episode tip. A closed span that landed nothing is dropped.
|
|
27
|
-
*/
|
|
28
|
-
export const sliceRanges = (
|
|
29
|
-
spans: readonly SliceSpan[],
|
|
30
|
-
episode: { readonly base: string; readonly tip: string },
|
|
31
|
-
): readonly SliceRange[] => {
|
|
32
|
-
if (spans.some((s) => s.enteredAt === "" || s.leftAt === "")) {
|
|
33
|
-
throw new Error('sliceRanges: a pending turn\'s "" hash is not a commit')
|
|
34
|
-
}
|
|
35
|
-
const kept = spans.filter((s) => s.leftAt === undefined || s.enteredAt !== s.leftAt)
|
|
36
|
-
if (kept.length === 0) {
|
|
37
|
-
return [{ name: IMPLICIT_SLICE, base: episode.base, tip: episode.tip, open: true }]
|
|
38
|
-
}
|
|
39
|
-
return kept.map((s, i) => ({
|
|
40
|
-
name: s.name,
|
|
41
|
-
base: i === 0 ? episode.base : (kept[i - 1]!.leftAt ?? episode.base),
|
|
42
|
-
tip: i === kept.length - 1 ? episode.tip : (s.leftAt ?? episode.tip),
|
|
43
|
-
open: i === kept.length - 1,
|
|
44
|
-
}))
|
|
45
|
-
}
|
|
@@ -1,30 +0,0 @@
|
|
|
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"
|
|
@@ -1,96 +0,0 @@
|
|
|
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
|
-
})
|
package/src/wire/ContextLimit.ts
DELETED
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
// The context-limit decision every driver shares: `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. Where your instructions name a file for that note, write it there too.`,
|
|
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/Slices.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { WIRE_SCHEMA } from "./constants.js"
|
|
2
|
-
|
|
3
|
-
export interface SliceEntry {
|
|
4
|
-
readonly name: string
|
|
5
|
-
/** Full hash, exclusive: git's base..tip. */
|
|
6
|
-
readonly base: string
|
|
7
|
-
/** Full hash, inclusive; HEAD's hash for the open slice. */
|
|
8
|
-
readonly tip: string
|
|
9
|
-
/** false only for the last slice of an underway process. */
|
|
10
|
-
readonly closed: boolean
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface SlicesDocument {
|
|
14
|
-
readonly schema: number
|
|
15
|
-
readonly slices: readonly SliceEntry[]
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** The ONLY place `gtd slices --json`'s shape is built; copies exactly the four fields, in order. */
|
|
19
|
-
export const slicesDocument = (slices: readonly SliceEntry[]): SlicesDocument => ({
|
|
20
|
-
schema: WIRE_SCHEMA,
|
|
21
|
-
slices: slices.map(({ name, base, tip, closed }) => ({ name, base, tip, closed })),
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
export const renderSlicesJson = (document: SlicesDocument): string =>
|
|
25
|
-
JSON.stringify(document) + "\n"
|
|
@@ -1,37 +0,0 @@
|
|
|
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
|
-
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
@inmem
|
|
2
|
-
Feature: past its continuation cap a phase rests for a person
|
|
3
|
-
|
|
4
|
-
With `continuations` at 1, a naive pass stopped at its context limit gets
|
|
5
|
-
one continuation; when that one stops too, `seam.oversized` asks a person,
|
|
6
|
-
and landing there grants a fresh round.
|
|
7
|
-
|
|
8
|
-
Background:
|
|
9
|
-
Given the shipped workflows, as gtd install scaffolds them
|
|
10
|
-
And an environment variable "GTD_CONTINUATIONS" set to "1"
|
|
11
|
-
And an environment variable "GTD_QUALITYREVIEWS" set to ""
|
|
12
|
-
And an environment variable "GTD_REFACTORQUALITYREVIEWS" set to ""
|
|
13
|
-
And an environment variable "GTD_FLAG_RISK_TIER" set to "0"
|
|
14
|
-
And a file "src/calc.ts" with:
|
|
15
|
-
"""
|
|
16
|
-
export const add = (a: number, b: number) => a + b
|
|
17
|
-
"""
|
|
18
|
-
And the working tree is committed as "feat: add calculator"
|
|
19
|
-
And a file "TODO.md" with:
|
|
20
|
-
"""
|
|
21
|
-
# Add subtract and multiply
|
|
22
|
-
"""
|
|
23
|
-
And step "unwind" changes nothing
|
|
24
|
-
And step "start-gate.check" changes nothing
|
|
25
|
-
And step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
|
|
26
|
-
"""
|
|
27
|
-
# Requirements
|
|
28
|
-
|
|
29
|
-
## Acceptance Criteria
|
|
30
|
-
|
|
31
|
-
- AC-1: `subtract(5, 3)` returns 2.
|
|
32
|
-
- AC-2: `multiply(2, 3)` returns 6.
|
|
33
|
-
"""
|
|
34
|
-
And step "design.gate.answer" changes nothing
|
|
35
|
-
And step "seam.check" changes nothing
|
|
36
|
-
|
|
37
|
-
Scenario: past the continuation cap the naive pass rests for a person, and landing there grants a new round
|
|
38
|
-
Given step "seam.naive" writes ".gtd/HANDOFF.md" with:
|
|
39
|
-
"""
|
|
40
|
-
Roughed out subtract.
|
|
41
|
-
"""
|
|
42
|
-
And step "seam.continue.naive" writes "tests/calc.test.ts" with:
|
|
43
|
-
"""
|
|
44
|
-
it("[AC-1] subtracts", () => {})
|
|
45
|
-
"""
|
|
46
|
-
And step "seam.continue.naive" writes ".gtd/HANDOFF.md" with:
|
|
47
|
-
"""
|
|
48
|
-
Subtract is tested; multiply was next.
|
|
49
|
-
"""
|
|
50
|
-
When the process is driven
|
|
51
|
-
Then the process rests at "seam.oversized"
|
|
52
|
-
And the resting step's content contains "1 continuation ran and the last still stopped at its context limit"
|
|
53
|
-
And the resting step's content contains "still open: AC-2"
|
|
54
|
-
|
|
55
|
-
When gtd lands
|
|
56
|
-
Then the process rests at "seam.continue.naive"
|
|
57
|
-
And the resting step's content contains "Still open: AC-2."
|
|
58
|
-
And the commit subjects end with:
|
|
59
|
-
"""
|
|
60
|
-
gtd(human): design.gate.answer → seam.naive
|
|
61
|
-
gtd(agent): seam.naive → seam.continue.naive
|
|
62
|
-
gtd(agent): seam.continue.naive → seam.oversized
|
|
63
|
-
gtd(human): seam.oversized → seam.continue.naive
|
|
64
|
-
"""
|
|
65
|
-
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
@inmem
|
|
2
|
-
Feature: a rebuild stopped at its context limit is continued on the branch
|
|
3
|
-
|
|
4
|
-
The rebuild's snapshot is its own commit: the continuation finds the work
|
|
5
|
-
in the tree, and the health loop greens the whole build afterwards.
|
|
6
|
-
`seamAllow` is empty here, so the naive pass's change to an existing test
|
|
7
|
-
file is the judge's only stray and refactor-first rebuilds with no refactor.
|
|
8
|
-
|
|
9
|
-
Background:
|
|
10
|
-
Given the shipped workflows, as gtd install scaffolds them
|
|
11
|
-
And an environment variable "GTD_SEAMALLOW" set to ""
|
|
12
|
-
And an environment variable "GTD_QUALITYREVIEWS" set to ""
|
|
13
|
-
And an environment variable "GTD_REFACTORQUALITYREVIEWS" set to ""
|
|
14
|
-
And an environment variable "GTD_FLAG_RISK_TIER" set to "0"
|
|
15
|
-
And a file "src/calc.ts" with:
|
|
16
|
-
"""
|
|
17
|
-
export const add = (a: number, b: number) => a + b
|
|
18
|
-
"""
|
|
19
|
-
And a file "tests/calc.test.ts" with:
|
|
20
|
-
"""
|
|
21
|
-
it("adds", () => {})
|
|
22
|
-
"""
|
|
23
|
-
And the working tree is committed as "feat: add calculator"
|
|
24
|
-
And a file "TODO.md" with:
|
|
25
|
-
"""
|
|
26
|
-
# Add subtract and multiply
|
|
27
|
-
"""
|
|
28
|
-
And step "unwind" changes nothing
|
|
29
|
-
And step "start-gate.check" changes nothing
|
|
30
|
-
And step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
|
|
31
|
-
"""
|
|
32
|
-
# Requirements
|
|
33
|
-
|
|
34
|
-
## Acceptance Criteria
|
|
35
|
-
|
|
36
|
-
- AC-1: `subtract(5, 3)` returns 2.
|
|
37
|
-
- AC-2: `multiply(2, 3)` returns 6.
|
|
38
|
-
"""
|
|
39
|
-
And step "design.gate.answer" changes nothing
|
|
40
|
-
And step "seam.check" changes nothing
|
|
41
|
-
|
|
42
|
-
Scenario: a rebuild stopped at its context limit lands its snapshot and is continued on the branch
|
|
43
|
-
Given step "seam.naive" writes "src/ops.ts" with:
|
|
44
|
-
"""
|
|
45
|
-
export const subtract = (a: number, b: number) => a - b
|
|
46
|
-
export const multiply = (a: number, b: number) => a * b
|
|
47
|
-
"""
|
|
48
|
-
And step "seam.naive" writes "tests/calc.test.ts" with:
|
|
49
|
-
"""
|
|
50
|
-
it("adds", () => {})
|
|
51
|
-
it("[AC-1] subtracts", () => {})
|
|
52
|
-
it("[AC-2] multiplies", () => {})
|
|
53
|
-
"""
|
|
54
|
-
And judge step "seam.judge" answers:
|
|
55
|
-
"""
|
|
56
|
-
[{"id":"shape","answer":"refactor-first","p":0.9}]
|
|
57
|
-
"""
|
|
58
|
-
And step "implement.building" writes "tests/calc.test.ts" with:
|
|
59
|
-
"""
|
|
60
|
-
it("adds", () => {})
|
|
61
|
-
it("[AC-1] subtracts", () => {})
|
|
62
|
-
"""
|
|
63
|
-
And step "implement.building" writes ".gtd/HANDOFF.md" with:
|
|
64
|
-
"""
|
|
65
|
-
Rebuilt subtract behind the flag; multiply was next.
|
|
66
|
-
"""
|
|
67
|
-
When the process is driven
|
|
68
|
-
Then the process rests at "implement.continue.building"
|
|
69
|
-
And the resting step's content contains "Still open: AC-2."
|
|
70
|
-
And the resting step's content contains "Its work is already in the tree."
|
|
71
|
-
And the resting step's content contains "Delete `.gtd/HANDOFF.md` once you are done"
|
|
72
|
-
And "tests/calc.test.ts" contains "[AC-1] subtracts"
|
|
73
|
-
|
|
74
|
-
Given step "implement.continue.building" writes "tests/calc.test.ts" with:
|
|
75
|
-
"""
|
|
76
|
-
it("adds", () => {})
|
|
77
|
-
it("[AC-1] subtracts", () => {})
|
|
78
|
-
it("[AC-2] multiplies", () => {})
|
|
79
|
-
"""
|
|
80
|
-
And step "implement.continue.building" deletes ".gtd/HANDOFF.md"
|
|
81
|
-
When the process is driven
|
|
82
|
-
Then the process rests at "implement.health.check"
|
|
83
|
-
And the commit subjects end with:
|
|
84
|
-
"""
|
|
85
|
-
gtd(agent): implement.building → implement.continue.building
|
|
86
|
-
gtd(agent): implement.continue.building → implement.health.check
|
|
87
|
-
"""
|
|
88
|
-
|