@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,256 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
2
|
+
import { installContext, type StepRequest } from "../flows/index.js"
|
|
3
|
+
import {
|
|
4
|
+
driftedScenarios,
|
|
5
|
+
freezeScenarios,
|
|
6
|
+
guarded,
|
|
7
|
+
holdWording,
|
|
8
|
+
scenarioText,
|
|
9
|
+
} from "./scenarios.js"
|
|
10
|
+
import { fixtureContext, renderText } from "./text.fixture.js"
|
|
11
|
+
|
|
12
|
+
afterEach(() => installContext(undefined))
|
|
13
|
+
|
|
14
|
+
const tree = (files: Record<string, string>) => ({
|
|
15
|
+
read: (path: string) => files[path],
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe("scenarioText", () => {
|
|
19
|
+
it("ignores comments, tags, blank lines and indentation", () => {
|
|
20
|
+
const loose = [
|
|
21
|
+
"@wip @slow",
|
|
22
|
+
"Feature: x",
|
|
23
|
+
"",
|
|
24
|
+
" # a comment",
|
|
25
|
+
" @tag",
|
|
26
|
+
" Scenario: y",
|
|
27
|
+
" Given a thing",
|
|
28
|
+
].join("\n")
|
|
29
|
+
const tight = "Feature: x\nScenario: y\nGiven a thing"
|
|
30
|
+
expect(scenarioText(loose)).toBe(scenarioText(tight))
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("keeps changed wording", () => {
|
|
34
|
+
expect(scenarioText("Given a")).not.toBe(scenarioText("Given b"))
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it.each([
|
|
38
|
+
["a # line", "# Review: abc"],
|
|
39
|
+
["an @ line", "@mention"],
|
|
40
|
+
["a blank line", ""],
|
|
41
|
+
])("keeps %s inside a doc string", (_, line) => {
|
|
42
|
+
const doc = (body: string[]) =>
|
|
43
|
+
["Given a file with:", ' """', ...body.map((l) => (l === "" ? "" : ` ${l}`)), ' """'].join(
|
|
44
|
+
"\n",
|
|
45
|
+
)
|
|
46
|
+
expect(scenarioText(doc(["top", line, "end"]))).not.toBe(scenarioText(doc(["top", "end"])))
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("keeps indentation inside a doc string", () => {
|
|
50
|
+
const doc = (line: string) => ["Given a file with:", ' """', ` ${line}`, ' """'].join("\n")
|
|
51
|
+
expect(scenarioText(doc(" nested"))).not.toBe(scenarioText(doc("nested")))
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("ignores a doc string re-indented together with its delimiters", () => {
|
|
55
|
+
const at = (indent: string) =>
|
|
56
|
+
[
|
|
57
|
+
"Given a file with:",
|
|
58
|
+
`${indent}\`\`\``,
|
|
59
|
+
`${indent}# Review`,
|
|
60
|
+
`${indent} - x`,
|
|
61
|
+
`${indent}\`\`\``,
|
|
62
|
+
].join("\n")
|
|
63
|
+
expect(scenarioText(at(" "))).toBe(scenarioText(at(" ")))
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe("freezeScenarios", () => {
|
|
68
|
+
it("snapshots wording at head, nothing frozen is undefined", () => {
|
|
69
|
+
const files = { "a.feature": "@t\nGiven a" }
|
|
70
|
+
renderText(() => {
|
|
71
|
+
expect(freezeScenarios([])).toBeUndefined()
|
|
72
|
+
})
|
|
73
|
+
installContext(fixtureContext({ head: "h1" }, tree(files)))
|
|
74
|
+
expect(freezeScenarios(["a.feature"])).toEqual({
|
|
75
|
+
at: "h1",
|
|
76
|
+
texts: { "a.feature": scenarioText("Given a") },
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("merges over previous, a new path overriding an old one", () => {
|
|
81
|
+
installContext(
|
|
82
|
+
fixtureContext({ head: "h2" }, tree({ "b.feature": "Given b", "a.feature": "Given a2" })),
|
|
83
|
+
)
|
|
84
|
+
const merged = freezeScenarios(["a.feature", "b.feature"], {
|
|
85
|
+
at: "h1",
|
|
86
|
+
texts: { "a.feature": "Given a", "c.feature": "Given c" },
|
|
87
|
+
})
|
|
88
|
+
expect(merged).toEqual({
|
|
89
|
+
at: "h2",
|
|
90
|
+
texts: {
|
|
91
|
+
"a.feature": "Given a2",
|
|
92
|
+
"b.feature": "Given b",
|
|
93
|
+
"c.feature": "Given c",
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("keeps previous untouched when no paths are given", () => {
|
|
99
|
+
const previous = { at: "h1", texts: { "a.feature": "Given a" } }
|
|
100
|
+
installContext(fixtureContext({ head: "h2" }, tree({})))
|
|
101
|
+
expect(freezeScenarios([], previous)).toBe(previous)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe("driftedScenarios", () => {
|
|
106
|
+
const frozen = { at: "h1", texts: { "a.feature": "Given a", "b.feature": "Given b" } }
|
|
107
|
+
|
|
108
|
+
it("flags edited and deleted files, ignores comment-only edits and other paths", () => {
|
|
109
|
+
installContext(
|
|
110
|
+
fixtureContext({}, tree({ "a.feature": "# note\nGiven a2", "other.feature": "Given z" })),
|
|
111
|
+
)
|
|
112
|
+
expect(driftedScenarios(frozen)).toEqual(["a.feature", "b.feature"])
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("is empty while wording is unchanged", () => {
|
|
116
|
+
installContext(
|
|
117
|
+
fixtureContext({}, tree({ "a.feature": " @x\n Given a", "b.feature": "Given b" })),
|
|
118
|
+
)
|
|
119
|
+
expect(driftedScenarios(frozen)).toEqual([])
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe("holdWording", () => {
|
|
124
|
+
const run = async (
|
|
125
|
+
files: () => Record<string, string>,
|
|
126
|
+
frozen: Parameters<typeof holdWording>[0],
|
|
127
|
+
head = "h2",
|
|
128
|
+
): Promise<StepRequest[]> => {
|
|
129
|
+
const steps: StepRequest[] = []
|
|
130
|
+
installContext(
|
|
131
|
+
fixtureContext(
|
|
132
|
+
{ head },
|
|
133
|
+
{
|
|
134
|
+
read: (path) => files()[path],
|
|
135
|
+
step: (request) => {
|
|
136
|
+
steps.push(request)
|
|
137
|
+
return Promise.resolve()
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
await holdWording(frozen)
|
|
143
|
+
return steps
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
it("does nothing without a freeze or without drift", async () => {
|
|
147
|
+
expect(await run(() => ({}), undefined)).toEqual([])
|
|
148
|
+
const frozen = { at: "h1", texts: { "a.feature": "Given a" } }
|
|
149
|
+
expect(await run(() => ({ "a.feature": "Given a" }), frozen)).toEqual([])
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it("rests at scenario-wording with the removed and added lines and the restore command", async () => {
|
|
153
|
+
const frozen = { at: "h1", texts: { "my dir/a.feature": "Given a\nThen ok" } }
|
|
154
|
+
const steps = await run(() => ({ "my dir/a.feature": "Given a\nThen better" }), frozen)
|
|
155
|
+
const [step] = steps
|
|
156
|
+
if (step?.kind !== "human") throw new Error("expected a human step")
|
|
157
|
+
expect(step.name).toBe("scenario-wording")
|
|
158
|
+
expect(step.options.acceptClean).toBe(true)
|
|
159
|
+
expect(step.options.base).toBe("h1")
|
|
160
|
+
const message = step.options.message ?? ""
|
|
161
|
+
expect(message).toContain("- Then ok")
|
|
162
|
+
expect(message).toContain("+ Then better")
|
|
163
|
+
expect(message).toContain("git checkout h1 -- 'my dir/a.feature'")
|
|
164
|
+
expect(message).toContain("partial accept")
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const shownDiff = async (before: string, after: string): Promise<string[]> => {
|
|
168
|
+
const frozen = { at: "h1", texts: { "a.feature": before } }
|
|
169
|
+
const [step] = await run(() => ({ "a.feature": after }), frozen)
|
|
170
|
+
if (step?.kind !== "human") throw new Error("expected a human step")
|
|
171
|
+
return (step.options.message ?? "").split("\n").filter((l) => /^[-+] /.test(l))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
it("shows a moved step as one removal and one addition of it, in place", async () => {
|
|
175
|
+
expect(await shownDiff("Given a\nWhen b\nThen c", "When b\nGiven a\nThen c")).toEqual([
|
|
176
|
+
"- Given a",
|
|
177
|
+
"+ Given a",
|
|
178
|
+
])
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it("shows a blank line added inside a doc string", async () => {
|
|
182
|
+
expect(await shownDiff('"""\na\nb\n"""', '"""\na\n\nb\n"""')).toEqual(["+ "])
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it("shows a repeated step as its added copy", async () => {
|
|
186
|
+
expect(await shownDiff("Given a\nThen c", "Given a\nGiven a\nThen c")).toEqual(["+ Given a"])
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it("accepts still-differing files by moving the snapshot to head", async () => {
|
|
190
|
+
const frozen = { at: "h1", texts: { "a.feature": "Given a", "b.feature": "Given b" } }
|
|
191
|
+
await run(() => ({ "a.feature": "Given a2", "b.feature": "Given b2" }), frozen)
|
|
192
|
+
expect(frozen).toEqual({
|
|
193
|
+
at: "h2",
|
|
194
|
+
texts: { "a.feature": "Given a2", "b.feature": "Given b2" },
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it("rejects restored files, keeping the original wording frozen", async () => {
|
|
199
|
+
const frozen = { at: "h1", texts: { "a.feature": "Given a", "b.feature": "Given b" } }
|
|
200
|
+
let files: Record<string, string> = { "a.feature": "Given a2", "b.feature": "Given b2" }
|
|
201
|
+
installContext(
|
|
202
|
+
fixtureContext(
|
|
203
|
+
{ head: "h2" },
|
|
204
|
+
{
|
|
205
|
+
read: (path) => files[path],
|
|
206
|
+
step: () => {
|
|
207
|
+
files = { "a.feature": "Given a", "b.feature": "Given b2" }
|
|
208
|
+
return Promise.resolve()
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
await holdWording(frozen)
|
|
214
|
+
expect(frozen.texts).toEqual({ "a.feature": "Given a", "b.feature": "Given b2" })
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it("drops an accepted deletion from the snapshot", async () => {
|
|
218
|
+
const frozen = { at: "h1", texts: { "a.feature": "Given a" } }
|
|
219
|
+
await run(() => ({}), frozen)
|
|
220
|
+
expect(frozen.texts).toEqual({})
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
describe("guarded", () => {
|
|
225
|
+
it("runs the turn, then holds wording", async () => {
|
|
226
|
+
const order: string[] = []
|
|
227
|
+
const frozen = { at: "h1", texts: { "a.feature": "Given a" } }
|
|
228
|
+
installContext(
|
|
229
|
+
fixtureContext(
|
|
230
|
+
{ head: "h2" },
|
|
231
|
+
{
|
|
232
|
+
read: () => "Given changed",
|
|
233
|
+
step: (request) => {
|
|
234
|
+
order.push(request.kind)
|
|
235
|
+
return Promise.resolve()
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
await guarded(frozen, () => {
|
|
241
|
+
order.push("turn")
|
|
242
|
+
return Promise.resolve()
|
|
243
|
+
})()
|
|
244
|
+
expect(order).toEqual(["turn", "human"])
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it("is the bare turn without a freeze", async () => {
|
|
248
|
+
const order: string[] = []
|
|
249
|
+
installContext(fixtureContext())
|
|
250
|
+
await guarded(undefined, () => {
|
|
251
|
+
order.push("turn")
|
|
252
|
+
return Promise.resolve()
|
|
253
|
+
})()
|
|
254
|
+
expect(order).toEqual(["turn"])
|
|
255
|
+
})
|
|
256
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { head, human, read, scope } from "../flows/index.js"
|
|
2
|
+
import * as t from "./text.js"
|
|
3
|
+
|
|
4
|
+
/** Mutable: an accepted change moves the snapshot forward. */
|
|
5
|
+
export interface FrozenScenarios {
|
|
6
|
+
at: string
|
|
7
|
+
texts: Record<string, string>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const DOC_STRING = /^(\s*)("""|```)/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Gherkin wording only: lines trimmed; blank, `#` comment and `@` tag lines
|
|
14
|
+
* dropped — except inside a doc string, whose content is test input: kept
|
|
15
|
+
* whole, indented relative to its opening delimiter (as Gherkin reads it).
|
|
16
|
+
*/
|
|
17
|
+
export const scenarioText = (source: string): string => {
|
|
18
|
+
const out: string[] = []
|
|
19
|
+
let doc: { readonly indent: number; readonly fence: string } | undefined
|
|
20
|
+
for (const line of source.split("\n")) {
|
|
21
|
+
const delimiter = DOC_STRING.exec(line)
|
|
22
|
+
if (doc === undefined && delimiter !== null) {
|
|
23
|
+
doc = { indent: delimiter[1]!.length, fence: delimiter[2]! }
|
|
24
|
+
out.push(line.trim())
|
|
25
|
+
} else if (doc !== undefined && line.trim().startsWith(doc.fence)) {
|
|
26
|
+
doc = undefined
|
|
27
|
+
out.push(line.trim())
|
|
28
|
+
} else if (doc !== undefined) {
|
|
29
|
+
const lead = line.length - line.trimStart().length
|
|
30
|
+
out.push(line.slice(Math.min(lead, doc.indent)).trimEnd())
|
|
31
|
+
} else {
|
|
32
|
+
const trimmed = line.trim()
|
|
33
|
+
if (trimmed !== "" && !trimmed.startsWith("#") && !trimmed.startsWith("@")) out.push(trimmed)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out.join("\n")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const textOf = (path: string): string | undefined => {
|
|
40
|
+
const source = read(path)
|
|
41
|
+
return source === undefined ? undefined : scenarioText(source)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Snapshot `.feature` paths at `head()`, merged over `previous`; `undefined` when nothing is frozen. */
|
|
45
|
+
export const freezeScenarios = (
|
|
46
|
+
paths: readonly string[],
|
|
47
|
+
previous?: FrozenScenarios,
|
|
48
|
+
): FrozenScenarios | undefined => {
|
|
49
|
+
if (paths.length === 0) return previous
|
|
50
|
+
const texts: Record<string, string> = { ...previous?.texts }
|
|
51
|
+
for (const path of paths) {
|
|
52
|
+
const text = textOf(path)
|
|
53
|
+
if (text !== undefined) texts[path] = text
|
|
54
|
+
}
|
|
55
|
+
return Object.keys(texts).length === 0 ? previous : { at: head(), texts }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Paths whose wording differs from the snapshot (a deleted file counts). */
|
|
59
|
+
export const driftedScenarios = (frozen: FrozenScenarios): readonly string[] =>
|
|
60
|
+
Object.entries(frozen.texts)
|
|
61
|
+
.filter(([path, text]) => textOf(path) !== text)
|
|
62
|
+
.map(([path]) => path)
|
|
63
|
+
|
|
64
|
+
// Blank lines survive only inside doc strings, where they are wording.
|
|
65
|
+
const lines = (text: string | undefined): string[] =>
|
|
66
|
+
text === undefined || text === "" ? [] : text.split("\n")
|
|
67
|
+
|
|
68
|
+
/** `table[i][j]`: the longest common subsequence of `a[i..]` and `b[j..]`. */
|
|
69
|
+
const lcsTable = (a: readonly string[], b: readonly string[]): number[][] => {
|
|
70
|
+
const table = Array.from({ length: a.length + 1 }, () => Array<number>(b.length + 1).fill(0))
|
|
71
|
+
for (let i = a.length - 1; i >= 0; i--) {
|
|
72
|
+
for (let j = b.length - 1; j >= 0; j--) {
|
|
73
|
+
table[i]![j] =
|
|
74
|
+
a[i] === b[j] ? table[i + 1]![j + 1]! + 1 : Math.max(table[i + 1]![j]!, table[i]![j + 1]!)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return table
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Changed lines in file order, `- ` / `+ `-prefixed, so a moved or repeated step still shows. */
|
|
81
|
+
const lineDiff = (a: readonly string[], b: readonly string[]): string[] => {
|
|
82
|
+
const table = lcsTable(a, b)
|
|
83
|
+
const out: string[] = []
|
|
84
|
+
let i = 0
|
|
85
|
+
let j = 0
|
|
86
|
+
while (i < a.length && j < b.length) {
|
|
87
|
+
if (a[i] === b[j]) {
|
|
88
|
+
i++
|
|
89
|
+
j++
|
|
90
|
+
} else if (table[i + 1]![j]! >= table[i]![j + 1]!) out.push(`- ${a[i++]}`)
|
|
91
|
+
else out.push(`+ ${b[j++]}`)
|
|
92
|
+
}
|
|
93
|
+
return [...out, ...a.slice(i).map((l) => `- ${l}`), ...b.slice(j).map((l) => `+ ${l}`)]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const wordingDrift = (frozen: FrozenScenarios, path: string): t.WordingDrift => ({
|
|
97
|
+
path,
|
|
98
|
+
lines: lineDiff(lines(frozen.texts[path]), lines(textOf(path))),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
/** After a turn: stop at `scenario-wording` while wording drifted; accept moves the snapshot. */
|
|
102
|
+
export const holdWording = async (frozen: FrozenScenarios | undefined): Promise<void> => {
|
|
103
|
+
if (frozen === undefined) return
|
|
104
|
+
const drifted = driftedScenarios(frozen)
|
|
105
|
+
if (drifted.length === 0) return
|
|
106
|
+
await human("scenario-wording", {
|
|
107
|
+
message: t.scenarioWordingMessage(
|
|
108
|
+
frozen.at,
|
|
109
|
+
drifted.map((path) => wordingDrift(frozen, path)),
|
|
110
|
+
),
|
|
111
|
+
label: "Scenario wording changed",
|
|
112
|
+
acceptClean: true,
|
|
113
|
+
base: frozen.at,
|
|
114
|
+
})
|
|
115
|
+
// Still differing is accepted; restored is rejected and stays frozen as it was.
|
|
116
|
+
const accepted = driftedScenarios(frozen)
|
|
117
|
+
if (accepted.length === 0) return
|
|
118
|
+
for (const path of accepted) {
|
|
119
|
+
const text = textOf(path)
|
|
120
|
+
if (text === undefined) delete frozen.texts[path]
|
|
121
|
+
else frozen.texts[path] = text
|
|
122
|
+
}
|
|
123
|
+
frozen.at = head()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** `turn`, then `holdWording`; `within` names the scope the gate rests in. */
|
|
127
|
+
export const guarded =
|
|
128
|
+
(
|
|
129
|
+
frozen: FrozenScenarios | undefined,
|
|
130
|
+
turn: () => Promise<void>,
|
|
131
|
+
within?: string,
|
|
132
|
+
): (() => Promise<void>) =>
|
|
133
|
+
async () => {
|
|
134
|
+
await turn()
|
|
135
|
+
if (within === undefined) await holdWording(frozen)
|
|
136
|
+
else await scope(within, () => holdWording(frozen))
|
|
137
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
2
|
import { skills } from "./skills.js"
|
|
3
|
-
import {
|
|
3
|
+
import { bundled as workflow } from "./index.js"
|
|
4
4
|
|
|
5
|
-
const { defaults } =
|
|
5
|
+
const { defaults } = workflow
|
|
6
6
|
|
|
7
7
|
const bundled = skills(defaults)
|
|
8
8
|
|
|
@@ -18,8 +18,6 @@ describe("the bundled workflow's skills export", () => {
|
|
|
18
18
|
"architecture.decompose",
|
|
19
19
|
"packages.item",
|
|
20
20
|
"packages.item.fix.suite",
|
|
21
|
-
"packages.item.fix.spec",
|
|
22
|
-
"packages.item.spec",
|
|
23
21
|
"packages.item.health",
|
|
24
22
|
"build",
|
|
25
23
|
"build.health",
|
|
@@ -50,8 +48,6 @@ describe("the bundled workflow's skills export", () => {
|
|
|
50
48
|
"architecture.decompose": ["incremental-implementation", "planning-and-task-breakdown"],
|
|
51
49
|
"packages.item": ["test-driven-development", "incremental-implementation"],
|
|
52
50
|
"packages.item.fix.suite": ["debugging-and-error-recovery"],
|
|
53
|
-
"packages.item.fix.spec": ["incremental-implementation", "code-simplification"],
|
|
54
|
-
"packages.item.spec": ["code-review-and-quality", "spec-driven-development"],
|
|
55
51
|
"packages.item.health": ["debugging-and-error-recovery"],
|
|
56
52
|
build: ["debugging-and-error-recovery"],
|
|
57
53
|
"build.health": ["debugging-and-error-recovery"],
|
package/src/workflows/skills.ts
CHANGED
|
@@ -15,8 +15,6 @@ export const skills = (
|
|
|
15
15
|
"architecture.decompose": ["incremental-implementation", "planning-and-task-breakdown"],
|
|
16
16
|
"packages.item": ["test-driven-development", "incremental-implementation"],
|
|
17
17
|
"packages.item.fix.suite": ["debugging-and-error-recovery"],
|
|
18
|
-
"packages.item.fix.spec": ["incremental-implementation", "code-simplification"],
|
|
19
|
-
"packages.item.spec": ["code-review-and-quality", "spec-driven-development"],
|
|
20
18
|
// `health` is shared code reached from both `packages.item` and `build`.
|
|
21
19
|
"packages.item.health": ["debugging-and-error-recovery"],
|
|
22
20
|
build: ["debugging-and-error-recovery"],
|
|
@@ -40,18 +40,8 @@ describe("the bundled workflow's steps declare skills — a bundled step's rende
|
|
|
40
40
|
expect(prompt).toContain("debugging-and-error-recovery")
|
|
41
41
|
})
|
|
42
42
|
|
|
43
|
-
it("fixSpec carries packages.item.fix.spec's bundled skills", async () => {
|
|
44
|
-
const prompt = agentPrompt(await capture(() => steps.fixSpec("pkg"), "packages.item"))
|
|
45
|
-
expect(prompt).toContain("incremental-implementation, code-simplification")
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
it("reviewPackage carries packages.item.spec.review's bundled skills", async () => {
|
|
49
|
-
const prompt = agentPrompt(await capture(() => steps.reviewPackage("pkg"), "packages.item"))
|
|
50
|
-
expect(prompt).toContain("code-review-and-quality, spec-driven-development")
|
|
51
|
-
})
|
|
52
|
-
|
|
53
43
|
it("fix carries build.fix's bundled skills", async () => {
|
|
54
|
-
const prompt = agentPrompt(await capture(() => steps.
|
|
44
|
+
const prompt = agentPrompt(await capture(() => steps.fixCheck(), "build"))
|
|
55
45
|
expect(prompt).toContain("debugging-and-error-recovery")
|
|
56
46
|
})
|
|
57
47
|
|
|
@@ -120,7 +110,7 @@ describe("the bundled workflow's steps declare skills — a bundled step's rende
|
|
|
120
110
|
})
|
|
121
111
|
|
|
122
112
|
it("an empty configured list leaves that step's skills empty, and its rendered prompt bare", async () => {
|
|
123
|
-
const request = await capture(() => steps.
|
|
113
|
+
const request = await capture(() => steps.fixCheck(), "build", { build: [] })
|
|
124
114
|
if (request.kind !== "agent") throw new Error("unreachable")
|
|
125
115
|
expect(request.prompt).not.toContain("Load whatever's listed here")
|
|
126
116
|
})
|
package/src/workflows/steps.ts
CHANGED
|
@@ -12,7 +12,6 @@ export const REQUIREMENTS = ".gtd/REQUIREMENTS.md"
|
|
|
12
12
|
export const ARCHITECTURE = ".gtd/ARCHITECTURE.md"
|
|
13
13
|
export const REVIEW = ".gtd/REVIEW.md"
|
|
14
14
|
export const QUALITY = ".gtd/QUALITY.md"
|
|
15
|
-
export const SPEC_FEEDBACK = ".gtd/SPEC_FEEDBACK.md"
|
|
16
15
|
|
|
17
16
|
const planner = (): string => env.plannerModel ?? ""
|
|
18
17
|
const coder = (): string => env.coderModel ?? ""
|
|
@@ -80,27 +79,10 @@ export const fixSuite = (): Promise<void> =>
|
|
|
80
79
|
system: t.builderSystem(),
|
|
81
80
|
})
|
|
82
81
|
|
|
83
|
-
export const fixSpec = (pkg: string): Promise<void> =>
|
|
84
|
-
t.agentWithSkills("fix.spec.fixing", t.packagesItemFixSpecPrompt(pkg), {
|
|
85
|
-
label: "Fixing review feedback",
|
|
86
|
-
file: SPEC_FEEDBACK,
|
|
87
|
-
model: coder(),
|
|
88
|
-
system: t.builderSystem(),
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
/** Review `pkg` against its spec, focused on the `failing` sections the pre-judge could not clear. */
|
|
92
|
-
export const reviewPackage = (pkg: string, failing: readonly string[] = []): Promise<void> =>
|
|
93
|
-
t.agentWithSkills("spec.review", t.packagesItemSpecReviewPrompt(pkg, failing), {
|
|
94
|
-
label: "Reviewing the package",
|
|
95
|
-
model: planner(),
|
|
96
|
-
system: t.specReviewerSystem(),
|
|
97
|
-
allowEmpty: true,
|
|
98
|
-
})
|
|
99
|
-
|
|
100
82
|
// ── Keeping the suite green ─────────────────────────────────────────────────
|
|
101
83
|
|
|
102
|
-
export const
|
|
103
|
-
t.agentWithSkills("fix", t.buildFixPrompt(), {
|
|
84
|
+
export const fixCheck = (built?: t.BuildContext): Promise<void> =>
|
|
85
|
+
t.agentWithSkills("fix", t.buildFixPrompt(built), {
|
|
104
86
|
label: "Fixing the check",
|
|
105
87
|
file: FEEDBACK,
|
|
106
88
|
model: coder(),
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
agentWithSkills,
|
|
5
5
|
architectureAuthorPrompt,
|
|
6
6
|
architectureGateAnswerMessage,
|
|
7
|
+
buildFixPrompt,
|
|
7
8
|
buildFixQualityPrompt,
|
|
8
9
|
buildQualityReviewingPrompt,
|
|
9
10
|
buildReviewAwaitReviewMessage,
|
|
@@ -160,6 +161,65 @@ describe("architectureAuthorPrompt", () => {
|
|
|
160
161
|
const prompt = renderText(() => architectureAuthorPrompt())
|
|
161
162
|
expect(prompt).toContain("is an open question, not a")
|
|
162
163
|
})
|
|
164
|
+
it("names the four content sections in order and the entry forms", () => {
|
|
165
|
+
const prompt = renderText(() => architectureAuthorPrompt())
|
|
166
|
+
const at = ["## Interfaces", "## Call Stacks", "## E2E Scenarios", "## Unit Tests"].map((h) =>
|
|
167
|
+
prompt.indexOf(h),
|
|
168
|
+
)
|
|
169
|
+
expect(at.every((i) => i >= 0)).toBe(true)
|
|
170
|
+
expect([...at].sort((a, b) => a - b)).toEqual(at)
|
|
171
|
+
expect(prompt).toContain("- unit: <path>")
|
|
172
|
+
expect(prompt).toContain("- e2e: <path>")
|
|
173
|
+
expect(prompt).toContain("- chore: <path>")
|
|
174
|
+
expect(prompt).toContain("No e2e change.")
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
describe("buildFixPrompt with build context", () => {
|
|
179
|
+
const built = {
|
|
180
|
+
ranges: [
|
|
181
|
+
{ pkg: "00-scenarios", from: "aaa1111", to: "bbb2222" },
|
|
182
|
+
{ pkg: "01-engine", from: "bbb2222", to: "ccc3333" },
|
|
183
|
+
],
|
|
184
|
+
scenarios: { added: ["tests/new.feature"], changed: ["tests/old.feature"] },
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
it("points at the architecture document", () => {
|
|
188
|
+
expect(renderText(() => buildFixPrompt(built))).toContain(".gtd/ARCHITECTURE.md")
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it("lists new scenario paths as likely missing wiring", () => {
|
|
192
|
+
const prompt = renderText(() => buildFixPrompt(built))
|
|
193
|
+
expect(prompt).toContain("tests/new.feature")
|
|
194
|
+
expect(prompt).toContain("missing wiring")
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it("lists changed or existing scenarios as regressions", () => {
|
|
198
|
+
const prompt = renderText(() => buildFixPrompt(built))
|
|
199
|
+
expect(prompt).toContain("regression")
|
|
200
|
+
expect(prompt).toContain("tests/old.feature")
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it("lists one range line per package", () => {
|
|
204
|
+
const prompt = renderText(() => buildFixPrompt(built))
|
|
205
|
+
expect(prompt).toContain("git log --oneline aaa1111..bbb2222 # 00-scenarios")
|
|
206
|
+
expect(prompt).toContain("git log --oneline bbb2222..ccc3333 # 01-engine")
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it("is today's prompt without a build context", () => {
|
|
210
|
+
const prompt = renderText(() => buildFixPrompt())
|
|
211
|
+
expect(renderText(() => buildFixPrompt(undefined))).toBe(prompt)
|
|
212
|
+
expect(prompt).toMatch(/\n- Leave everything uncommitted — do not commit\n$/)
|
|
213
|
+
expect(prompt).toContain("writes its own\n- Leave")
|
|
214
|
+
expect(prompt).not.toContain("ARCHITECTURE")
|
|
215
|
+
expect(prompt).not.toContain("git log --oneline")
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it("starts each bullet on its own line", () => {
|
|
219
|
+
const prompt = renderText(() => buildFixPrompt(built))
|
|
220
|
+
expect(prompt).toMatch(/^- Read `\.gtd\/ARCHITECTURE\.md`/m)
|
|
221
|
+
expect(prompt).toMatch(/^- Leave everything uncommitted/m)
|
|
222
|
+
})
|
|
163
223
|
})
|
|
164
224
|
|
|
165
225
|
describe("buildFixQualityPrompt", () => {
|