@pmelab/gtd 18.1.0 → 20.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 +69 -69
- package/claude/hooks/doors.ts +34 -0
- package/claude/hooks/drive.ts +1 -0
- package/claude/hooks/register.tsx +12 -13
- package/claude/hooks/ship.ts +3 -2
- package/dist/gtd.bundle.mjs +847 -721
- package/package.json +3 -2
- package/skills/authoring/SKILL.md +43 -32
- package/src/flows/helpers.ts +4 -2
- package/src/flows/runtime.ts +22 -17
- package/src/flows/scripts.test.ts +28 -1
- package/src/flows/scripts.ts +3 -0
- package/src/workflows/access.test.ts +3 -4
- package/src/workflows/access.ts +0 -1
- package/src/workflows/{unified.ts → bundled.ts} +38 -31
- package/src/workflows/health.test.ts +21 -1
- package/src/workflows/health.ts +47 -8
- package/src/workflows/index.ts +1 -1
- package/src/workflows/packages.test.ts +91 -0
- package/src/workflows/packages.ts +96 -76
- package/src/workflows/planning.ts +5 -49
- package/src/workflows/prose.ts +1 -9
- package/src/workflows/review.test.ts +91 -4
- package/src/workflows/review.ts +42 -17
- package/src/workflows/scenarios.test.ts +256 -0
- package/src/workflows/scenarios.ts +137 -0
- package/src/workflows/skills.test.ts +2 -6
- package/src/workflows/skills.ts +0 -2
- package/src/workflows/steps.test.ts +2 -12
- package/src/workflows/steps.ts +2 -20
- package/src/workflows/text.test.ts +60 -0
- package/src/workflows/text.ts +118 -96
- package/src/workflows/vars.ts +1 -2
- package/claude/hooks/entry.ts +0 -34
- package/src/workflows/diff.test.ts +0 -115
- package/src/workflows/diff.ts +0 -306
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
2
|
+
import { installContext, type Change, type StepRequest } from "../flows/index.js"
|
|
3
|
+
import { declaredTests, packages, SCENARIO_PACKAGE } from "./packages.js"
|
|
4
|
+
import { fixtureContext } from "./text.fixture.js"
|
|
5
|
+
|
|
6
|
+
afterEach(() => installContext(undefined))
|
|
7
|
+
|
|
8
|
+
describe("declaredTests", () => {
|
|
9
|
+
it("parses unit and e2e lines of the Tests section", () => {
|
|
10
|
+
const text = [
|
|
11
|
+
"# Package",
|
|
12
|
+
"",
|
|
13
|
+
"## Tests",
|
|
14
|
+
"",
|
|
15
|
+
"- unit: `lib/a.test.ts`",
|
|
16
|
+
"- e2e: `spec/a.feature`",
|
|
17
|
+
].join("\n")
|
|
18
|
+
expect(declaredTests(text)).toEqual([
|
|
19
|
+
{ level: "unit", path: "lib/a.test.ts" },
|
|
20
|
+
{ level: "e2e", path: "spec/a.feature" },
|
|
21
|
+
])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("ignores prose, malformed lines and other sections", () => {
|
|
25
|
+
const text = [
|
|
26
|
+
"## Tasks",
|
|
27
|
+
"- unit: `lib/not-here.test.ts`",
|
|
28
|
+
"## Tests",
|
|
29
|
+
"Some prose.",
|
|
30
|
+
"- unit: lib/unquoted.test.ts",
|
|
31
|
+
"- integration: `lib/x.test.ts`",
|
|
32
|
+
"- unit: `lib/ok.test.ts` extra",
|
|
33
|
+
"## Notes",
|
|
34
|
+
"- e2e: `spec/other.feature`",
|
|
35
|
+
].join("\n")
|
|
36
|
+
expect(declaredTests(text)).toEqual([{ level: "unit", path: "lib/ok.test.ts" }])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("returns nothing without a Tests section", () => {
|
|
40
|
+
expect(declaredTests("# Chore\n\n## Tasks\n- do it\n")).toEqual([])
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe("packages scenarios", () => {
|
|
45
|
+
it("holds only package 0's own paths, not what later packages add", async () => {
|
|
46
|
+
const queue = [SCENARIO_PACKAGE, ".gtd/packages/01-impl.md"]
|
|
47
|
+
const touches: { at: number; change: Change }[] = []
|
|
48
|
+
let steps = 0
|
|
49
|
+
const touch = (path: string, status: Change["status"]): void => {
|
|
50
|
+
touches.push({ at: steps, change: { path, status, before: undefined, after: "x" } })
|
|
51
|
+
}
|
|
52
|
+
const effects: Record<string, () => void> = {
|
|
53
|
+
building: () => {
|
|
54
|
+
if (queue[0] === SCENARIO_PACKAGE) {
|
|
55
|
+
touch("tests/e2e/a.feature", "added")
|
|
56
|
+
touch(".gtd/packages/00-e2e-scenarios.md", "modified")
|
|
57
|
+
} else {
|
|
58
|
+
touch("src/impl.ts", "added")
|
|
59
|
+
touch("tests/e2e/b.feature", "added")
|
|
60
|
+
touch("tests/e2e/a.feature", "modified")
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
closing: () => void queue.shift(),
|
|
64
|
+
}
|
|
65
|
+
installContext(
|
|
66
|
+
fixtureContext(
|
|
67
|
+
{},
|
|
68
|
+
{
|
|
69
|
+
step: (request: StepRequest) => {
|
|
70
|
+
steps++
|
|
71
|
+
if (request.kind !== "restart") effects[request.name]?.()
|
|
72
|
+
return Promise.resolve()
|
|
73
|
+
},
|
|
74
|
+
pushScope: () => undefined,
|
|
75
|
+
popScope: () => undefined,
|
|
76
|
+
refuse: (message: string): never => {
|
|
77
|
+
throw new Error(message)
|
|
78
|
+
},
|
|
79
|
+
glob: () => [...queue],
|
|
80
|
+
changesSince: (hash: string): readonly Change[] =>
|
|
81
|
+
touches.filter((t) => t.at >= Number(hash.slice(1))).map((t) => t.change),
|
|
82
|
+
head: () => `c${steps}`,
|
|
83
|
+
},
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
const plan = await packages()
|
|
87
|
+
expect(plan.ranges.map((r) => r.pkg)).toEqual([SCENARIO_PACKAGE, ".gtd/packages/01-impl.md"])
|
|
88
|
+
expect(plan.scenarios.added).toEqual(["tests/e2e/a.feature"])
|
|
89
|
+
expect(plan.scenarios.changed).toEqual([])
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -1,109 +1,129 @@
|
|
|
1
1
|
import {
|
|
2
|
-
answered,
|
|
3
|
-
changes,
|
|
4
2
|
changesSince,
|
|
3
|
+
type Change,
|
|
5
4
|
glob,
|
|
6
5
|
head,
|
|
7
|
-
judge,
|
|
8
|
-
numeric,
|
|
9
6
|
read,
|
|
10
7
|
refuse,
|
|
11
8
|
removeScript,
|
|
12
9
|
run,
|
|
13
10
|
scope,
|
|
14
|
-
sectionBodies,
|
|
15
|
-
vars,
|
|
16
|
-
wrote,
|
|
17
|
-
type JudgeQuestion,
|
|
18
11
|
} from "../flows/index.js"
|
|
19
|
-
import { packageDiff } from "./diff.js"
|
|
20
12
|
import { healthy } from "./health.js"
|
|
21
|
-
import {
|
|
22
|
-
import
|
|
13
|
+
import { freezeScenarios, guarded, type FrozenScenarios } from "./scenarios.js"
|
|
14
|
+
import { build, fixSuite } from "./steps.js"
|
|
23
15
|
|
|
24
|
-
|
|
25
|
-
const
|
|
16
|
+
/** Package 0: the e2e scenarios, written red. */
|
|
17
|
+
export const SCENARIO_PACKAGE = ".gtd/packages/00-e2e-scenarios.md"
|
|
26
18
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
19
|
+
export interface DeclaredTest {
|
|
20
|
+
readonly level: "unit" | "e2e"
|
|
21
|
+
readonly path: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const TEST_LINE = /^\s*-\s+(unit|e2e):\s+`([^`]+)`/
|
|
25
|
+
|
|
26
|
+
/** The `## Tests` entries of a package file; malformed lines are ignored. */
|
|
27
|
+
export const declaredTests = (packageText: string): readonly DeclaredTest[] => {
|
|
28
|
+
const found: DeclaredTest[] = []
|
|
29
|
+
let inTests = false
|
|
30
|
+
for (const line of packageText.split("\n")) {
|
|
31
|
+
if (/^##\s/.test(line)) inTests = /^##\s+Tests\s*$/.test(line)
|
|
32
|
+
else if (inTests) {
|
|
33
|
+
const match = TEST_LINE.exec(line)
|
|
34
|
+
if (match) found.push({ level: match[1] as DeclaredTest["level"], path: match[2]! })
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return found
|
|
38
|
+
}
|
|
34
39
|
|
|
35
40
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* package's commits. Resolves `true` when approved.
|
|
41
|
+
* Refuse a build or fix turn unless every test `pkg` declares exists and is in the
|
|
42
|
+
* diff since `since`. The package text is read as of `since`, so a builder
|
|
43
|
+
* editing its own `## Tests` cannot dodge the guard; with `.gtd/SATISFIED.md`
|
|
44
|
+
* written, existing in the tree suffices.
|
|
41
45
|
*/
|
|
42
|
-
export const
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
// `diff` itself, so this cap equals what `budgeted` gives it below.
|
|
55
|
-
const capBytes = Math.floor(numeric(vars.judgeBudgetBytes, 32768) / (found.length + 1))
|
|
56
|
-
evidence[DIFF_KEY] = packageDiff(changesSince(since), capBytes)
|
|
57
|
-
}
|
|
58
|
-
const { answers, truncated } = await judge("spec.pre", {
|
|
59
|
-
questions,
|
|
60
|
-
evidence,
|
|
61
|
-
message: t.packagesItemSpecPreMessage(),
|
|
62
|
-
label: "Judging spec coverage",
|
|
63
|
-
})
|
|
64
|
-
// A section is cleared only by a confident yes on evidence that was not
|
|
65
|
-
// cut — its own body, or the diff every question is judged against.
|
|
66
|
-
const clearMinP = numeric(vars.specPreJudge, Infinity)
|
|
67
|
-
const failing = titles.filter((_, i) => {
|
|
68
|
-
const id = `section-${i + 1}`
|
|
69
|
-
return (
|
|
70
|
-
!judged ||
|
|
71
|
-
truncated.includes(id) ||
|
|
72
|
-
truncated.includes(DIFF_KEY) ||
|
|
73
|
-
!answered(answers[id], "yes", clearMinP)
|
|
46
|
+
export const requireDeclaredTests = (pkg: string, since: string): void => {
|
|
47
|
+
const range = changesSince(since)
|
|
48
|
+
const text = range.get(pkg)?.before ?? read(pkg) ?? ""
|
|
49
|
+
const satisfied = read(".gtd/SATISFIED.md") !== undefined
|
|
50
|
+
const missing = declaredTests(text)
|
|
51
|
+
.map((test) => test.path)
|
|
52
|
+
.filter((path) =>
|
|
53
|
+
satisfied
|
|
54
|
+
? read(path) === undefined
|
|
55
|
+
: range.get(path) === undefined ||
|
|
56
|
+
range.get(path)?.status === "deleted" ||
|
|
57
|
+
read(path) === undefined,
|
|
74
58
|
)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (wrote(SPEC_FEEDBACK)) return false
|
|
79
|
-
if (changes(SPEC_FEEDBACK).length > 0 || changes().length === 0) return true
|
|
80
|
-
return refuse(
|
|
81
|
-
"gtd land: no declared pattern matches the pending changes — write .gtd/SPEC_FEEDBACK.md to request changes, or change nothing to approve",
|
|
59
|
+
if (missing.length === 0) return
|
|
60
|
+
refuse(
|
|
61
|
+
`gtd land: declared-tests: ${pkg} declares tests that are ${satisfied ? "missing from the tree" : "not in the package's diff"} — write them first:\n${missing.map((path) => ` - ${path}`).join("\n")}`,
|
|
82
62
|
)
|
|
83
63
|
}
|
|
84
64
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
65
|
+
export interface PackageRange {
|
|
66
|
+
readonly pkg: string
|
|
67
|
+
readonly from: string
|
|
68
|
+
readonly to: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface BuiltPlan {
|
|
72
|
+
readonly ranges: readonly PackageRange[]
|
|
73
|
+
/** Paths package 0 added / modified outside `.gtd/`. */
|
|
74
|
+
readonly scenarios: { readonly added: readonly string[]; readonly changed: readonly string[] }
|
|
75
|
+
/** The wording snapshot after this run, to guard the tail and later laps. */
|
|
76
|
+
readonly frozen: FrozenScenarios | undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Build `pkg`, check its declared tests, keep the fast suite green, and close it out, which removes it. */
|
|
80
|
+
export const packageItem = async (pkg: string, frozen?: FrozenScenarios): Promise<PackageRange> => {
|
|
81
|
+
const from = head()
|
|
82
|
+
// Rechecked after every fix too: a fix deleting a red declared test would
|
|
83
|
+
// otherwise green the suite. Before the wording gate, so it refuses the turn.
|
|
84
|
+
const declared = (turn: () => Promise<void>) => async () => {
|
|
85
|
+
await turn()
|
|
86
|
+
requireDeclaredTests(pkg, from)
|
|
93
87
|
}
|
|
94
|
-
|
|
95
|
-
|
|
88
|
+
await guarded(
|
|
89
|
+
frozen,
|
|
90
|
+
declared(() => build(pkg)),
|
|
91
|
+
)()
|
|
92
|
+
await healthy(guarded(frozen, declared(fixSuite)), { suite: "fast" })
|
|
93
|
+
await run("closing", removeScript([pkg, ".gtd/SATISFIED.md"]), {
|
|
96
94
|
label: "Closing out the package",
|
|
97
95
|
})
|
|
96
|
+
return { pkg, from, to: head() }
|
|
98
97
|
}
|
|
99
98
|
|
|
100
99
|
/** The first queued package — the one a package step works on. */
|
|
101
100
|
export const nextPackage = (): string | undefined => [...glob(".gtd/packages/*.md")].sort()[0]
|
|
102
101
|
|
|
102
|
+
const outsideGtd = (path: string): boolean => path !== ".gtd" && !path.startsWith(".gtd/")
|
|
103
|
+
|
|
103
104
|
/** Build every package file under `.gtd/packages/`, in name order. */
|
|
104
|
-
export const packages = (): Promise<
|
|
105
|
+
export const packages = (carried?: FrozenScenarios): Promise<BuiltPlan> =>
|
|
105
106
|
scope("packages", async () => {
|
|
107
|
+
let frozen = carried
|
|
108
|
+
const ranges: PackageRange[] = []
|
|
109
|
+
// Captured right after package 0: a later diff to the tree would also hold what 01…N touch.
|
|
110
|
+
let touched: readonly Change[] = []
|
|
106
111
|
for (let pkg = nextPackage(); pkg !== undefined; pkg = nextPackage()) {
|
|
107
|
-
await scope("item", () =>
|
|
112
|
+
const range = await scope("item", () =>
|
|
113
|
+
packageItem(pkg, pkg === SCENARIO_PACKAGE ? undefined : frozen),
|
|
114
|
+
)
|
|
115
|
+
ranges.push(range)
|
|
116
|
+
if (pkg === SCENARIO_PACKAGE) {
|
|
117
|
+
touched = changesSince(range.from).filter(
|
|
118
|
+
(c) => outsideGtd(c.path) && c.status !== "deleted",
|
|
119
|
+
)
|
|
120
|
+
frozen = freezeScenarios(
|
|
121
|
+
touched.filter((c) => c.path.endsWith(".feature")).map((c) => c.path),
|
|
122
|
+
frozen,
|
|
123
|
+
)
|
|
124
|
+
}
|
|
108
125
|
}
|
|
126
|
+
const paths = (status: string): string[] =>
|
|
127
|
+
touched.filter((c) => c.status === status).map((c) => c.path)
|
|
128
|
+
return { ranges, scenarios: { added: paths("added"), changed: paths("modified") }, frozen }
|
|
109
129
|
})
|
|
@@ -1,20 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
|
-
answered,
|
|
3
2
|
changes,
|
|
4
3
|
hasThreadFor,
|
|
5
|
-
judge,
|
|
6
|
-
moveScript,
|
|
7
|
-
numeric,
|
|
8
|
-
read,
|
|
9
4
|
refuse,
|
|
10
5
|
requireAnswers,
|
|
11
6
|
requireReplies,
|
|
12
7
|
requireThreadsClosed,
|
|
13
8
|
requireProgress,
|
|
14
|
-
run,
|
|
15
9
|
scope,
|
|
16
|
-
sections,
|
|
17
|
-
vars,
|
|
18
10
|
} from "../flows/index.js"
|
|
19
11
|
import {
|
|
20
12
|
answerProductQuestions,
|
|
@@ -25,7 +17,6 @@ import {
|
|
|
25
17
|
REQUIREMENTS,
|
|
26
18
|
triage,
|
|
27
19
|
} from "./steps.js"
|
|
28
|
-
import * as t from "./text.js"
|
|
29
20
|
|
|
30
21
|
/**
|
|
31
22
|
* Always stop for the human. Resolves `true` when the round changed anything
|
|
@@ -62,44 +53,9 @@ export const architecture = (): Promise<void> =>
|
|
|
62
53
|
if (changes(".gtd/packages/**").length === 0) {
|
|
63
54
|
refuse("gtd land: decompose: write at least one package under .gtd/packages/")
|
|
64
55
|
}
|
|
56
|
+
if (changes(ARCHITECTURE).some((c) => c.status === "deleted")) {
|
|
57
|
+
refuse(
|
|
58
|
+
"gtd land: decompose: keep .gtd/ARCHITECTURE.md — the build tail's full run needs it; it is removed only once that run is green",
|
|
59
|
+
)
|
|
60
|
+
}
|
|
65
61
|
})
|
|
66
|
-
|
|
67
|
-
/** A package file name from a plan's first heading. */
|
|
68
|
-
const slug = (title: string): string =>
|
|
69
|
-
title
|
|
70
|
-
.toLowerCase()
|
|
71
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
72
|
-
.replace(/^-+|-+$/g, "") || "package"
|
|
73
|
-
|
|
74
|
-
/** Whether the settled plan needs its own architecture pass, or goes straight to one package. */
|
|
75
|
-
export const architecturePass = async (): Promise<void> => {
|
|
76
|
-
const { answers, truncated } = await judge("architecture-pre", {
|
|
77
|
-
questions: [
|
|
78
|
-
{
|
|
79
|
-
id: "architectureWarranted",
|
|
80
|
-
primitive: "noul",
|
|
81
|
-
instructions:
|
|
82
|
-
"Given the settled concerns in `.gtd/REQUIREMENTS.md` (in state), does this plan warrant a dedicated architecture pass — real structural decisions, multiple integration points, or a non-obvious tradeoff — before packages are written?",
|
|
83
|
-
criteria:
|
|
84
|
-
"Answer yes if uncertain; a trivial, single-concern, mechanical plan with no real design decision answers no.",
|
|
85
|
-
},
|
|
86
|
-
],
|
|
87
|
-
evidence: { requirements: read(REQUIREMENTS) ?? "" },
|
|
88
|
-
message: t.architecturePreMessage(),
|
|
89
|
-
label: "Judging whether this plan warrants an architecture pass",
|
|
90
|
-
})
|
|
91
|
-
// A plan the budget cut can hide its structural concerns from the judge:
|
|
92
|
-
// never skip the architecture pass on it, however confident the "no".
|
|
93
|
-
const skip =
|
|
94
|
-
truncated.length === 0 &&
|
|
95
|
-
answered(answers.architectureWarranted, "no", numeric(vars.architectureSkipMinP, Infinity))
|
|
96
|
-
const plan = read(REQUIREMENTS)
|
|
97
|
-
if (skip && plan !== undefined) {
|
|
98
|
-
const target = `.gtd/packages/01-${slug(sections(plan)[0] ?? "")}.md`
|
|
99
|
-
await run("architecture-promote", moveScript(REQUIREMENTS, target), {
|
|
100
|
-
label: "Promoting the plan straight to a package",
|
|
101
|
-
})
|
|
102
|
-
return
|
|
103
|
-
}
|
|
104
|
-
await architecture()
|
|
105
|
-
}
|
package/src/workflows/prose.ts
CHANGED
|
@@ -77,8 +77,7 @@ export const architectPersona = `You are the technical planning voice in gtd's b
|
|
|
77
77
|
once product concerns are settled, reading them cold, no carried
|
|
78
78
|
conversation. Work out the *how* per concern — structure, data models,
|
|
79
79
|
tech-stack choices, error handling — raise the technical open
|
|
80
|
-
questions, then
|
|
81
|
-
judgement call: the grouping is already decided.`
|
|
80
|
+
questions, then pin interfaces and tests in the architecture document.`
|
|
82
81
|
|
|
83
82
|
export const reviewerPersona = `You are the independent reviewing mind in gtd's build pipeline —
|
|
84
83
|
deliberately separate from whoever wrote the code, with no attachment
|
|
@@ -89,13 +88,6 @@ and, when asked, fix the small nits the human flagged and the risks
|
|
|
89
88
|
you marked yourself, each in one batch.
|
|
90
89
|
Beyond that you never fix or build anything yourself.`
|
|
91
90
|
|
|
92
|
-
export const specReviewerPersona = `You are the adversarial spec-conformance checker in gtd's build
|
|
93
|
-
pipeline, checking one freshly-built package against its spec. Verify
|
|
94
|
-
only: tasks done, criteria met, code sound and consistent with the
|
|
95
|
-
codebase. Write feedback only when something is genuinely wrong —
|
|
96
|
-
otherwise write nothing; a clean turn IS the approval. Never fix what
|
|
97
|
-
you find — naming it precisely enough for a fix turn is the whole job.`
|
|
98
|
-
|
|
99
91
|
export const builderPersona = `You are the TDD implementer in gtd's build pipeline: build one
|
|
100
92
|
package's declared scope end to end, tests first, and come back to fix
|
|
101
93
|
things when redirected — a failing check, or reviewer feedback. Stay
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from "vitest"
|
|
2
2
|
import { installContext, type Change, type JudgeAnswer, type StepRequest } from "../flows/index.js"
|
|
3
|
-
import { review, type ReviewOutcome } from "./review.js"
|
|
4
|
-
import {
|
|
3
|
+
import { fixQualityFindings, review, type ReviewOutcome } from "./review.js"
|
|
4
|
+
import { bundled } from "./index.js"
|
|
5
5
|
import { fixtureContext } from "./text.fixture.js"
|
|
6
6
|
|
|
7
7
|
afterEach(() => installContext(undefined))
|
|
@@ -313,7 +313,7 @@ describe("review verdict routing", () => {
|
|
|
313
313
|
|
|
314
314
|
describe("the default quality lenses", () => {
|
|
315
315
|
it("are the six lenses in the settled order", () => {
|
|
316
|
-
expect(
|
|
316
|
+
expect(bundled.defaults.qualityReviews!.split(",").map((l) => l.trim())).toEqual([
|
|
317
317
|
"correctness",
|
|
318
318
|
"owasp-security",
|
|
319
319
|
"ponytail-review",
|
|
@@ -324,10 +324,97 @@ describe("the default quality lenses", () => {
|
|
|
324
324
|
})
|
|
325
325
|
|
|
326
326
|
it("expose builtInLenses through the public workflow module", () => {
|
|
327
|
-
expect(Object.keys(
|
|
327
|
+
expect(Object.keys(bundled.builtInLenses).sort()).toEqual([
|
|
328
328
|
"conventions",
|
|
329
329
|
"correctness",
|
|
330
330
|
"spec-challenge",
|
|
331
331
|
])
|
|
332
332
|
})
|
|
333
333
|
})
|
|
334
|
+
|
|
335
|
+
describe("scenario wording in review's fix turns", () => {
|
|
336
|
+
const frozen = { at: "h1", texts: { "e2e/a.feature": "Given a" } }
|
|
337
|
+
const risky = doc(["Risk: drops the carry", "sub"])
|
|
338
|
+
|
|
339
|
+
it("a green-keeping fix after a risk fix that rewrites a scenario stops at review.scenario-wording", async () => {
|
|
340
|
+
const log: string[] = []
|
|
341
|
+
let red = false
|
|
342
|
+
let drifted = false
|
|
343
|
+
installContext(
|
|
344
|
+
fixtureContext(
|
|
345
|
+
{},
|
|
346
|
+
{
|
|
347
|
+
step: (request: StepRequest) => {
|
|
348
|
+
if (request.kind === "restart") return Promise.resolve()
|
|
349
|
+
log.push(request.name)
|
|
350
|
+
if (request.name === "health.check") red = !red && !drifted
|
|
351
|
+
if (request.name === "fix") drifted = true
|
|
352
|
+
if (request.name === "scenario-wording") throw new Stop()
|
|
353
|
+
return Promise.resolve()
|
|
354
|
+
},
|
|
355
|
+
pushScope: () => undefined,
|
|
356
|
+
popScope: () => undefined,
|
|
357
|
+
read: (path) =>
|
|
358
|
+
path === REVIEW
|
|
359
|
+
? risky
|
|
360
|
+
: path === "e2e/a.feature"
|
|
361
|
+
? drifted
|
|
362
|
+
? "Given b"
|
|
363
|
+
: "Given a"
|
|
364
|
+
: undefined,
|
|
365
|
+
changes: (): readonly Change[] =>
|
|
366
|
+
red
|
|
367
|
+
? [{ path: ".gtd/FEEDBACK.md", status: "added", before: undefined, after: "red" }]
|
|
368
|
+
: [],
|
|
369
|
+
matches: (path, pattern) => path === pattern,
|
|
370
|
+
head: () => "h2",
|
|
371
|
+
start: () => "h0",
|
|
372
|
+
},
|
|
373
|
+
),
|
|
374
|
+
)
|
|
375
|
+
await review("base", undefined, frozen).catch((e: unknown) => {
|
|
376
|
+
if (!(e instanceof Stop)) throw e
|
|
377
|
+
})
|
|
378
|
+
expect(log.slice(-2)).toEqual(["fix", "scenario-wording"])
|
|
379
|
+
})
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
describe("the quality fix loop", () => {
|
|
383
|
+
it("a fix that resolves QUALITY.md and rewrites a scenario ends after the wording gate", async () => {
|
|
384
|
+
const QUALITY = ".gtd/QUALITY.md"
|
|
385
|
+
const log: string[] = []
|
|
386
|
+
const files = new Map([
|
|
387
|
+
[QUALITY, "- finding"],
|
|
388
|
+
["e2e/a.feature", "Given a"],
|
|
389
|
+
])
|
|
390
|
+
let last: readonly Change[] = []
|
|
391
|
+
installContext(
|
|
392
|
+
fixtureContext(
|
|
393
|
+
{},
|
|
394
|
+
{
|
|
395
|
+
step: (request: StepRequest) => {
|
|
396
|
+
if (request.kind === "restart") return Promise.resolve()
|
|
397
|
+
log.push(request.name)
|
|
398
|
+
last = []
|
|
399
|
+
if (request.name === "fix.quality.fixing") {
|
|
400
|
+
files.delete(QUALITY)
|
|
401
|
+
files.set("e2e/a.feature", "Given b")
|
|
402
|
+
last = [{ path: QUALITY, status: "deleted", before: "- finding", after: undefined }]
|
|
403
|
+
}
|
|
404
|
+
return Promise.resolve()
|
|
405
|
+
},
|
|
406
|
+
pushScope: () => undefined,
|
|
407
|
+
popScope: () => undefined,
|
|
408
|
+
read: (path) => files.get(path),
|
|
409
|
+
changes: () => last,
|
|
410
|
+
matches: (path, pattern) => path === pattern,
|
|
411
|
+
head: () => "h2",
|
|
412
|
+
start: () => "h0",
|
|
413
|
+
},
|
|
414
|
+
),
|
|
415
|
+
)
|
|
416
|
+
const frozen = { at: "h1", texts: { "e2e/a.feature": "Given a" } }
|
|
417
|
+
expect(await fixQualityFindings({ rounds: 0 }, frozen)).toBe(true)
|
|
418
|
+
expect(log).toEqual(["fix.quality.fixing", "scenario-wording"])
|
|
419
|
+
})
|
|
420
|
+
})
|
package/src/workflows/review.ts
CHANGED
|
@@ -19,11 +19,14 @@ import {
|
|
|
19
19
|
} from "../flows/index.js"
|
|
20
20
|
import { reviewNotes, reviewRisks, stripCodeThreads, type ReviewNote } from "../steering/index.js"
|
|
21
21
|
import { escalation, FIX_CAP, healthy, type EscalationCount } from "./health.js"
|
|
22
|
+
import type { BuiltPlan } from "./packages.js"
|
|
23
|
+
import { guarded, type FrozenScenarios } from "./scenarios.js"
|
|
22
24
|
import {
|
|
23
25
|
answerReviewQuestions,
|
|
26
|
+
ARCHITECTURE,
|
|
24
27
|
awaitReview,
|
|
25
28
|
collecting,
|
|
26
|
-
|
|
29
|
+
fixCheck,
|
|
27
30
|
fixNits,
|
|
28
31
|
fixQuality,
|
|
29
32
|
fixRisks,
|
|
@@ -54,10 +57,14 @@ export const qualityLap = async (): Promise<"clean" | "findings"> => {
|
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
/** Resolves `true` once `.gtd/QUALITY.md` is resolved, `false` when the fix cap escalated instead. */
|
|
57
|
-
export const fixQualityFindings = async (
|
|
60
|
+
export const fixQualityFindings = async (
|
|
61
|
+
escalations: EscalationCount,
|
|
62
|
+
frozen?: FrozenScenarios,
|
|
63
|
+
): Promise<boolean> => {
|
|
58
64
|
for (let turns = 0; turns < FIX_CAP; turns++) {
|
|
59
|
-
await fixQuality()
|
|
60
|
-
|
|
65
|
+
await guarded(frozen, fixQuality)()
|
|
66
|
+
// Not `changes()`: a wording gate after the fix would be the last step.
|
|
67
|
+
if (read(QUALITY) === undefined) return true
|
|
61
68
|
}
|
|
62
69
|
await escalation(escalations)
|
|
63
70
|
return false
|
|
@@ -203,6 +210,7 @@ type Finish =
|
|
|
203
210
|
interface Round {
|
|
204
211
|
readonly round: string
|
|
205
212
|
readonly escalations: EscalationCount
|
|
213
|
+
readonly frozen: FrozenScenarios | undefined
|
|
206
214
|
readonly close: () => Promise<void>
|
|
207
215
|
readonly outcome: (verdict: "signoff" | "feedback") => ReviewOutcome
|
|
208
216
|
readonly unfolded: () => Promise<Finish>
|
|
@@ -221,8 +229,8 @@ const routeNotes = async (notes: readonly ReviewNote[], r: Round): Promise<Finis
|
|
|
221
229
|
answeredAt = head()
|
|
222
230
|
}
|
|
223
231
|
if (nits.length > 0) {
|
|
224
|
-
await fixNits(nits)
|
|
225
|
-
await healthy(
|
|
232
|
+
await guarded(r.frozen, () => fixNits(nits), "review")()
|
|
233
|
+
await healthy(guarded(r.frozen, fixCheck, "review"), { escalations: r.escalations })
|
|
226
234
|
}
|
|
227
235
|
if (edits.length > 0) {
|
|
228
236
|
await r.close()
|
|
@@ -239,6 +247,7 @@ const finish = async (
|
|
|
239
247
|
reviewed: string,
|
|
240
248
|
collectedAt: string | undefined,
|
|
241
249
|
escalations: EscalationCount,
|
|
250
|
+
frozen: FrozenScenarios | undefined,
|
|
242
251
|
): Promise<Finish> => {
|
|
243
252
|
// Everything the human did is read before any agent turn runs, so a nit fix
|
|
244
253
|
// is never counted as a hand-edit.
|
|
@@ -268,7 +277,7 @@ const finish = async (
|
|
|
268
277
|
if (edited.length > 0) return collectWith(t.reviewEditsCapture(round))
|
|
269
278
|
const notes = reviewNotes(baselineText, record)
|
|
270
279
|
if (notes.length === 0) return collectWith(t.reviewNotesCapture(round))
|
|
271
|
-
return routeNotes(notes, { round, escalations, close, outcome, unfolded })
|
|
280
|
+
return routeNotes(notes, { round, escalations, frozen, close, outcome, unfolded })
|
|
272
281
|
}
|
|
273
282
|
|
|
274
283
|
/** Write the review; if it marks risks, fix them, keep green, and write it again — once, so the re-review's own risks reach the human unfixed. */
|
|
@@ -276,12 +285,13 @@ const reviewOnce = async (
|
|
|
276
285
|
base: string,
|
|
277
286
|
carry: string | undefined,
|
|
278
287
|
escalations: EscalationCount,
|
|
288
|
+
frozen: FrozenScenarios | undefined,
|
|
279
289
|
): Promise<void> => {
|
|
280
290
|
await reviewing(base, carry)
|
|
281
291
|
const risks = reviewRisks(read(REVIEW) ?? "")
|
|
282
292
|
if (risks.length === 0) return
|
|
283
|
-
await fixRisks(risks)
|
|
284
|
-
await healthy(
|
|
293
|
+
await guarded(frozen, () => fixRisks(risks), "review")()
|
|
294
|
+
await healthy(guarded(frozen, fixCheck, "review"), { escalations })
|
|
285
295
|
await reviewing(base, carry)
|
|
286
296
|
}
|
|
287
297
|
|
|
@@ -294,17 +304,18 @@ const reviewOnce = async (
|
|
|
294
304
|
export const review = async (
|
|
295
305
|
base: string,
|
|
296
306
|
escalations: EscalationCount = { rounds: 0 },
|
|
307
|
+
frozen?: FrozenScenarios,
|
|
297
308
|
): Promise<ReviewOutcome> => {
|
|
298
309
|
let carry: string | undefined
|
|
299
310
|
for (;;) {
|
|
300
|
-
await reviewOnce(base, carry, escalations)
|
|
311
|
+
await reviewOnce(base, carry, escalations, frozen)
|
|
301
312
|
carry = undefined
|
|
302
313
|
let reviewed = head()
|
|
303
314
|
let collectedAt: string | undefined
|
|
304
315
|
for (;;) {
|
|
305
316
|
collectedAt = await converse(base, collectedAt)
|
|
306
317
|
if (collectedAt === "missing") break
|
|
307
|
-
const result = await finish(reviewed, collectedAt, escalations)
|
|
318
|
+
const result = await finish(reviewed, collectedAt, escalations, frozen)
|
|
308
319
|
if (!("next" in result)) return result
|
|
309
320
|
if (result.next === "rereview") {
|
|
310
321
|
carry = result.carry
|
|
@@ -316,22 +327,36 @@ export const review = async (
|
|
|
316
327
|
}
|
|
317
328
|
|
|
318
329
|
/** The build tail: fix (when entered red), keep green, the quality lap, then human review since `base`. */
|
|
319
|
-
export const buildTail = (
|
|
330
|
+
export const buildTail = (
|
|
331
|
+
fixFirst: boolean,
|
|
332
|
+
base: string,
|
|
333
|
+
built?: BuiltPlan,
|
|
334
|
+
): Promise<ReviewOutcome> =>
|
|
320
335
|
scope("build", async () => {
|
|
336
|
+
const frozen = built?.frozen
|
|
321
337
|
const escalations: EscalationCount = { rounds: 0 }
|
|
338
|
+
const guardedFix = guarded(frozen, () => fixCheck())
|
|
322
339
|
let redFirst = fixFirst
|
|
340
|
+
// fixFirst's own fix + healthy below already is the full run.
|
|
341
|
+
if (!fixFirst) {
|
|
342
|
+
await healthy(
|
|
343
|
+
guarded(frozen, () => fixCheck(built)),
|
|
344
|
+
{ escalations, sweepOnGreen: [ARCHITECTURE] },
|
|
345
|
+
)
|
|
346
|
+
}
|
|
323
347
|
// The lap runs once a tail: after its findings are fixed, review follows.
|
|
324
348
|
let lapped = false
|
|
325
349
|
for (;;) {
|
|
326
350
|
if (redFirst) {
|
|
327
|
-
await
|
|
328
|
-
await healthy(
|
|
351
|
+
await guardedFix()
|
|
352
|
+
await healthy(guardedFix, { fixesSoFar: 1, escalations })
|
|
329
353
|
redFirst = false
|
|
330
354
|
}
|
|
331
355
|
const lap = lapped ? "clean" : await qualityLap()
|
|
332
356
|
lapped = true
|
|
333
|
-
if (lap === "clean") return review(base, escalations)
|
|
334
|
-
if (await fixQualityFindings(escalations))
|
|
335
|
-
|
|
357
|
+
if (lap === "clean") return review(base, escalations, frozen)
|
|
358
|
+
if (await fixQualityFindings(escalations, frozen)) {
|
|
359
|
+
await healthy(guardedFix, { escalations })
|
|
360
|
+
} else redFirst = true
|
|
336
361
|
}
|
|
337
362
|
})
|