@pmelab/gtd 35.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 +45 -29
- 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-C8Fm3s72.mjs → reserved-zAfoJtR7.mjs} +62 -24
- package/dist/chunks/{tail-oaYewBN_.mjs → tail-BRC4DGdV.mjs} +1 -1
- package/dist/chunks/{wire-D3Rc5r22.mjs → wire-CSsa3Wxd.mjs} +117 -2
- package/dist/flows.mjs +15 -10
- package/dist/gtd.bundle.mjs +511 -64
- package/dist/testkit.mjs +158 -26
- package/dist/wire.mjs +2 -2
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +138 -38
- 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 +208 -66
- package/package.json +1 -1
- package/skills/authoring/SKILL.md +3 -3
- 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/flows/scripts.ts +31 -9
- package/src/replay/Replay.ts +51 -2
- package/src/testkit/FlowScripts.test.ts +58 -0
- package/src/testkit/FlowScripts.ts +61 -24
- 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/acceptance-criteria.feature +5 -1
- package/src/workflows/feature/context-limit.feature +37 -0
- package/src/workflows/feature/coverage.feature +6 -3
- package/src/workflows/feature/coverage.ts +8 -2
- package/src/workflows/feature/fast-path.feature +11 -19
- package/src/workflows/feature/feature.test.ts +24 -4
- package/src/workflows/feature/feature.ts +14 -10
- package/src/workflows/feature/flag-mode.feature +17 -9
- package/src/workflows/feature/lens-models.feature +7 -1
- package/src/workflows/feature/naive-acceptance.feature +2 -2
- package/src/workflows/feature/one-step-land.feature +10 -12
- package/src/workflows/feature/quality-unresolved.feature +7 -1
- 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/risk-tier.feature +12 -4
- package/src/workflows/feature/seam-rejection.feature +4 -9
- package/src/workflows/feature/seam.feature +29 -26
- package/src/workflows/feature/seam.test.ts +117 -6
- package/src/workflows/feature/seam.ts +158 -39
- package/src/workflows/review/review-door.feature +12 -4
- 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.test.ts +8 -0
- package/src/workflows/shared/text.ts +91 -22
package/src/cli/Cli.ts
CHANGED
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
type JsonMode,
|
|
36
36
|
} from "./Program.js"
|
|
37
37
|
import type { ToolCommands } from "./ToolCommands.js"
|
|
38
|
-
import { EXIT_RUNTIME_ERROR, EXIT_USAGE_ERROR } from "../wire/index.js"
|
|
38
|
+
import { EXIT_RUNTIME_ERROR, EXIT_USAGE_ERROR, parseContextWindow } from "../wire/index.js"
|
|
39
39
|
|
|
40
40
|
export type { Command, CommandRequirements } from "./Program.js"
|
|
41
41
|
export { needsOf, standaloneKinds } from "./Program.js"
|
|
@@ -90,6 +90,13 @@ const nonNegativeNumber = (raw: string, flag: string): Either.Either<number, str
|
|
|
90
90
|
return Either.right(n)
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
const share = (raw: string, flag: string): Either.Either<number, string> => {
|
|
94
|
+
const n = Number(raw)
|
|
95
|
+
return raw.trim() !== "" && n > 0 && n <= 1
|
|
96
|
+
? Either.right(n)
|
|
97
|
+
: Either.left(`gtd: ${flag} must be a share of the context window in (0, 1] — got "${raw}"`)
|
|
98
|
+
}
|
|
99
|
+
|
|
93
100
|
const FLAGS: readonly FlagRow[] = [
|
|
94
101
|
{
|
|
95
102
|
name: "--json",
|
|
@@ -245,6 +252,41 @@ const FLAGS: readonly FlagRow[] = [
|
|
|
245
252
|
"GTD_JUDGE_MODEL, then judge.json, then sonnet",
|
|
246
253
|
],
|
|
247
254
|
},
|
|
255
|
+
{
|
|
256
|
+
name: "--soft",
|
|
257
|
+
arity: 1,
|
|
258
|
+
repeatable: false,
|
|
259
|
+
scope: (kind) => kind === "contextHook",
|
|
260
|
+
decode: ([raw]) => share(raw ?? "", "--soft"),
|
|
261
|
+
scopeError: "gtd: --soft is only valid for `gtd context-hook`",
|
|
262
|
+
valueHint: "<share>",
|
|
263
|
+
help: ["(gtd context-hook only) the soft limit; default 0.6"],
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
name: "--hard",
|
|
267
|
+
arity: 1,
|
|
268
|
+
repeatable: false,
|
|
269
|
+
scope: (kind) => kind === "contextHook",
|
|
270
|
+
decode: ([raw]) => share(raw ?? "", "--hard"),
|
|
271
|
+
scopeError: "gtd: --hard is only valid for `gtd context-hook`",
|
|
272
|
+
valueHint: "<share>",
|
|
273
|
+
help: ["(gtd context-hook only) the hard limit; default 0.7"],
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
name: "--window",
|
|
277
|
+
arity: 1,
|
|
278
|
+
repeatable: false,
|
|
279
|
+
scope: (kind) => kind === "contextHook",
|
|
280
|
+
decode: ([raw]) => {
|
|
281
|
+
const window = parseContextWindow(raw)
|
|
282
|
+
return window !== undefined
|
|
283
|
+
? Either.right(window)
|
|
284
|
+
: Either.left(`gtd: --window must be a positive integer — got "${raw ?? ""}"`)
|
|
285
|
+
},
|
|
286
|
+
scopeError: "gtd: --window is only valid for `gtd context-hook`",
|
|
287
|
+
valueHint: "<tokens>",
|
|
288
|
+
help: ["(gtd context-hook only) the model's context window in", "tokens; default 200000"],
|
|
289
|
+
},
|
|
248
290
|
{
|
|
249
291
|
name: "--slice",
|
|
250
292
|
arity: 1,
|
|
@@ -593,6 +635,22 @@ const COMMAND_ROWS: readonly CommandRow[] = [
|
|
|
593
635
|
"~/.config/gtd/loop.json); see docs/driver.md",
|
|
594
636
|
],
|
|
595
637
|
},
|
|
638
|
+
{
|
|
639
|
+
token: "context-hook",
|
|
640
|
+
kind: "contextHook",
|
|
641
|
+
arity: "none",
|
|
642
|
+
details: [
|
|
643
|
+
"A Claude Code PostToolUse and PreCompact hook that stops an",
|
|
644
|
+
"agent turn at a share of its context window. Reads the",
|
|
645
|
+
"hook's input on stdin and the context the transcript's",
|
|
646
|
+
"latest assistant message used. Past --soft it blocks with a",
|
|
647
|
+
"request to end the turn with a handoff note; past --hard,",
|
|
648
|
+
"or at PreCompact, it stops the turn with a stopReason",
|
|
649
|
+
'starting "gtd: context limit". Prints nothing otherwise,',
|
|
650
|
+
"and for a subagent's tool call. Always exits 0 on valid",
|
|
651
|
+
"input; see docs/driver.md",
|
|
652
|
+
],
|
|
653
|
+
},
|
|
596
654
|
{
|
|
597
655
|
token: "summary",
|
|
598
656
|
kind: "summary",
|
|
@@ -1182,6 +1240,9 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
|
|
|
1182
1240
|
readonly "--provider"?: "fixed" | "jev" | "llm"
|
|
1183
1241
|
readonly "--answers"?: string
|
|
1184
1242
|
readonly "--slice"?: string
|
|
1243
|
+
readonly "--soft"?: number
|
|
1244
|
+
readonly "--hard"?: number
|
|
1245
|
+
readonly "--window"?: number
|
|
1185
1246
|
}
|
|
1186
1247
|
|
|
1187
1248
|
// `present.has("--json")` alone can't distinguish bare `--json` (no value
|
|
@@ -1285,6 +1346,20 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
|
|
|
1285
1346
|
}
|
|
1286
1347
|
}
|
|
1287
1348
|
|
|
1349
|
+
if (kind === "contextHook") {
|
|
1350
|
+
return {
|
|
1351
|
+
kind: "command",
|
|
1352
|
+
command: {
|
|
1353
|
+
kind: "contextHook",
|
|
1354
|
+
...(bag["--soft"] !== undefined ? { soft: bag["--soft"] } : {}),
|
|
1355
|
+
...(bag["--hard"] !== undefined ? { hard: bag["--hard"] } : {}),
|
|
1356
|
+
...(bag["--window"] !== undefined ? { window: bag["--window"] } : {}),
|
|
1357
|
+
},
|
|
1358
|
+
json,
|
|
1359
|
+
verbose,
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1288
1363
|
if (kind === "summary") {
|
|
1289
1364
|
return {
|
|
1290
1365
|
kind: "command",
|
package/src/cli/Program.test.ts
CHANGED
|
@@ -1686,6 +1686,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
|
|
|
1686
1686
|
uncheck: { kind: "uncheck", file: ".gtd/REVIEW.md" },
|
|
1687
1687
|
install: { kind: "install" },
|
|
1688
1688
|
loop: { kind: "loop" },
|
|
1689
|
+
contextHook: { kind: "contextHook" },
|
|
1689
1690
|
summary: { kind: "summary" },
|
|
1690
1691
|
base: { kind: "base" },
|
|
1691
1692
|
judge: { kind: "judge" },
|
package/src/cli/Program.ts
CHANGED
|
@@ -162,6 +162,12 @@ export type Command =
|
|
|
162
162
|
| { readonly kind: "uncheck"; readonly file: string }
|
|
163
163
|
| { readonly kind: "install" }
|
|
164
164
|
| { readonly kind: "loop" }
|
|
165
|
+
| {
|
|
166
|
+
readonly kind: "contextHook"
|
|
167
|
+
readonly soft?: number
|
|
168
|
+
readonly hard?: number
|
|
169
|
+
readonly window?: number
|
|
170
|
+
}
|
|
165
171
|
| { readonly kind: "summary"; readonly slice?: string }
|
|
166
172
|
| { readonly kind: "base" }
|
|
167
173
|
| { readonly kind: "judge" }
|
|
@@ -434,6 +440,24 @@ const readStdin = (): Effect.Effect<string, Error> =>
|
|
|
434
440
|
catch: (e) => (e instanceof Error ? e : new Error(String(e))),
|
|
435
441
|
})
|
|
436
442
|
|
|
443
|
+
/** Stdout is written once: a hook reads one JSON document, or none. */
|
|
444
|
+
const runContextHookCommand = (
|
|
445
|
+
command: Extract<Command, { kind: "contextHook" }>,
|
|
446
|
+
out: ArtifactOut,
|
|
447
|
+
): Effect.Effect<void, Error, ToolCommands> =>
|
|
448
|
+
Effect.gen(function* () {
|
|
449
|
+
const tools = yield* ToolCommands
|
|
450
|
+
const input = yield* readStdin()
|
|
451
|
+
out.write(
|
|
452
|
+
yield* tools.contextHook({
|
|
453
|
+
input,
|
|
454
|
+
...(command.soft !== undefined ? { soft: command.soft } : {}),
|
|
455
|
+
...(command.hard !== undefined ? { hard: command.hard } : {}),
|
|
456
|
+
...(command.window !== undefined ? { window: command.window } : {}),
|
|
457
|
+
}),
|
|
458
|
+
)
|
|
459
|
+
})
|
|
460
|
+
|
|
437
461
|
/** Stdout is written once, after the answerer resolves — a rejection leaves it empty. */
|
|
438
462
|
const runJudgeRunCommand = (
|
|
439
463
|
command: Extract<Command, { kind: "judgeRun" }>,
|
|
@@ -1189,6 +1213,7 @@ export const needsOf = (kind: Command["kind"]): Needs => {
|
|
|
1189
1213
|
case "lsp":
|
|
1190
1214
|
case "install":
|
|
1191
1215
|
case "loop":
|
|
1216
|
+
case "contextHook":
|
|
1192
1217
|
case "judgeRun":
|
|
1193
1218
|
case "strays":
|
|
1194
1219
|
return "none"
|
|
@@ -1207,6 +1232,7 @@ export const standaloneKinds = (): readonly Command["kind"][] => [
|
|
|
1207
1232
|
"uncheck",
|
|
1208
1233
|
"install",
|
|
1209
1234
|
"loop",
|
|
1235
|
+
"contextHook",
|
|
1210
1236
|
"judgeRun",
|
|
1211
1237
|
"strays",
|
|
1212
1238
|
]
|
|
@@ -1278,6 +1304,8 @@ const dispatchVoidCommand = (
|
|
|
1278
1304
|
return runInstallCommand(out)
|
|
1279
1305
|
case "loop":
|
|
1280
1306
|
return runLoopCommand()
|
|
1307
|
+
case "contextHook":
|
|
1308
|
+
return runContextHookCommand(command, out)
|
|
1281
1309
|
case "summary":
|
|
1282
1310
|
return runSummaryCommand(out, command.slice)
|
|
1283
1311
|
case "base":
|
package/src/cli/ToolCommands.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Context } from "effect"
|
|
2
2
|
import type { Effect } from "effect"
|
|
3
3
|
import type { UiSettings } from "../flows/index.js"
|
|
4
|
+
import type { ContextHookRequest } from "../wire/index.js"
|
|
4
5
|
|
|
5
6
|
/** How to run THIS gtd — a driver (the loop, the LSP) never reads a different version's state than the one it ships with. */
|
|
6
7
|
export type SelfGtd = readonly [string, ...string[]]
|
|
@@ -38,6 +39,8 @@ export class ToolCommands extends Context.Tag("ToolCommands")<
|
|
|
38
39
|
) => Effect.Effect<void, Error>
|
|
39
40
|
readonly lsp: (gtd: SelfGtd) => Effect.Effect<void, Error>
|
|
40
41
|
readonly loop: (gtd: SelfGtd) => Effect.Effect<void, Error>
|
|
42
|
+
/** The hook's stdout: a JSON document, or empty to let the turn go on. */
|
|
43
|
+
readonly contextHook: (request: ContextHookRequest) => Effect.Effect<string, Error>
|
|
41
44
|
readonly judge: (request: JudgeRequest) => Promise<string>
|
|
42
45
|
}
|
|
43
46
|
>() {}
|
package/src/edge/Edge.ts
CHANGED
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
type ModelCost,
|
|
55
55
|
type SliceEntry,
|
|
56
56
|
type StepAccess,
|
|
57
|
+
type StepContextLimit,
|
|
57
58
|
} from "../wire/index.js"
|
|
58
59
|
|
|
59
60
|
import { GTD_VERSION } from "../install/index.js"
|
|
@@ -722,6 +723,13 @@ type RequestOf<K extends ReachedStep["request"]["kind"]> = Extract<
|
|
|
722
723
|
{ kind: K }
|
|
723
724
|
>
|
|
724
725
|
|
|
726
|
+
const stepContext = (
|
|
727
|
+
context: { readonly soft?: number | undefined; readonly hard?: number | undefined } | undefined,
|
|
728
|
+
): StepContextLimit | undefined =>
|
|
729
|
+
context === undefined
|
|
730
|
+
? undefined
|
|
731
|
+
: { ...optional("soft", context.soft), ...optional("hard", context.hard) }
|
|
732
|
+
|
|
725
733
|
const promptDef = (
|
|
726
734
|
common: StepCommon,
|
|
727
735
|
request: RequestOf<"agent">,
|
|
@@ -735,6 +743,7 @@ const promptDef = (
|
|
|
735
743
|
...optional("system", request.options.system),
|
|
736
744
|
...optional("allowEmpty", request.options.allowEmpty),
|
|
737
745
|
...optional("keep", request.options.keep),
|
|
746
|
+
...optional("context", stepContext(request.options.context)),
|
|
738
747
|
...optional("skills", skills),
|
|
739
748
|
...optional("access", access),
|
|
740
749
|
})
|
|
@@ -850,6 +859,7 @@ export interface RestHints {
|
|
|
850
859
|
readonly mode?: string
|
|
851
860
|
readonly skills?: readonly string[]
|
|
852
861
|
readonly access?: StepAccess
|
|
862
|
+
readonly context?: StepContextLimit
|
|
853
863
|
}
|
|
854
864
|
|
|
855
865
|
const hintsOf = (def: StepDef): RestHints => ({
|
|
@@ -861,6 +871,7 @@ const hintsOf = (def: StepDef): RestHints => ({
|
|
|
861
871
|
...optional("mode", def.mode),
|
|
862
872
|
...optional("skills", def.skills),
|
|
863
873
|
...optional("access", def.access),
|
|
874
|
+
...optional("context", def.context),
|
|
864
875
|
})
|
|
865
876
|
|
|
866
877
|
const normalizeStatus = (raw: string): ChangeStatus => (raw === "A" ? "A" : raw === "D" ? "D" : "M")
|
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/flows/scripts.ts
CHANGED
|
@@ -55,25 +55,31 @@ export const checkScript = (command: string, options: CheckScriptOptions): strin
|
|
|
55
55
|
].join("\n")
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
58
|
+
const patchScript = (
|
|
59
|
+
commit: string,
|
|
60
|
+
report: string,
|
|
61
|
+
failure: string,
|
|
62
|
+
reverse: boolean,
|
|
63
|
+
exclude: readonly string[] = [],
|
|
64
|
+
): string => {
|
|
65
|
+
const paths =
|
|
66
|
+
exclude.length === 0
|
|
67
|
+
? ""
|
|
68
|
+
: ` . ${exclude.map((p) => quote(`:(exclude,literal)${p}`)).join(" ")}`
|
|
64
69
|
const range = `${quote(`${commit}^`)} ${quote(commit)}`
|
|
65
70
|
const error = quote(`${report}.output`)
|
|
66
71
|
return [
|
|
67
72
|
"#!/usr/bin/env sh",
|
|
68
73
|
"set +e",
|
|
69
|
-
`if ! git diff --quiet ${range}
|
|
74
|
+
`if ! git diff --quiet ${range} --${paths}; then`,
|
|
70
75
|
` mkdir -p "$(dirname ${quote(report)})"`,
|
|
71
|
-
|
|
76
|
+
// Pinned flags: a user's diff.noprefix, color.ui, diff.external, textconv or apply.whitespace would change the patch.
|
|
77
|
+
` git diff --binary --no-color --no-ext-diff --no-textconv --src-prefix=a/ --dst-prefix=b/ ${range} --${paths} | git apply --whitespace=nowarn${reverse ? " -R" : ""} 2> ${error}`,
|
|
72
78
|
" code=$?",
|
|
73
79
|
' if [ "$code" -ne 0 ]; then',
|
|
74
80
|
" {",
|
|
75
81
|
` printf '%s\\n\\n' ${quote(failure)}`,
|
|
76
|
-
` if [ -s ${error} ]; then cat ${error}; else printf 'Reverting it exited %s and produced no output.\\n' "$code"; fi`,
|
|
82
|
+
` if [ -s ${error} ]; then cat ${error}; else printf '${reverse ? "Reverting" : "Applying"} it exited %s and produced no output.\\n' "$code"; fi`,
|
|
77
83
|
` } > ${quote(report)}`,
|
|
78
84
|
" fi",
|
|
79
85
|
` rm -f ${error}`,
|
|
@@ -82,6 +88,22 @@ export const revertScript = (commit: string, report: string, failure: string): s
|
|
|
82
88
|
].join("\n")
|
|
83
89
|
}
|
|
84
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Reverse-apply `commit`'s changes to the working tree. A failure — the
|
|
93
|
+
* patch no longer applies — writes `failure` and git's error to `report`,
|
|
94
|
+
* since a failed revert and one with nothing to undo both leave the tree clean.
|
|
95
|
+
*/
|
|
96
|
+
export const revertScript = (commit: string, report: string, failure: string): string =>
|
|
97
|
+
patchScript(commit, report, failure, true)
|
|
98
|
+
|
|
99
|
+
/** Apply `commit`'s changes outside `exclude` to the working tree; a failure is reported as `revertScript`'s is. */
|
|
100
|
+
export const applyScript = (
|
|
101
|
+
commit: string,
|
|
102
|
+
report: string,
|
|
103
|
+
failure: string,
|
|
104
|
+
exclude: readonly string[] = [],
|
|
105
|
+
): string => patchScript(commit, report, failure, false, exclude)
|
|
106
|
+
|
|
85
107
|
/**
|
|
86
108
|
* Undo the edits one commit made to `paths`: each goes back to its content
|
|
87
109
|
* before `commit` — or away, when `commit` added it — but only while it is
|
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) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
|
2
2
|
import {
|
|
3
|
+
applyScript,
|
|
3
4
|
checkScript,
|
|
4
5
|
moveScript,
|
|
5
6
|
removeScript,
|
|
@@ -178,6 +179,63 @@ for (const makeTier of gitTiers) {
|
|
|
178
179
|
})
|
|
179
180
|
})
|
|
180
181
|
|
|
182
|
+
describe("applyScript", () => {
|
|
183
|
+
it("replays a commit's modifications, additions and deletions onto the working tree", () => {
|
|
184
|
+
t.seed.commit("feat: base", { "mod.txt": "before", "gone.txt": "g" })
|
|
185
|
+
t.seed.writeFile("mod.txt", "after")
|
|
186
|
+
t.seed.writeFile("added.txt", "new")
|
|
187
|
+
t.seed.deleteFile("gone.txt")
|
|
188
|
+
t.seed.stageAll()
|
|
189
|
+
t.seed.commit("feat: change", {})
|
|
190
|
+
const commit = t.observe.resolveRef("HEAD")
|
|
191
|
+
t.seed.writeFile("gone.txt", "g")
|
|
192
|
+
t.seed.deleteFile("added.txt")
|
|
193
|
+
t.seed.stageAll()
|
|
194
|
+
t.seed.commit("revert: change", { "mod.txt": "before" })
|
|
195
|
+
const head = t.observe.resolveRef("HEAD")
|
|
196
|
+
expect(run(applyScript(commit, REPORT, "could not apply")).ok).toBe(true)
|
|
197
|
+
expect(read("mod.txt")).toBe("after")
|
|
198
|
+
expect(read("added.txt")).toBe("new")
|
|
199
|
+
expect(read("gone.txt")).toBeUndefined()
|
|
200
|
+
expect(read(REPORT)).toBeUndefined()
|
|
201
|
+
expect(t.observe.resolveRef("HEAD")).toBe(head)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it("leaves excluded paths as they are", () => {
|
|
205
|
+
t.seed.commit("feat: base", { "a.txt": "a0", ".gtd/R.md": "r0" })
|
|
206
|
+
t.seed.commit("feat: change", { "a.txt": "a1", ".gtd/R.md": "r1", ".gtd/new.md": "n" })
|
|
207
|
+
const commit = t.observe.resolveRef("HEAD")
|
|
208
|
+
t.seed.commit("revert: change", { "a.txt": "a0", ".gtd/R.md": "r2" })
|
|
209
|
+
t.seed.commitDeletion(".gtd/new.md", "revert: change")
|
|
210
|
+
expect(run(applyScript(commit, REPORT, "could not apply", [".gtd"])).ok).toBe(true)
|
|
211
|
+
expect(read(REPORT)).toBeUndefined()
|
|
212
|
+
expect(read("a.txt")).toBe("a1")
|
|
213
|
+
expect(read(".gtd/R.md")).toBe("r2")
|
|
214
|
+
expect(read(".gtd/new.md")).toBeUndefined()
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it("does nothing for a commit with nothing to apply", () => {
|
|
218
|
+
t.seed.commit("feat: empty", {})
|
|
219
|
+
const commit = t.observe.resolveRef("HEAD")
|
|
220
|
+
expect(run(applyScript(commit, REPORT, "could not apply")).ok).toBe(true)
|
|
221
|
+
expect(read(REPORT)).toBeUndefined()
|
|
222
|
+
expect(t.observe.statusPorcelain()).toBe("")
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it("reports a commit that cannot apply, leaving the tree untouched", () => {
|
|
226
|
+
t.seed.commit("feat: base", { "a.txt": "a0" })
|
|
227
|
+
t.seed.commit("feat: change", { "a.txt": "a1", "b.txt": "b1" })
|
|
228
|
+
const commit = t.observe.resolveRef("HEAD")
|
|
229
|
+
t.seed.commitDeletion("b.txt", "revert: change")
|
|
230
|
+
t.seed.commit("revert: change", { "a.txt": "a0" })
|
|
231
|
+
t.seed.writeFile("b.txt", "other")
|
|
232
|
+
expect(run(applyScript(commit, REPORT, "could not apply")).ok).toBe(true)
|
|
233
|
+
expect(read(REPORT)).toMatch(/^could not apply\n\n\S/)
|
|
234
|
+
expect(read("a.txt")).toBe("a0")
|
|
235
|
+
expect(read("b.txt")).toBe("other")
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
|
|
181
239
|
describe("checkScript", () => {
|
|
182
240
|
const options = { report: REPORT, stamp: "abc1234" }
|
|
183
241
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// `sh` and this twin, so the two cannot drift apart silently.
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
|
+
applyScript,
|
|
8
9
|
checkScript,
|
|
9
10
|
moveScript,
|
|
10
11
|
removeScript,
|
|
@@ -129,44 +130,80 @@ const recognizeRestore = (repo: InMemRepo, script: string): FlowScriptResult | u
|
|
|
129
130
|
|
|
130
131
|
const PATCH_FAILED = "error: patch does not apply"
|
|
131
132
|
|
|
133
|
+
const outside = (path: string, exclude: readonly string[]): boolean =>
|
|
134
|
+
!exclude.some((e) => path === e || path.startsWith(`${e}/`))
|
|
135
|
+
|
|
136
|
+
/** Why `path` blocks the patch, `undefined` when it is exactly as `from` has it. */
|
|
137
|
+
const blocking = (repo: InMemRepo, path: string, from: string, to: string): string | undefined => {
|
|
138
|
+
const expected = repo.fileAtRef(from, path)
|
|
139
|
+
const current = repo.readFile(path) ?? null
|
|
140
|
+
if (current === expected) return undefined
|
|
141
|
+
if (current === null || expected === null || repo.fileAtRef(to, path) === null) {
|
|
142
|
+
return `${PATCH_FAILED}: ${path}`
|
|
143
|
+
}
|
|
144
|
+
throw new Error(
|
|
145
|
+
`testkit: cannot tell in memory whether the patch of ${from}..${to} applies to the hand-edited ${path} — run this @live`,
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
132
149
|
/**
|
|
133
|
-
* `git diff C^ C | git apply -R`, which is all or nothing. A file
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
150
|
+
* `git diff C^ C | git apply [-R]`, which is all or nothing. A file exactly
|
|
151
|
+
* as the side being undone (`from`) has it moves to the other side (`to`); a
|
|
152
|
+
* file missing where `from` has one, or present where `from` has none, fails
|
|
153
|
+
* the whole patch. A file both sides have that differs from `from` may still
|
|
154
|
+
* apply through its context — that needs real git, so `blocking` throws
|
|
155
|
+
* rather than guess.
|
|
138
156
|
*/
|
|
139
|
-
const
|
|
157
|
+
const patch = (
|
|
158
|
+
repo: InMemRepo,
|
|
159
|
+
commit: string,
|
|
160
|
+
reverse: boolean,
|
|
161
|
+
exclude: readonly string[],
|
|
162
|
+
): string | undefined => {
|
|
140
163
|
const parent = `${commit}^`
|
|
164
|
+
const [from, to] = reverse ? [commit, parent] : [parent, commit]
|
|
141
165
|
const paths = new Set([...repo.pathsAtRef(parent), ...repo.pathsAtRef(commit)])
|
|
142
|
-
const changed = [...paths].filter(
|
|
166
|
+
const changed = [...paths].filter(
|
|
167
|
+
(p) => repo.fileAtRef(parent, p) !== repo.fileAtRef(commit, p) && outside(p, exclude),
|
|
168
|
+
)
|
|
143
169
|
for (const path of changed) {
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
if (current === after) continue
|
|
147
|
-
if (current === null || after === null || repo.fileAtRef(parent, path) === null) {
|
|
148
|
-
return `${PATCH_FAILED}: ${path}`
|
|
149
|
-
}
|
|
150
|
-
throw new Error(
|
|
151
|
-
`testkit: cannot tell in memory whether reverting ${commit} applies to the hand-edited ${path} — run this @live`,
|
|
152
|
-
)
|
|
170
|
+
const error = blocking(repo, path, from, to)
|
|
171
|
+
if (error !== undefined) return error
|
|
153
172
|
}
|
|
154
173
|
for (const path of changed) {
|
|
155
|
-
const
|
|
156
|
-
if (
|
|
157
|
-
else repo.writeFile(path,
|
|
174
|
+
const content = repo.fileAtRef(to, path)
|
|
175
|
+
if (content === null) repo.deleteFile(path)
|
|
176
|
+
else repo.writeFile(path, content)
|
|
158
177
|
}
|
|
159
178
|
return undefined
|
|
160
179
|
}
|
|
161
180
|
|
|
162
|
-
|
|
181
|
+
interface ParsedPatch {
|
|
182
|
+
readonly commit: string
|
|
183
|
+
readonly failure: string
|
|
184
|
+
readonly report: string
|
|
185
|
+
readonly exclude: readonly string[]
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const parsePatch = (script: string): ParsedPatch | undefined => {
|
|
163
189
|
const lines = script.split("\n")
|
|
164
|
-
const [, commit] = words(
|
|
190
|
+
const [, commit, ...pathspecs] = words(
|
|
191
|
+
lines.find((l) => l.startsWith("if ! git diff --quiet ")) ?? "",
|
|
192
|
+
)
|
|
165
193
|
const [, failure] = words(lines.find((l) => l.includes("printf '%s\\n\\n' ")) ?? "")
|
|
166
194
|
const [report] = words(lines.find((l) => l.startsWith(" } > ")) ?? "")
|
|
167
195
|
if (commit === undefined || failure === undefined || report === undefined) return undefined
|
|
168
|
-
|
|
169
|
-
|
|
196
|
+
const exclude = pathspecs.map((p) => p.replace(/^:\(exclude,literal\)/, ""))
|
|
197
|
+
return { commit, failure, report, exclude }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const recognizePatch = (repo: InMemRepo, script: string): FlowScriptResult | undefined => {
|
|
201
|
+
const parsed = parsePatch(script)
|
|
202
|
+
if (parsed === undefined) return undefined
|
|
203
|
+
const { commit, failure, report, exclude } = parsed
|
|
204
|
+
const reverse = revertScript(commit, report, failure) === script
|
|
205
|
+
if (!reverse && applyScript(commit, report, failure, exclude) !== script) return undefined
|
|
206
|
+
const error = patch(repo, commit, reverse, reverse ? [] : exclude)
|
|
170
207
|
if (error !== undefined) repo.writeFile(report, `${failure}\n\n${error}\n`)
|
|
171
208
|
return ok()
|
|
172
209
|
}
|
|
@@ -319,7 +356,7 @@ export const applyFlowScript = (
|
|
|
319
356
|
recognizeRemove(repo, script) ??
|
|
320
357
|
recognizeMove(repo, script) ??
|
|
321
358
|
recognizeShow(repo, script) ??
|
|
322
|
-
|
|
359
|
+
recognizePatch(repo, script) ??
|
|
323
360
|
recognizeRestore(repo, script) ??
|
|
324
361
|
recognizeCheck(repo, commands, script)
|
|
325
362
|
)
|
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()"),
|