@pmelab/gtd 28.2.0 → 29.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 +13 -4
- package/dist/chunks/{reserved-DeBS4Ymh.mjs → reserved-DoHUII2D.mjs} +206 -121
- package/dist/chunks/{tail-B2SER2Ih.mjs → tail-C6YBG5zh.mjs} +19 -12
- package/dist/testkit.mjs +2 -1
- package/dist/workflow-kit.mjs +51 -21
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +247 -96
- 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 +528 -262
- package/package.json +1 -1
- package/src/testkit/TestRepo.ts +3 -0
- package/src/testkit/index.ts +1 -1
- package/src/testkit/steps.ts +44 -0
- package/src/workflow-kit/review.test.ts +366 -8
- package/src/workflow-kit/review.ts +109 -26
- package/src/workflows/feature/feature.test.ts +313 -1
- package/src/workflows/feature/feature.ts +48 -43
- package/src/workflows/feature/one-step-land.feature +4 -0
- package/src/workflows/feature/refactor-first.feature +54 -24
- package/src/workflows/feature/refactor-review.feature +1706 -0
- package/src/workflows/feature/refactor.test.ts +592 -2
- package/src/workflows/feature/refactor.ts +160 -42
- package/src/workflows/feature/review.test.ts +322 -5
- package/src/workflows/feature/review.ts +77 -10
- package/src/workflows/feature/seam-rejection.feature +4 -0
- package/src/workflows/feature/seam.feature +3 -4
- package/src/workflows/feature/seam.test.ts +196 -1
- package/src/workflows/feature/seam.ts +23 -9
- package/src/workflows/feature/steps.test.ts +54 -2
- package/src/workflows/feature/steps.ts +45 -9
- package/src/workflows/feature/tier.test.ts +4 -0
- package/src/workflows/feature/tier.ts +2 -20
- package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +97 -0
- package/src/workflows/shared/access.test.ts +31 -1
- package/src/workflows/shared/access.ts +7 -1
- package/src/workflows/shared/fences.ts +19 -0
- package/src/workflows/shared/health.ts +32 -12
- package/src/workflows/shared/options.test.ts +18 -0
- package/src/workflows/shared/options.ts +1 -0
- package/src/workflows/shared/prose.ts +4 -2
- package/src/workflows/shared/skills.test.ts +20 -1
- package/src/workflows/shared/skills.ts +43 -32
- package/src/workflows/shared/steps.ts +33 -19
- package/src/workflows/shared/tail.test.ts +84 -1
- package/src/workflows/shared/tail.ts +30 -12
- package/src/workflows/shared/text.test.ts +266 -3
- package/src/workflows/shared/text.ts +183 -69
- package/src/workflows/shared/vars.ts +12 -2
|
@@ -9,7 +9,13 @@ import {
|
|
|
9
9
|
requireThreadsClosed,
|
|
10
10
|
type Change,
|
|
11
11
|
} from "../flows/index.js"
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
parseThreads,
|
|
14
|
+
reviewNotes,
|
|
15
|
+
reviewRisks,
|
|
16
|
+
stripCodeThreads,
|
|
17
|
+
type ReviewNote,
|
|
18
|
+
} from "../steering/index.js"
|
|
13
19
|
|
|
14
20
|
/** How a review round ended. `feedback` carries what the human edited, for the re-unwind to undo. */
|
|
15
21
|
export type ReviewOutcome =
|
|
@@ -24,6 +30,15 @@ export type ReviewOutcome =
|
|
|
24
30
|
readonly edited: readonly Change[]
|
|
25
31
|
}
|
|
26
32
|
|
|
33
|
+
export type CollectRound = "reply" | "edits" | "notes" | "triaged"
|
|
34
|
+
|
|
35
|
+
/** What a round asked to change: the notes judged `edit` and the code paths the human hand-edited. */
|
|
36
|
+
export interface Requested {
|
|
37
|
+
/** Empty on a hand-edit round: that path runs no triage. */
|
|
38
|
+
readonly notes: readonly ReviewNote[]
|
|
39
|
+
readonly paths: readonly string[]
|
|
40
|
+
}
|
|
41
|
+
|
|
27
42
|
export type Verdict = "edit" | "question" | "nit" | "praise" | "defer"
|
|
28
43
|
|
|
29
44
|
export type ReviewRisk = ReturnType<typeof reviewRisks>[number]
|
|
@@ -44,7 +59,7 @@ export interface ReviewPolicy {
|
|
|
44
59
|
/** The round at commit `round` left no `file`. */
|
|
45
60
|
readonly reviewMissing: (round: string) => Promise<void>
|
|
46
61
|
/** Turn the round `capture` describes into `feedback`, or change nothing. */
|
|
47
|
-
readonly collecting: (capture: string) => Promise<void>
|
|
62
|
+
readonly collecting: (capture: string, round: CollectRound) => Promise<void>
|
|
48
63
|
/** One verdict per note, keyed by note id; a note without one is an edit. */
|
|
49
64
|
readonly triage: (notes: readonly ReviewNote[]) => Promise<ReadonlyMap<string, Verdict>>
|
|
50
65
|
readonly answerQuestions: (notes: readonly ReviewNote[]) => Promise<void>
|
|
@@ -55,7 +70,8 @@ export interface ReviewPolicy {
|
|
|
55
70
|
readonly close: (round: string) => Promise<void>
|
|
56
71
|
/** What a collecting turn reads. */
|
|
57
72
|
readonly captures: {
|
|
58
|
-
|
|
73
|
+
/** `from` set: the hand-edits landed in an earlier commit than `round`, so name the range. */
|
|
74
|
+
readonly edits: (round: string, from?: string) => string
|
|
59
75
|
readonly notes: (round: string) => string
|
|
60
76
|
readonly editNotes: (
|
|
61
77
|
round: string,
|
|
@@ -68,24 +84,56 @@ export interface ReviewPolicy {
|
|
|
68
84
|
readonly deleted: string
|
|
69
85
|
/** The collecting turn changed something without writing `feedback`. */
|
|
70
86
|
readonly unmatched: string
|
|
87
|
+
/**
|
|
88
|
+
* The collecting turn changed nothing though the round carried an `edit`
|
|
89
|
+
* note or a code hand-edit. Absent: that round signs off.
|
|
90
|
+
*/
|
|
91
|
+
readonly dropped?: (requested: Requested) => string
|
|
71
92
|
}
|
|
72
93
|
}
|
|
73
94
|
|
|
74
|
-
const collect = async (
|
|
75
|
-
|
|
95
|
+
const collect = async (
|
|
96
|
+
p: ReviewPolicy,
|
|
97
|
+
capture: string,
|
|
98
|
+
round: CollectRound,
|
|
99
|
+
requested?: Requested,
|
|
100
|
+
): Promise<"signoff" | "feedback"> => {
|
|
101
|
+
await p.collecting(capture, round)
|
|
76
102
|
if (read(p.feedback) !== undefined) return "feedback"
|
|
77
|
-
if (changes().length === 0)
|
|
103
|
+
if (changes().length === 0) {
|
|
104
|
+
if (
|
|
105
|
+
p.refusals.dropped !== undefined &&
|
|
106
|
+
requested !== undefined &&
|
|
107
|
+
requested.notes.length + requested.paths.length > 0
|
|
108
|
+
)
|
|
109
|
+
return refuse(p.refusals.dropped(requested))
|
|
110
|
+
return "signoff"
|
|
111
|
+
}
|
|
78
112
|
return refuse(p.refusals.unmatched)
|
|
79
113
|
}
|
|
80
114
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
115
|
+
const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A review with ticks and THREAD footnotes (definition plus markers) removed:
|
|
119
|
+
* what is left is line notes and one-shot footnotes, which the triage judges.
|
|
120
|
+
*/
|
|
121
|
+
const notesOf = (text: string): string => {
|
|
122
|
+
let out = text
|
|
123
|
+
for (const { name } of parseThreads(text)) {
|
|
124
|
+
const n = escapeRe(name)
|
|
125
|
+
out = out
|
|
126
|
+
.replace(
|
|
127
|
+
new RegExp(
|
|
128
|
+
`^\\[\\^${n}\\]:[^\\n]*(\\n(?: {2,}|\\t)[^\\n]*|\\n(?=\\s*\\n(?: {2,}|\\t)))*`,
|
|
129
|
+
"gim",
|
|
130
|
+
),
|
|
131
|
+
"",
|
|
132
|
+
)
|
|
133
|
+
.replace(new RegExp(`\\[\\^${n}\\]`, "gi"), "")
|
|
134
|
+
}
|
|
135
|
+
return out.replace(/\[x\]/gi, "[ ]").replace(/\s+/g, " ").trim()
|
|
136
|
+
}
|
|
89
137
|
|
|
90
138
|
/**
|
|
91
139
|
* The human gate and its reply rounds: a question gets an answer inside
|
|
@@ -108,7 +156,7 @@ const converse = async (
|
|
|
108
156
|
}
|
|
109
157
|
requireThreadsClosed(p.file)
|
|
110
158
|
if (!hasThreadFor("agent", p.file)) return collectedAt
|
|
111
|
-
await p.collecting(p.captures.edits(head()))
|
|
159
|
+
await p.collecting(p.captures.edits(head()), "reply")
|
|
112
160
|
collectedAt = head()
|
|
113
161
|
requireReplies(p.file)
|
|
114
162
|
if (!hasThreadFor("human", p.file)) return collectedAt
|
|
@@ -152,7 +200,12 @@ export const routeNotes = async (
|
|
|
152
200
|
if (deferred.length > 0) await p.defer(deferred)
|
|
153
201
|
if (edits.length > 0) {
|
|
154
202
|
await r.close()
|
|
155
|
-
return r.outcome(
|
|
203
|
+
return r.outcome(
|
|
204
|
+
await collect(p, p.captures.editNotes(r.round, edits, answeredAt), "triaged", {
|
|
205
|
+
notes: edits,
|
|
206
|
+
paths: [],
|
|
207
|
+
}),
|
|
208
|
+
)
|
|
156
209
|
}
|
|
157
210
|
if (nits.length > 0) {
|
|
158
211
|
await r.close()
|
|
@@ -196,6 +249,28 @@ const settledAfter = (
|
|
|
196
249
|
!notedSince(p, collectedAt),
|
|
197
250
|
})
|
|
198
251
|
|
|
252
|
+
const finishNotes = async (
|
|
253
|
+
p: ReviewPolicy,
|
|
254
|
+
before: string,
|
|
255
|
+
reopened: boolean,
|
|
256
|
+
ctx: Parameters<typeof routeNotes>[2],
|
|
257
|
+
): Promise<Finish> => {
|
|
258
|
+
// An unchanged review file has no baseline entry, which would read as all-new.
|
|
259
|
+
const notes = reviewNotes(before, read(p.file) ?? "")
|
|
260
|
+
if (notes.length > 0) return routeNotes(p, notes, ctx)
|
|
261
|
+
if (reopened) return ctx.unfolded()
|
|
262
|
+
await ctx.close()
|
|
263
|
+
return ctx.outcome(await collect(p, p.captures.notes(ctx.round), "notes"))
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const toOutcome = (
|
|
267
|
+
verdict: "signoff" | "feedback",
|
|
268
|
+
rest: { base: string; restoreFrom: string; edited: ReadonlyArray<Change> },
|
|
269
|
+
): ReviewOutcome => (verdict === "signoff" ? { verdict } : { verdict, ...rest })
|
|
270
|
+
|
|
271
|
+
const isReopened = (p: ReviewPolicy, r: { settled: boolean; folded: boolean }): boolean =>
|
|
272
|
+
r.settled && !r.folded && p.refusals.dropped !== undefined
|
|
273
|
+
|
|
199
274
|
const finish = async (
|
|
200
275
|
p: ReviewPolicy,
|
|
201
276
|
reviewed: string,
|
|
@@ -206,29 +281,37 @@ const finish = async (
|
|
|
206
281
|
const round = head()
|
|
207
282
|
const since = changesSince(reviewed)
|
|
208
283
|
const edited = since.filter((c) => isCodeEdit(p, c))
|
|
209
|
-
const baselineText = since.get(p.file)?.before ?? ""
|
|
210
|
-
const record = read(p.file) ?? ""
|
|
211
284
|
const { folded, settled } = afterCollect(p, collectedAt)
|
|
212
285
|
// The review gate clears every tick before its commit, so a changed
|
|
213
286
|
// review file is a note, never a tick.
|
|
214
287
|
const noted = since.some((c) => c.path === p.file)
|
|
215
288
|
const close = (): Promise<void> => p.close(round)
|
|
216
289
|
const outcome = (verdict: "signoff" | "feedback"): ReviewOutcome =>
|
|
217
|
-
verdict
|
|
290
|
+
toOutcome(verdict, { base: round, restoreFrom: reviewed, edited })
|
|
218
291
|
const unfolded = async (): Promise<Finish> => {
|
|
219
292
|
await close()
|
|
220
293
|
return outcome(folded ? "feedback" : "signoff")
|
|
221
294
|
}
|
|
222
295
|
|
|
223
|
-
|
|
296
|
+
// Thread-only edits settled the round, but a change note or hand-edit since
|
|
297
|
+
// `reviewed` must still reach a collecting turn when a dropped edit is refused.
|
|
298
|
+
const reopened = isReopened(p, { settled, folded })
|
|
299
|
+
if ((settled && !reopened) || (edited.length === 0 && !noted)) return unfolded()
|
|
300
|
+
if (edited.length > 0) {
|
|
224
301
|
await close()
|
|
225
|
-
return outcome(
|
|
302
|
+
return outcome(
|
|
303
|
+
await collect(p, p.captures.edits(round, reopened ? reviewed : undefined), "edits", {
|
|
304
|
+
notes: [],
|
|
305
|
+
paths: edited.map((c) => c.path),
|
|
306
|
+
}),
|
|
307
|
+
)
|
|
226
308
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
309
|
+
return finishNotes(p, since.get(p.file)?.before ?? "", reopened, {
|
|
310
|
+
round,
|
|
311
|
+
close,
|
|
312
|
+
outcome,
|
|
313
|
+
unfolded,
|
|
314
|
+
})
|
|
232
315
|
}
|
|
233
316
|
|
|
234
317
|
/** 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. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
-
import { testRepo } from "../../testkit/index.js"
|
|
2
|
+
import { runsItsScript, testRepo, type Actor } from "../../testkit/index.js"
|
|
3
3
|
import { CONFIG_SCAFFOLD } from "../../install/index.js"
|
|
4
4
|
import { reviewDoc } from "../shared/config.fixture.js"
|
|
5
5
|
|
|
@@ -30,3 +30,315 @@ describe("an ordinary feature start", () => {
|
|
|
30
30
|
expect(repo.subjects()).toEqual(["chore: initial commit"])
|
|
31
31
|
})
|
|
32
32
|
})
|
|
33
|
+
|
|
34
|
+
const review = (text?: string): string =>
|
|
35
|
+
[
|
|
36
|
+
"# Review: abc1234",
|
|
37
|
+
"",
|
|
38
|
+
"<!-- base: 0000000 -->",
|
|
39
|
+
"",
|
|
40
|
+
"## calc",
|
|
41
|
+
"",
|
|
42
|
+
"- [ ] ./src/calc.ts#1-1",
|
|
43
|
+
text === undefined ? " add" : " add[^why]",
|
|
44
|
+
...(text === undefined ? [] : ["", "[^why]:", ` - H: ${text}`]),
|
|
45
|
+
"",
|
|
46
|
+
].join("\n")
|
|
47
|
+
|
|
48
|
+
const CALC = "export const add = (a: number, b: number) => a + b\n"
|
|
49
|
+
|
|
50
|
+
const naivePlanned = (flag: "0" | "1") => {
|
|
51
|
+
const repo = testRepo({
|
|
52
|
+
workflow: CONFIG_SCAFFOLD,
|
|
53
|
+
env: { GTD_QUALITYREVIEWS: "", GTD_FLAG_NAIVE_PASS: flag, GTD_FLAG_RISK_TIER: "0" },
|
|
54
|
+
})
|
|
55
|
+
repo.write("src/calc.ts", CALC)
|
|
56
|
+
repo.commit("feat: add calculator")
|
|
57
|
+
repo.write("TODO.md", "# Add subtract\n")
|
|
58
|
+
return repo
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("planAndBuild on the naive pass", () => {
|
|
62
|
+
it("after land, feedback rests next at design.triage, then implement.building, never seam.naive", async () => {
|
|
63
|
+
const repo = naivePlanned("1")
|
|
64
|
+
const requirements = (_: unknown, r: typeof repo) =>
|
|
65
|
+
r.write(
|
|
66
|
+
".gtd/REQUIREMENTS.md",
|
|
67
|
+
"# Requirements\n\n## Concerns\n\n### Subtract\n\n- `subtract`\n",
|
|
68
|
+
)
|
|
69
|
+
const lap = {
|
|
70
|
+
unwind: () => undefined,
|
|
71
|
+
"start-gate.check": () => undefined,
|
|
72
|
+
"design.triage": requirements,
|
|
73
|
+
"design.gate.answer": () => undefined,
|
|
74
|
+
"seam.naive": (_: unknown, r: typeof repo) => r.write("src/calc.ts", `${CALC}// naive\n`),
|
|
75
|
+
"seam.judge": () => '[{"id":"shape","answer":"land","p":0.9}]',
|
|
76
|
+
"implement.building": (_: unknown, r: typeof repo) =>
|
|
77
|
+
r.write("src/calc.ts", `${CALC}// built\n`),
|
|
78
|
+
"implement.health.check": () => undefined,
|
|
79
|
+
"implement.closing": runsItsScript,
|
|
80
|
+
"build.review.reviewing": (_: unknown, r: typeof repo) => r.write(".gtd/REVIEW.md", review()),
|
|
81
|
+
}
|
|
82
|
+
expect((await repo.drive(lap)).state).toBe("build.review.await-review")
|
|
83
|
+
|
|
84
|
+
repo.write(".gtd/REVIEW.md", review("also export multiply"))
|
|
85
|
+
await repo.land()
|
|
86
|
+
const collected = await repo.drive({
|
|
87
|
+
"build.review.collecting": (_, r) => {
|
|
88
|
+
requirements(_, r)
|
|
89
|
+
r.write(".gtd/REVIEW.md", `${review("also export multiply")} - A: noted.\n`)
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
expect(collected.state).toBe("build.review.await-review")
|
|
93
|
+
|
|
94
|
+
repo.write(".gtd/REVIEW.md", review())
|
|
95
|
+
await repo.land()
|
|
96
|
+
const rest = await repo.drive({
|
|
97
|
+
"build.review.closing": runsItsScript,
|
|
98
|
+
"re-unwind": runsItsScript,
|
|
99
|
+
"design.triage": (_, r) => r.write(".gtd/REQUIREMENTS.md", "# Requirements\n\n- multiply\n"),
|
|
100
|
+
"design.gate.answer": () => undefined,
|
|
101
|
+
})
|
|
102
|
+
expect(rest.state).toBe("implement.building")
|
|
103
|
+
expect(rest.content).toContain("already exists")
|
|
104
|
+
expect(rest.content).not.toContain("write the change fresh")
|
|
105
|
+
const subjects = repo.subjects()
|
|
106
|
+
const reUnwind = subjects.findIndex((subject) => subject.endsWith("→ re-unwind"))
|
|
107
|
+
expect(reUnwind).toBeGreaterThan(-1)
|
|
108
|
+
expect(subjects.slice(reUnwind + 1).some((subject) => subject.includes("seam."))).toBe(false)
|
|
109
|
+
expect(subjects.filter((subject) => subject.includes("→ seam.naive"))).toHaveLength(1)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe("planAndBuild without the naive pass", () => {
|
|
114
|
+
it("runs the design → architecture path: no seam, no implement", async () => {
|
|
115
|
+
const repo = naivePlanned("0")
|
|
116
|
+
const rest = await repo.drive({
|
|
117
|
+
unwind: () => undefined,
|
|
118
|
+
"start-gate.check": () => undefined,
|
|
119
|
+
"design.triage": (_, r) => r.write(".gtd/REQUIREMENTS.md", "# Requirements\n"),
|
|
120
|
+
"design.gate.answer": () => undefined,
|
|
121
|
+
})
|
|
122
|
+
expect(rest.state).toBe("architecture.author")
|
|
123
|
+
expect(repo.subjects().some((subject) => /seam|implement/.test(subject))).toBe(false)
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe("afterTail feedback lap", () => {
|
|
128
|
+
const TAIL = [
|
|
129
|
+
`import { start } from "@pmelab/gtd/flows"`,
|
|
130
|
+
`import { afterTail, buildTail, gate } from "@pmelab/gtd/workflows"`,
|
|
131
|
+
`export { feature as default, defaults, envDefaults, summary, base, steering, skills, access } from "@pmelab/gtd/workflows"`,
|
|
132
|
+
`export async function tail() {`,
|
|
133
|
+
` await gate("review-gate", "The baseline is red.")`,
|
|
134
|
+
` return afterTail(await buildTail(false, start()))`,
|
|
135
|
+
`}`,
|
|
136
|
+
].join("\n")
|
|
137
|
+
|
|
138
|
+
const prompts: string[] = []
|
|
139
|
+
|
|
140
|
+
const feedbackLap = async (flag: "0" | "1") => {
|
|
141
|
+
prompts.length = 0
|
|
142
|
+
const repo = testRepo({
|
|
143
|
+
workflow: TAIL,
|
|
144
|
+
env: { GTD_QUALITYREVIEWS: "", GTD_FLAG_NAIVE_PASS: flag, GTD_FLAG_RISK_TIER: "0" },
|
|
145
|
+
})
|
|
146
|
+
repo.write("src/calc.ts", CALC)
|
|
147
|
+
repo.commit("feat: add calculator")
|
|
148
|
+
await repo.start("tail", "--var", `reviewBase=${repo.hashes()[0]}`)
|
|
149
|
+
await repo.drive({
|
|
150
|
+
"review-gate.check": () => undefined,
|
|
151
|
+
"build.health.check": () => undefined,
|
|
152
|
+
"build.review.reviewing": (beat, r) => {
|
|
153
|
+
prompts.push(beat.content)
|
|
154
|
+
r.write(".gtd/REVIEW.md", review())
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
repo.write(".gtd/REVIEW.md", review("also export multiply"))
|
|
158
|
+
await repo.land()
|
|
159
|
+
await repo.drive({
|
|
160
|
+
"build.review.collecting": (_, r) => {
|
|
161
|
+
r.write(".gtd/REQUIREMENTS.md", "# Requirements\n\n- multiply\n")
|
|
162
|
+
r.write(".gtd/REVIEW.md", `${review("also export multiply")} - A: noted.\n`)
|
|
163
|
+
},
|
|
164
|
+
})
|
|
165
|
+
repo.write(".gtd/REVIEW.md", review())
|
|
166
|
+
await repo.land()
|
|
167
|
+
return repo
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const afterFeedback = {
|
|
171
|
+
"build.review.closing": runsItsScript,
|
|
172
|
+
"re-unwind": runsItsScript,
|
|
173
|
+
"design.triage": (_: unknown, r: ReturnType<typeof testRepo>) =>
|
|
174
|
+
r.write(".gtd/REQUIREMENTS.md", "# Requirements\n\n- multiply, designed\n"),
|
|
175
|
+
"design.gate.answer": () => undefined,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
it("flag_naive_pass=1 rests at design.triage, then implement.building, reaching no seam step", async () => {
|
|
179
|
+
const repo = await feedbackLap("1")
|
|
180
|
+
expect(
|
|
181
|
+
(await repo.drive({ "build.review.closing": runsItsScript, "re-unwind": runsItsScript }))
|
|
182
|
+
.state,
|
|
183
|
+
).toBe("design.triage")
|
|
184
|
+
const rest = await repo.drive(afterFeedback)
|
|
185
|
+
expect(rest.state).toBe("implement.building")
|
|
186
|
+
expect(rest.content).toContain("already exists")
|
|
187
|
+
expect(rest.content).not.toContain("write the change fresh")
|
|
188
|
+
expect(repo.subjects().some((subject) => subject.includes("seam."))).toBe(false)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it("flag_naive_pass=0 re-plans through architecture and packages", async () => {
|
|
192
|
+
const repo = await feedbackLap("0")
|
|
193
|
+
const rest = await repo.drive(afterFeedback)
|
|
194
|
+
expect(rest.state).toBe("architecture.author")
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it.each(["0", "1"] as const)(
|
|
198
|
+
"flag_naive_pass=%s: the first review says everything since base, the one after feedback only what is new",
|
|
199
|
+
async (flag) => {
|
|
200
|
+
const repo = await feedbackLap(flag)
|
|
201
|
+
expect(prompts[0]).toContain("it covers everything since the base")
|
|
202
|
+
expect(prompts[0]).not.toContain("only what is new since the previous review")
|
|
203
|
+
const writes: Record<string, () => [string, string]> = {
|
|
204
|
+
"design.triage": () => [".gtd/REQUIREMENTS.md", "# Requirements\n\n- multiply, designed\n"],
|
|
205
|
+
"implement.building": () => ["src/calc.ts", `${CALC}// again\n`],
|
|
206
|
+
"packages.item.building": () => ["src/calc.ts", `${CALC}// package\n`],
|
|
207
|
+
"architecture.author": () => [".gtd/ARCHITECTURE.md", "# Architecture\n\nmultiply\n"],
|
|
208
|
+
"architecture.decompose.decomposing": () => [
|
|
209
|
+
".gtd/packages/01-multiply.md",
|
|
210
|
+
"# 01 multiply\n",
|
|
211
|
+
],
|
|
212
|
+
}
|
|
213
|
+
const actors = new Proxy({} as Record<string, Actor>, {
|
|
214
|
+
get: (_, state: string): Actor | undefined =>
|
|
215
|
+
state === "build.review.await-review" || state === "idle"
|
|
216
|
+
? undefined
|
|
217
|
+
: (beat, r) => {
|
|
218
|
+
if (state === "build.review.reviewing") {
|
|
219
|
+
prompts.push(beat.content)
|
|
220
|
+
r.write(".gtd/REVIEW.md", review())
|
|
221
|
+
} else if (writes[state]) r.write(...writes[state]())
|
|
222
|
+
else if (state.endsWith("closing") || state === "re-unwind")
|
|
223
|
+
r.runScript(beat.content)
|
|
224
|
+
},
|
|
225
|
+
})
|
|
226
|
+
await repo.drive(actors, { maxBeats: 80 })
|
|
227
|
+
expect(prompts[1]).toContain("it covers only what is new since the previous review")
|
|
228
|
+
expect(prompts[1]).not.toContain("everything since the base")
|
|
229
|
+
},
|
|
230
|
+
)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
describe("an in-flight 28.0.0 naive-pass process", () => {
|
|
234
|
+
type Edge = readonly [actor: string, from: string, to: string, trailer?: string]
|
|
235
|
+
const verdict = (answer: string) => `Gtd-Judge: {"id":"shape","answer":"${answer}","p":0.9}`
|
|
236
|
+
const approved = 'Gtd-Approved: {"step":"seam.approve#1","name":"Test","email":"test@test.com"}'
|
|
237
|
+
const building: readonly Edge[] = [
|
|
238
|
+
["agent", "implement.building", "implement.health.check"],
|
|
239
|
+
["check", "implement.health.check", "implement.closing"],
|
|
240
|
+
["check", "implement.closing", "build.review.reviewing"],
|
|
241
|
+
["agent", "build.review.reviewing", "build.review.await-review"],
|
|
242
|
+
]
|
|
243
|
+
const feedback: readonly Edge[] = [
|
|
244
|
+
["human", "build.review.await-review", "build.review.collecting"],
|
|
245
|
+
["agent", "build.review.collecting", "build.review.await-review"],
|
|
246
|
+
["human", "build.review.await-review", "build.review.closing"],
|
|
247
|
+
["check", "build.review.closing", "re-unwind"],
|
|
248
|
+
["check", "re-unwind", "design.triage"],
|
|
249
|
+
["agent", "design.triage", "design.gate.answer"],
|
|
250
|
+
["human", "design.gate.answer", "implement.building"],
|
|
251
|
+
]
|
|
252
|
+
const trails: Record<string, readonly Edge[]> = {
|
|
253
|
+
land: [["judge", "seam.judge", "implement.building", verdict("land")], ...building],
|
|
254
|
+
"land, then a feature feedback lap": [
|
|
255
|
+
["judge", "seam.judge", "implement.building", verdict("land")],
|
|
256
|
+
...building,
|
|
257
|
+
...feedback,
|
|
258
|
+
],
|
|
259
|
+
"rejected twice, steered, then landed": [
|
|
260
|
+
["judge", "seam.judge", "seam.naive", verdict("reject")],
|
|
261
|
+
["agent", "seam.naive", "seam.judge"],
|
|
262
|
+
["judge", "seam.judge", "seam.naive", verdict("reject")],
|
|
263
|
+
["agent", "seam.naive", "seam.judge"],
|
|
264
|
+
["judge", "seam.judge", "seam.steer", verdict("reject")],
|
|
265
|
+
["human", "seam.steer", "seam.naive"],
|
|
266
|
+
["agent", "seam.naive", "seam.judge"],
|
|
267
|
+
["judge", "seam.judge", "implement.building", verdict("land")],
|
|
268
|
+
...building,
|
|
269
|
+
],
|
|
270
|
+
"refactor-first": [
|
|
271
|
+
["judge", "seam.judge", "seam.approve", verdict("refactor-first")],
|
|
272
|
+
["human", "seam.approve", "refactor.deriving", approved],
|
|
273
|
+
["check", "refactor.deriving", "refactor.building"],
|
|
274
|
+
["agent", "refactor.building", "refactor.health.check"],
|
|
275
|
+
["check", "refactor.health.check", "refactor.closing"],
|
|
276
|
+
["check", "refactor.closing", "implement.building"],
|
|
277
|
+
...building,
|
|
278
|
+
],
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const writeEdgeFiles = (
|
|
282
|
+
repo: { write: (path: string, content: string) => void },
|
|
283
|
+
from: string,
|
|
284
|
+
to: string,
|
|
285
|
+
) => {
|
|
286
|
+
if (from === "seam.steer") repo.write(".gtd/REQUIREMENTS.md", "# Requirements\n\n- steered\n")
|
|
287
|
+
if (from === "build.review.reviewing") repo.write(".gtd/REVIEW.md", review())
|
|
288
|
+
if (to === "build.review.collecting")
|
|
289
|
+
repo.write(".gtd/REVIEW.md", review("also export multiply"))
|
|
290
|
+
if (from === "build.review.collecting") {
|
|
291
|
+
repo.write(".gtd/REVIEW.md", `${review("also export multiply")} - A: noted.\n`)
|
|
292
|
+
repo.write(".gtd/REQUIREMENTS.md", "# Requirements\n\n- multiply\n")
|
|
293
|
+
}
|
|
294
|
+
if (to === "build.review.closing") repo.write(".gtd/REVIEW.md", review())
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The repo at the judge's rest, then `trail` forged edge by edge as `Gtd-Step:` commits. */
|
|
298
|
+
const forged = async (trail: readonly Edge[], upTo: number) => {
|
|
299
|
+
const repo = naivePlanned("1")
|
|
300
|
+
await repo.drive({
|
|
301
|
+
unwind: () => undefined,
|
|
302
|
+
"start-gate.check": () => undefined,
|
|
303
|
+
"design.triage": (_, r) =>
|
|
304
|
+
r.write(".gtd/REQUIREMENTS.md", "# Requirements\n\n## Concerns\n\n### S\n\n- `s`\n"),
|
|
305
|
+
"design.gate.answer": () => undefined,
|
|
306
|
+
"seam.naive": (_, r) => r.write("src/calc.ts", `${CALC}// naive\n`),
|
|
307
|
+
})
|
|
308
|
+
const kept = /Gtd-Kept: (\w+)/.exec(repo.messages().at(-1)!)![1]!
|
|
309
|
+
const seen: Record<string, number> = {
|
|
310
|
+
"seam.naive": 1,
|
|
311
|
+
"design.triage": 1,
|
|
312
|
+
"design.gate.answer": 1,
|
|
313
|
+
}
|
|
314
|
+
for (const [actor, from, to, trailer] of trail.slice(0, upTo)) {
|
|
315
|
+
seen[from] = (seen[from] ?? 0) + 1
|
|
316
|
+
writeEdgeFiles(repo, from, to)
|
|
317
|
+
repo.commit(
|
|
318
|
+
[
|
|
319
|
+
`gtd(${actor}): ${from} → ${to}`,
|
|
320
|
+
"",
|
|
321
|
+
`Gtd-Step: ${from}#${seen[from]}`,
|
|
322
|
+
...(trailer === undefined ? [] : [trailer]),
|
|
323
|
+
...(actor === "agent" && from === "seam.naive" ? [`Gtd-Kept: ${kept}`] : []),
|
|
324
|
+
"Gtd-Format: 1",
|
|
325
|
+
].join("\n"),
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
return repo
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
for (const [name, trail] of Object.entries(trails)) {
|
|
332
|
+
const replays = name !== "refactor-first"
|
|
333
|
+
it(`${name}: ${replays ? "every rest replays to itself" : "every rest from the verdict on fails replay"}`, async () => {
|
|
334
|
+
for (let upTo = 1; upTo <= trail.length; upTo++) {
|
|
335
|
+
const rest = trail[upTo - 1]![2]
|
|
336
|
+
const repo = await forged(trail, upTo)
|
|
337
|
+
if (replays) expect((await repo.next()).state, rest).toBe(rest)
|
|
338
|
+
else {
|
|
339
|
+
await expect(repo.next(), rest).rejects.toThrow("the workflow changed under this process")
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
})
|
|
@@ -1,23 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
changes,
|
|
3
|
-
head,
|
|
4
|
-
human,
|
|
5
|
-
requireRevert,
|
|
6
|
-
restoreScript,
|
|
7
|
-
revertScript,
|
|
8
|
-
run,
|
|
9
|
-
start,
|
|
10
|
-
vars,
|
|
11
|
-
} from "../../flows/index.js"
|
|
1
|
+
import { changes, head, human, revertScript, run, slice, start, vars } from "../../flows/index.js"
|
|
12
2
|
import { gate } from "../shared/health.js"
|
|
13
3
|
import { shipped } from "../shared/options.js"
|
|
14
|
-
import { FEEDBACK
|
|
4
|
+
import { FEEDBACK } from "../shared/steps.js"
|
|
15
5
|
import { refusePendingHandOff } from "../shared/tail.js"
|
|
16
6
|
import * as t from "../shared/text.js"
|
|
17
7
|
import { packages } from "./packages.js"
|
|
18
8
|
import { architecture, design } from "./planning.js"
|
|
19
|
-
import { buildTail, type ReviewOutcome } from "./review.js"
|
|
20
|
-
import { seamBuild } from "./seam.js"
|
|
9
|
+
import { buildTail, reUnwind, type ReviewOutcome } from "./review.js"
|
|
10
|
+
import { implement, seamBuild } from "./seam.js"
|
|
21
11
|
import { recordRisk, riskTierOn } from "./tier.js"
|
|
22
12
|
|
|
23
13
|
// The ordinary start. Any change to the tree starts a process: `idle` →
|
|
@@ -34,49 +24,64 @@ export const unwind = (): Promise<void> => {
|
|
|
34
24
|
})
|
|
35
25
|
}
|
|
36
26
|
|
|
37
|
-
|
|
38
|
-
* Undo the human's review-round code edits. A path changed since the human
|
|
39
|
-
* left it is not overwritten; requireRevert names it instead.
|
|
40
|
-
*/
|
|
41
|
-
export const reUnwind = async (
|
|
42
|
-
feedback: Extract<ReviewOutcome, { verdict: "feedback" }>,
|
|
43
|
-
): Promise<void> => {
|
|
44
|
-
const { base, restoreFrom, edited } = feedback
|
|
45
|
-
const restore = edited.filter((c) => c.status !== "added").map((c) => c.path)
|
|
46
|
-
const remove = edited.filter((c) => c.status === "added").map((c) => c.path)
|
|
47
|
-
await run("re-unwind", restoreScript(base, { restore, remove }, restoreFrom), {
|
|
48
|
-
label: "Re-unwinding your review edit",
|
|
49
|
-
file: REVIEW,
|
|
50
|
-
base,
|
|
51
|
-
})
|
|
52
|
-
requireRevert(edited, base)
|
|
53
|
-
}
|
|
27
|
+
export { reUnwind }
|
|
54
28
|
|
|
55
29
|
/** Plan, build and review until a review round signs off; feedback re-plans from scratch. */
|
|
56
|
-
export const planAndBuild = async (
|
|
30
|
+
export const planAndBuild = async (
|
|
31
|
+
firstBase: string,
|
|
32
|
+
firstLap: t.ReviewRange = "first",
|
|
33
|
+
): Promise<void> => {
|
|
34
|
+
// GTD_FLAG_NAIVE_PASS: the record is pinned at the process start, so a mid-process flip never moves a process.
|
|
35
|
+
if (vars.flag_naive_pass === "1") return naivePlanAndBuild(firstBase)
|
|
57
36
|
let base = firstBase
|
|
37
|
+
let lap = firstLap
|
|
58
38
|
for (;;) {
|
|
59
39
|
await design(base)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
await seamBuild(tier)
|
|
64
|
-
} else {
|
|
65
|
-
await architecture()
|
|
66
|
-
await packages()
|
|
67
|
-
}
|
|
68
|
-
const outcome = await buildTail(false, base, true)
|
|
40
|
+
await architecture()
|
|
41
|
+
await packages()
|
|
42
|
+
const outcome = await buildTail(false, base, true, lap === "first" ? {} : { lap })
|
|
69
43
|
if (outcome.verdict === "signoff") return
|
|
70
44
|
await reUnwind(outcome)
|
|
71
45
|
base = outcome.base
|
|
46
|
+
lap = "feedback"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// seam.naive / seam.judge run once: a feedback lap is design → implement on every verdict, so a
|
|
51
|
+
// seam a re-plan turns up is built unjudged and unrefactored.
|
|
52
|
+
const naivePlanAndBuild = async (firstBase: string): Promise<void> => {
|
|
53
|
+
await design(firstBase)
|
|
54
|
+
const tier = riskTierOn() ? recordRisk() : undefined
|
|
55
|
+
const seam = await seamBuild(firstBase)
|
|
56
|
+
await slice("feature", async () => {
|
|
57
|
+
await implement(seam.reference, tier)
|
|
58
|
+
await featureFeedback(
|
|
59
|
+
await buildTail(false, seam.base, true, { lensBase: seam.base }),
|
|
60
|
+
seam.base,
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const featureFeedback = async (first: ReviewOutcome, lensBase?: string): Promise<void> => {
|
|
66
|
+
let outcome = first
|
|
67
|
+
while (outcome.verdict === "feedback") {
|
|
68
|
+
await reUnwind(outcome)
|
|
69
|
+
await design(outcome.base)
|
|
70
|
+
const tier = riskTierOn() ? recordRisk() : undefined
|
|
71
|
+
await implement(undefined, tier, true)
|
|
72
|
+
outcome = await buildTail(false, outcome.base, true, {
|
|
73
|
+
lap: "feedback",
|
|
74
|
+
...(lensBase === undefined ? {} : { lensBase }),
|
|
75
|
+
})
|
|
72
76
|
}
|
|
73
77
|
}
|
|
74
78
|
|
|
75
|
-
/** After a tail that skipped planning: stop on sign-off, re-plan on feedback. */
|
|
79
|
+
/** After a tail that skipped planning: stop on sign-off, re-plan on feedback. Opens no slice: slices cannot nest. */
|
|
76
80
|
export const afterTail = async (outcome: ReviewOutcome): Promise<void> => {
|
|
77
81
|
if (outcome.verdict === "signoff") return
|
|
82
|
+
if (vars.flag_naive_pass === "1") return featureFeedback(outcome)
|
|
78
83
|
await reUnwind(outcome)
|
|
79
|
-
await planAndBuild(outcome.base)
|
|
84
|
+
await planAndBuild(outcome.base, "feedback")
|
|
80
85
|
}
|
|
81
86
|
|
|
82
87
|
/** An ordinary start: wait at `idle`, unwind the sketch, check the baseline, then plan and build. */
|
|
@@ -105,4 +105,8 @@ Feature: a naive pass the judge rules land goes straight to implement with no se
|
|
|
105
105
|
And the commit "gtd(agent): seam.naive → seam.judge" has a body containing "Gtd-Kept:"
|
|
106
106
|
And the commit "gtd(judge): seam.judge → implement.building" has a body containing "\"answer\":\"land\""
|
|
107
107
|
And no commit body contains "Gtd-Approved:"
|
|
108
|
+
And the output lines of gtd "slices" start with:
|
|
109
|
+
"""
|
|
110
|
+
feature
|
|
111
|
+
"""
|
|
108
112
|
And no commit body contains "Gtd-Workflow:"
|