@pmelab/gtd 15.6.0 → 15.7.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/README.md +37 -8
- package/dist/gtd.bundle.mjs +849 -212
- package/package.json +1 -1
- package/src/flows/helpers.ts +46 -0
- package/src/flows/runtime.ts +30 -0
- package/src/flows/scripts.test.ts +16 -0
- package/src/flows/scripts.ts +2 -1
- package/src/workflows/planning.ts +17 -11
- package/src/workflows/prose.ts +33 -7
- package/src/workflows/review.ts +103 -28
- package/src/workflows/text.fixture.ts +6 -1
- package/src/workflows/text.test.ts +68 -0
- package/src/workflows/text.ts +84 -38
- package/src/workflows/threads.test.ts +129 -0
- package/src/workflows/unified.ts +3 -2
package/package.json
CHANGED
package/src/flows/helpers.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
changes,
|
|
3
|
+
codeThreads,
|
|
3
4
|
head,
|
|
4
5
|
openQuestions,
|
|
5
6
|
read,
|
|
6
7
|
refuse,
|
|
7
8
|
run,
|
|
9
|
+
threads,
|
|
8
10
|
type Change,
|
|
9
11
|
type JudgeAnswer,
|
|
10
12
|
} from "./runtime.js"
|
|
@@ -82,6 +84,50 @@ export const requireAnswers = (file: string): void => {
|
|
|
82
84
|
)
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
/** Footnote threads in `file` waiting on `waitingOn`, plus any with a syntax fault — a fault refuses both ways. */
|
|
88
|
+
const threadLines = (file: string, waitingOn: "human" | "agent"): string[] =>
|
|
89
|
+
threads(read(file) ?? "")
|
|
90
|
+
.filter((t) => t.waitingOn === waitingOn || t.faults.length > 0)
|
|
91
|
+
.flatMap((t) => [
|
|
92
|
+
` - ${file}:${t.line}: [^${t.name}]`,
|
|
93
|
+
...t.faults.map((fault) => ` ${fault}`),
|
|
94
|
+
])
|
|
95
|
+
|
|
96
|
+
/** Code threads waiting on `waitingOn`, plus any with a syntax fault — a fault refuses both ways. */
|
|
97
|
+
const codeThreadLines = (waitingOn: "human" | "agent"): string[] =>
|
|
98
|
+
codeThreads()
|
|
99
|
+
.filter((t) => t.waitingOn === waitingOn || t.faults.length > 0)
|
|
100
|
+
.flatMap((t) => [
|
|
101
|
+
` - ${t.path}:${t.line}: ${t.first}`,
|
|
102
|
+
...t.faults.map((fault) => ` ${fault}`),
|
|
103
|
+
])
|
|
104
|
+
|
|
105
|
+
const countOf = (lines: readonly string[]): number =>
|
|
106
|
+
lines.filter((line) => line.startsWith(" - ")).length
|
|
107
|
+
|
|
108
|
+
/** Whether a thread waits on `waitingOn`: one in footnote file `file`, or any code thread in the process's changed files. */
|
|
109
|
+
export const hasThreadFor = (waitingOn: "human" | "agent", file?: string): boolean =>
|
|
110
|
+
(file !== undefined && threads(read(file) ?? "").some((t) => t.waitingOn === waitingOn)) ||
|
|
111
|
+
codeThreads().some((t) => t.waitingOn === waitingOn)
|
|
112
|
+
|
|
113
|
+
/** Refuse any landing while a thread in `file` or a code thread in a changed file waits on the human (its last entry is the agent's), or has a syntax fault. */
|
|
114
|
+
export const requireThreadsClosed = (file: string): void => {
|
|
115
|
+
const open = [...threadLines(file, "human"), ...codeThreadLines("human")]
|
|
116
|
+
if (open.length === 0) return
|
|
117
|
+
refuse(
|
|
118
|
+
`gtd land: open-threads: ${countOf(open)} thread(s) in ${file} or code comments wait on you — reply with a conclusion, or delete the thread:\n${open.join("\n")}`,
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Refuse an agent turn that leaves a thread in `file`, or a code thread in a changed file, waiting on the agent (its last entry is `H:`) or with a syntax fault. */
|
|
123
|
+
export const requireReplies = (file: string): void => {
|
|
124
|
+
const open = [...threadLines(file, "agent"), ...codeThreadLines("agent")]
|
|
125
|
+
if (open.length === 0) return
|
|
126
|
+
refuse(
|
|
127
|
+
`gtd land: unanswered-threads: ${countOf(open)} thread(s) in ${file} or code comments still wait on the agent — append one "- A:" reply to each (one "A:" comment line, same token and indentation, directly below a code thread's last line), or fold a concluded thread and delete it:\n${open.join("\n")}`,
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
85
131
|
/** Refuse a turn that did not undo the code edits `edited` recorded, back to their content before `base`. */
|
|
86
132
|
export const requireRevert = (edited: readonly Change[], base: string): void => {
|
|
87
133
|
const residue = edited.filter((c) => read(c.path) !== c.before).map((c) => c.path)
|
package/src/flows/runtime.ts
CHANGED
|
@@ -133,6 +133,8 @@ export interface FlowContext {
|
|
|
133
133
|
readonly sections: (text: string) => readonly string[]
|
|
134
134
|
readonly sectionBodies: (text: string) => readonly Section[]
|
|
135
135
|
readonly openQuestions: (text: string) => readonly OpenQuestion[]
|
|
136
|
+
readonly threads: (text: string) => readonly ThreadInfo[]
|
|
137
|
+
readonly codeThreads: () => readonly CodeThreadInfo[]
|
|
136
138
|
readonly vars: Readonly<Record<string, string>>
|
|
137
139
|
readonly head: () => string
|
|
138
140
|
readonly start: () => string
|
|
@@ -275,6 +277,34 @@ export interface OpenQuestion {
|
|
|
275
277
|
readonly line: number
|
|
276
278
|
}
|
|
277
279
|
|
|
280
|
+
export interface ThreadInfo {
|
|
281
|
+
readonly name: string
|
|
282
|
+
/** The 1-based line of the thread's `[^name]:` definition. */
|
|
283
|
+
readonly line: number
|
|
284
|
+
/** `"human"` means the last entry is the agent's: the thread is open. */
|
|
285
|
+
readonly waitingOn: "human" | "agent"
|
|
286
|
+
/** Syntax faults, each already prefixed `Footnote thread "[^name]":`. */
|
|
287
|
+
readonly faults: readonly string[]
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Every `H:`/`A:` thread (a footnote) in `text`. */
|
|
291
|
+
export const threads = (text: string): readonly ThreadInfo[] => ctx().threads(text)
|
|
292
|
+
|
|
293
|
+
export interface CodeThreadInfo {
|
|
294
|
+
readonly path: string
|
|
295
|
+
/** The 1-based line of the thread's first comment line. */
|
|
296
|
+
readonly line: number
|
|
297
|
+
/** `"human"` means the last entry is the agent's: the thread is open. */
|
|
298
|
+
readonly waitingOn: "human" | "agent"
|
|
299
|
+
/** The text of the opening `H:` entry. */
|
|
300
|
+
readonly first: string
|
|
301
|
+
/** Syntax faults, each already prefixed `Code thread at <path>:<line>:`. */
|
|
302
|
+
readonly faults: readonly string[]
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Every `H:`/`A:` thread in line comments of the files changed since the process's diff base. */
|
|
306
|
+
export const codeThreads = (): readonly CodeThreadInfo[] => ctx().codeThreads()
|
|
307
|
+
|
|
278
308
|
/** The qa-format questions in `text` that are still unanswered. */
|
|
279
309
|
export const openQuestions = (text: string): readonly OpenQuestion[] => ctx().openQuestions(text)
|
|
280
310
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { restoreScript } from "./scripts.js"
|
|
3
|
+
|
|
4
|
+
describe("restoreScript", () => {
|
|
5
|
+
const paths = { restore: ["a.ts"], remove: [] }
|
|
6
|
+
|
|
7
|
+
it("restores from <commit>~1 by default", () => {
|
|
8
|
+
expect(restoreScript("abc", paths)).toContain("--source='abc~1'")
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it("restores from the given source commit", () => {
|
|
12
|
+
const script = restoreScript("abc", paths, "def")
|
|
13
|
+
expect(script).toContain("--source='def'")
|
|
14
|
+
expect(script).not.toContain("abc~1")
|
|
15
|
+
})
|
|
16
|
+
})
|
package/src/flows/scripts.ts
CHANGED
|
@@ -87,6 +87,7 @@ export const revertScript = (commit: string, report: string, failure: string): s
|
|
|
87
87
|
export const restoreScript = (
|
|
88
88
|
commit: string,
|
|
89
89
|
paths: { readonly restore: readonly string[]; readonly remove: readonly string[] },
|
|
90
|
+
source?: string,
|
|
90
91
|
): string => {
|
|
91
92
|
const unchanged = (path: string): string => `git diff --quiet ${quote(commit)} -- ${quote(path)}`
|
|
92
93
|
return [
|
|
@@ -94,7 +95,7 @@ export const restoreScript = (
|
|
|
94
95
|
"set +e",
|
|
95
96
|
...paths.restore.map(
|
|
96
97
|
(path) =>
|
|
97
|
-
`${unchanged(path)} && git restore --source=${quote(`${commit}~1`)} --worktree -- ${quote(path)}`,
|
|
98
|
+
`${unchanged(path)} && git restore --source=${quote(source ?? `${commit}~1`)} --worktree -- ${quote(path)}`,
|
|
98
99
|
),
|
|
99
100
|
...paths.remove.map((path) => `${unchanged(path)} && rm -f -- ${quote(path)}`),
|
|
100
101
|
"",
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
2
|
answered,
|
|
3
3
|
changes,
|
|
4
|
+
hasThreadFor,
|
|
4
5
|
judge,
|
|
5
6
|
moveScript,
|
|
6
7
|
numeric,
|
|
7
|
-
openQuestions,
|
|
8
8
|
read,
|
|
9
9
|
refuse,
|
|
10
10
|
requireAnswers,
|
|
11
|
+
requireReplies,
|
|
12
|
+
requireThreadsClosed,
|
|
11
13
|
requireProgress,
|
|
12
14
|
run,
|
|
13
15
|
scope,
|
|
@@ -26,15 +28,16 @@ import {
|
|
|
26
28
|
import * as t from "./text.js"
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* no
|
|
31
|
+
* Always stop for the human. Resolves `true` when the round changed anything
|
|
32
|
+
* (the author revises), `false` on a clean re-run. Open threads refuse every
|
|
33
|
+
* landing; unticked questions only refuse a round with no thread for the
|
|
34
|
+
* agent to answer.
|
|
32
35
|
*/
|
|
33
|
-
const
|
|
34
|
-
const content = read(file)
|
|
35
|
-
if (content !== undefined && openQuestions(content).length === 0) return false
|
|
36
|
+
const noteGate = async (file: string, answer: () => Promise<void>): Promise<boolean> => {
|
|
36
37
|
await answer()
|
|
37
|
-
|
|
38
|
+
requireThreadsClosed(file)
|
|
39
|
+
if (changes().length === 0) return false
|
|
40
|
+
if (!hasThreadFor("agent", file)) requireAnswers(file)
|
|
38
41
|
return true
|
|
39
42
|
}
|
|
40
43
|
|
|
@@ -44,14 +47,17 @@ export const design = (base: string): Promise<void> =>
|
|
|
44
47
|
do {
|
|
45
48
|
await triage(base)
|
|
46
49
|
requireProgress(REQUIREMENTS)
|
|
47
|
-
|
|
50
|
+
requireReplies(REQUIREMENTS)
|
|
51
|
+
} while (await noteGate(REQUIREMENTS, answerProductQuestions))
|
|
48
52
|
})
|
|
49
53
|
|
|
50
54
|
/** Work out the technical plan, then decompose it into package files. */
|
|
51
55
|
export const architecture = (): Promise<void> =>
|
|
52
56
|
scope("architecture", async () => {
|
|
53
|
-
do
|
|
54
|
-
|
|
57
|
+
do {
|
|
58
|
+
await author()
|
|
59
|
+
requireReplies(ARCHITECTURE)
|
|
60
|
+
} while (await noteGate(ARCHITECTURE, answerTechnicalQuestions))
|
|
55
61
|
await decompose()
|
|
56
62
|
if (changes(".gtd/packages/**").length === 0) {
|
|
57
63
|
refuse("gtd land: decompose: write at least one package under .gtd/packages/")
|
package/src/workflows/prose.ts
CHANGED
|
@@ -42,7 +42,13 @@ export const agentConduct = `- You have shell and file tools — bash, read, wri
|
|
|
42
42
|
- The turn's message names a commit, a range, or a file to go inspect
|
|
43
43
|
yourself — never a diff or summary inlined into the conversation
|
|
44
44
|
- Across turns one conversation may span, work from what you already
|
|
45
|
-
read and settled, unless told this is a fresh, cold pass
|
|
45
|
+
read and settled, unless told this is a fresh, cold pass
|
|
46
|
+
- Your turn ends when you return, and nothing you leave running survives
|
|
47
|
+
it in any useful way — never detach or background work you intend to
|
|
48
|
+
act on, never end a turn planning to "check back": no later turn
|
|
49
|
+
inherits your intent. Run it in the foreground and wait, however long
|
|
50
|
+
it takes. If it genuinely cannot finish inside one turn, write down
|
|
51
|
+
where it stands and what to resume, so the tree carries the state`
|
|
46
52
|
|
|
47
53
|
export const designPersona = `You are the product-facing planning voice in gtd's build pipeline: turn
|
|
48
54
|
a raw sketch — a hand-edit, a scratch note, or both — into an ordered,
|
|
@@ -211,14 +217,34 @@ export const footnoteRules = `- Leave a footnote anywhere: mark the exact spot w
|
|
|
211
217
|
- A footnote is a comment on that exact spot — the hunk, line, or
|
|
212
218
|
paragraph it marks — never a whole-file remark
|
|
213
219
|
- \`name\` is yours to pick; only a definition's name must be unique in
|
|
214
|
-
this document — the same name may mark more than one spot
|
|
220
|
+
this document — the same name may mark more than one spot
|
|
221
|
+
- To start a conversation, open the definition with a list: \`[^name]:\`
|
|
222
|
+
then an indented \`- H: <your question>\`. The agent answers with
|
|
223
|
+
\`- A: <reply>\`. Reply with a new \`- H:\` entry to continue; to
|
|
224
|
+
close the thread, reply with a conclusion (the agent folds it in and
|
|
225
|
+
deletes the thread) or delete the thread yourself. Entries alternate
|
|
226
|
+
\`H:\`/\`A:\` and the agent never starts a thread
|
|
227
|
+
- While a thread's last entry is the agent's, it is open and moving on
|
|
228
|
+
is refused: reply with a conclusion, or delete it
|
|
229
|
+
- The same conversation works as \`// H: <question>\` comment lines in a
|
|
230
|
+
file changed in this process (\`#\`, \`--\` or \`;\` by language); the phone UI does not show them,
|
|
231
|
+
and \`gtd check --open-threads\` lists the open ones`
|
|
215
232
|
|
|
216
233
|
export const footnoteFoldIn = `- A footnote (\`[^name]\` plus its \`[^name]:\` definition) is a comment on
|
|
217
234
|
its exact anchor — fold it in as a mandatory concern described
|
|
218
235
|
against that anchor's hunk or paragraph, never flattened into a
|
|
219
236
|
whole-file remark
|
|
220
|
-
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
237
|
+
- A one-shot footnote needs no reply: fold it in and DELETE it in this
|
|
238
|
+
same turn — marker and definition together. Never re-read a footnote
|
|
239
|
+
already acted on. A one-shot footnote that asks a question becomes a
|
|
240
|
+
thread instead: rewrite its definition as \`- H: <its body>\` then
|
|
241
|
+
\`- A: <your reply>\`, keeping the marker
|
|
242
|
+
- A thread is a definition whose body is a list of \`- H:\`/\`- A:\`
|
|
243
|
+
entries. Handle each by its last entry:
|
|
244
|
+
- last is \`- H:\` and asks something — reply with exactly one
|
|
245
|
+
\`- A: <reply>\` entry and change nothing else for it
|
|
246
|
+
- last concludes or advises — fold it in like a one-shot footnote,
|
|
247
|
+
then delete marker and definition
|
|
248
|
+
- last is \`- A:\` — it waits on the human; leave it alone
|
|
249
|
+
- Never start a thread yourself, and never leave a thread whose last
|
|
250
|
+
entry is \`- H:\` — a turn that does is refused`
|
package/src/workflows/review.ts
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
import {
|
|
2
2
|
answered,
|
|
3
3
|
changes,
|
|
4
|
-
|
|
4
|
+
changesSince,
|
|
5
|
+
hasThreadFor,
|
|
5
6
|
head,
|
|
6
7
|
judge,
|
|
7
8
|
numeric,
|
|
8
9
|
read,
|
|
9
10
|
refuse,
|
|
10
11
|
removeScript,
|
|
12
|
+
requireReplies,
|
|
13
|
+
requireThreadsClosed,
|
|
11
14
|
run,
|
|
12
15
|
scope,
|
|
13
16
|
sectionBodies,
|
|
14
17
|
vars,
|
|
15
|
-
wrote,
|
|
16
18
|
type Change,
|
|
17
19
|
type JudgeQuestion,
|
|
18
20
|
} from "../flows/index.js"
|
|
21
|
+
import { stripCodeThreads } from "../steering/index.js"
|
|
19
22
|
import { escalation, FIX_CAP, healthy, type EscalationCount } from "./health.js"
|
|
20
23
|
import {
|
|
21
24
|
awaitReview,
|
|
@@ -59,9 +62,11 @@ export type ReviewOutcome =
|
|
|
59
62
|
| { readonly verdict: "signoff" }
|
|
60
63
|
| {
|
|
61
64
|
readonly verdict: "feedback"
|
|
62
|
-
/** The
|
|
65
|
+
/** The last round's commit, the re-plan base. */
|
|
63
66
|
readonly base: string
|
|
64
|
-
/** The
|
|
67
|
+
/** The commit `.gtd/REVIEW.md` was written at; edits are restored from it. */
|
|
68
|
+
readonly restoreFrom: string
|
|
69
|
+
/** The code paths the rounds changed, with their content before. */
|
|
65
70
|
readonly edited: readonly Change[]
|
|
66
71
|
}
|
|
67
72
|
|
|
@@ -98,48 +103,118 @@ const actionable = async (review: string): Promise<boolean> => {
|
|
|
98
103
|
)
|
|
99
104
|
}
|
|
100
105
|
|
|
106
|
+
const isCode = (path: string): boolean => path !== ".gtd" && !path.startsWith(".gtd/")
|
|
107
|
+
|
|
108
|
+
/** A code edit, not a thread-only change: lines that only add, answer or remove code threads don't count. */
|
|
109
|
+
const isCodeEdit = (c: Change): boolean =>
|
|
110
|
+
isCode(c.path) &&
|
|
111
|
+
stripCodeThreads(c.path, c.before ?? "") !== stripCodeThreads(c.path, c.after ?? "")
|
|
112
|
+
|
|
101
113
|
const collect = async (capture: string): Promise<"signoff" | "feedback"> => {
|
|
102
114
|
await collecting(capture)
|
|
103
|
-
if (
|
|
115
|
+
if (read(REQUIREMENTS) !== undefined) return "feedback"
|
|
104
116
|
if (changes().length === 0) return "signoff"
|
|
105
117
|
return refuse(
|
|
106
118
|
"gtd land: no declared pattern matches the pending changes — write .gtd/REQUIREMENTS.md from the feedback, or change nothing when it asks for nothing",
|
|
107
119
|
)
|
|
108
120
|
}
|
|
109
121
|
|
|
122
|
+
/** A review with ticks, footnote markers and thread definitions removed: what is left is line notes. */
|
|
123
|
+
const notesOf = (text: string): string =>
|
|
124
|
+
text
|
|
125
|
+
.replace(/^\[\^[^\]]+\]:[^\n]*(\n(?: {2,}|\t)[^\n]*|\n(?=\s*\n(?: {2,}|\t)))*/gm, "")
|
|
126
|
+
.replace(/\[\^[^\]]+\]/g, "")
|
|
127
|
+
.replace(/\[x\]/gi, "[ ]")
|
|
128
|
+
.replace(/\s+/g, " ")
|
|
129
|
+
.trim()
|
|
130
|
+
|
|
131
|
+
/** Whether anything but thread edits and ticks landed in `.gtd/REVIEW.md` since `commit`. */
|
|
132
|
+
const notedSince = (commit: string): boolean =>
|
|
133
|
+
changesSince(commit).some(
|
|
134
|
+
(c) => c.path === REVIEW && notesOf(c.before ?? "") !== notesOf(read(REVIEW) ?? ""),
|
|
135
|
+
)
|
|
136
|
+
|
|
110
137
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
138
|
+
* The human gate and its reply rounds: a question gets an answer inside
|
|
139
|
+
* `.gtd/REVIEW.md` and rests at the gate again, without a lap. Resolves the
|
|
140
|
+
* commit of the last `review.collecting` turn, `"missing"` when the round
|
|
141
|
+
* left no review, or `undefined` when no such turn ran.
|
|
114
142
|
*/
|
|
115
|
-
|
|
143
|
+
const converse = async (base: string): Promise<string | undefined> => {
|
|
144
|
+
let collectedAt: string | undefined
|
|
116
145
|
for (;;) {
|
|
117
|
-
await reviewing(base)
|
|
118
146
|
await awaitReview(base)
|
|
119
147
|
if (changes(REVIEW).some((c) => c.status === "deleted")) {
|
|
120
148
|
refuse(
|
|
121
149
|
"gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note (or edit code) to request changes.",
|
|
122
150
|
)
|
|
123
151
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (record === undefined) {
|
|
128
|
-
await reviewMissing(round)
|
|
129
|
-
continue
|
|
152
|
+
if (read(REVIEW) === undefined) {
|
|
153
|
+
await reviewMissing(head())
|
|
154
|
+
return "missing"
|
|
130
155
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
156
|
+
requireThreadsClosed(REVIEW)
|
|
157
|
+
if (!hasThreadFor("agent", REVIEW)) return collectedAt
|
|
158
|
+
await collecting(t.reviewEditsCapture(head()))
|
|
159
|
+
collectedAt = head()
|
|
160
|
+
requireReplies(REVIEW)
|
|
161
|
+
if (!hasThreadFor("human", REVIEW)) return collectedAt
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* What the in-loop `review.collecting` turn left: `folded` when it wrote
|
|
167
|
+
* REQUIREMENTS.md (those folds need their lap whatever is judged later),
|
|
168
|
+
* `settled` when only thread edits and ticks landed after it.
|
|
169
|
+
*/
|
|
170
|
+
const afterCollect = (collectedAt: string | undefined): { folded: boolean; settled: boolean } =>
|
|
171
|
+
// While the collecting turn is still pending its commit has no hash, so no
|
|
172
|
+
// range can start there; the flow rests at `review.closing` before it matters.
|
|
173
|
+
collectedAt === "" ? { folded: false, settled: false } : settledAfter(collectedAt)
|
|
174
|
+
|
|
175
|
+
const settledAfter = (collectedAt: string | undefined): { folded: boolean; settled: boolean } => ({
|
|
176
|
+
folded: collectedAt !== undefined && read(REQUIREMENTS) !== undefined,
|
|
177
|
+
settled:
|
|
178
|
+
collectedAt !== undefined &&
|
|
179
|
+
changesSince(collectedAt).every((c) => !isCodeEdit(c)) &&
|
|
180
|
+
!notedSince(collectedAt),
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
const finish = async (
|
|
184
|
+
reviewed: string,
|
|
185
|
+
collectedAt: string | undefined,
|
|
186
|
+
): Promise<ReviewOutcome> => {
|
|
187
|
+
const round = head()
|
|
188
|
+
const since = changesSince(reviewed)
|
|
189
|
+
const edited = since.filter(isCodeEdit)
|
|
190
|
+
const record = read(REVIEW) ?? ""
|
|
191
|
+
const { folded, settled } = afterCollect(collectedAt)
|
|
192
|
+
// The review gate clears every tick before its commit, so a changed
|
|
193
|
+
// REVIEW.md is a note, never a tick.
|
|
194
|
+
const noted = since.some((c) => c.path === REVIEW)
|
|
195
|
+
const outcome = (verdict: "signoff" | "feedback"): ReviewOutcome =>
|
|
196
|
+
verdict === "signoff" ? { verdict } : { verdict, base: round, restoreFrom: reviewed, edited }
|
|
197
|
+
await run("review.closing", removeScript([REVIEW]), {
|
|
198
|
+
label: "Closing the review",
|
|
199
|
+
base: round,
|
|
200
|
+
})
|
|
201
|
+
if (settled) return outcome(folded ? "feedback" : "signoff")
|
|
202
|
+
if (edited.length > 0) return outcome(await collect(t.reviewEditsCapture(round)))
|
|
203
|
+
if (!noted || !(await actionable(record))) return outcome(folded ? "feedback" : "signoff")
|
|
204
|
+
return outcome(await collect(t.reviewNotesCapture(round)))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A reviewer writes `.gtd/REVIEW.md` over everything since `base`, a human
|
|
209
|
+
* reviews and signs off or comments, and a comment is classified into
|
|
210
|
+
* requirements for another lap.
|
|
211
|
+
*/
|
|
212
|
+
export const review = async (base: string): Promise<ReviewOutcome> => {
|
|
213
|
+
for (;;) {
|
|
214
|
+
await reviewing(base)
|
|
215
|
+
const reviewed = head()
|
|
216
|
+
const collectedAt = await converse(base)
|
|
217
|
+
if (collectedAt !== "missing") return finish(reviewed, collectedAt)
|
|
143
218
|
}
|
|
144
219
|
}
|
|
145
220
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { installContext, type StepRequest } from "../flows/index.js"
|
|
1
|
+
import { installContext, type CodeThreadInfo, type StepRequest } from "../flows/index.js"
|
|
2
2
|
import { defaults } from "./vars.js"
|
|
3
3
|
|
|
4
4
|
export interface TextContext {
|
|
5
5
|
readonly vars?: Readonly<Record<string, string>>
|
|
6
6
|
readonly head?: string
|
|
7
7
|
readonly start?: string
|
|
8
|
+
readonly codeThreads?: readonly CodeThreadInfo[]
|
|
8
9
|
readonly read?: (path: string) => string | undefined
|
|
9
10
|
}
|
|
10
11
|
|
|
@@ -27,6 +28,8 @@ export const renderText = <T>(text: () => T, context: TextContext = {}): T => {
|
|
|
27
28
|
sections: () => [],
|
|
28
29
|
sectionBodies: () => [],
|
|
29
30
|
openQuestions: () => [],
|
|
31
|
+
threads: () => [],
|
|
32
|
+
codeThreads: () => context.codeThreads ?? [],
|
|
30
33
|
vars: { ...defaults, ...context.vars },
|
|
31
34
|
head: () => context.head ?? "",
|
|
32
35
|
start: () => context.start ?? "",
|
|
@@ -60,6 +63,8 @@ export const captureStep = async (
|
|
|
60
63
|
sections: () => [],
|
|
61
64
|
sectionBodies: () => [],
|
|
62
65
|
openQuestions: () => [],
|
|
66
|
+
threads: () => [],
|
|
67
|
+
codeThreads: () => context.codeThreads ?? [],
|
|
63
68
|
vars: { ...defaults, ...context.vars },
|
|
64
69
|
head: () => context.head ?? "",
|
|
65
70
|
start: () => context.start ?? "",
|
|
@@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest"
|
|
|
2
2
|
import {
|
|
3
3
|
agentWithSkills,
|
|
4
4
|
architectureAuthorPrompt,
|
|
5
|
+
architectureGateAnswerMessage,
|
|
5
6
|
buildFixQualityPrompt,
|
|
7
|
+
buildReviewAwaitReviewMessage,
|
|
8
|
+
buildReviewCollectingPrompt,
|
|
9
|
+
designGateAnswerMessage,
|
|
6
10
|
designTriagePrompt,
|
|
7
11
|
splitSkills,
|
|
8
12
|
summaryPrompt,
|
|
@@ -136,3 +140,67 @@ describe("summaryPrompt", () => {
|
|
|
136
140
|
expect(prompt).toContain("Token cost: 12\n- smart: 5\n- base: 7\n\nPrint the closing message")
|
|
137
141
|
})
|
|
138
142
|
})
|
|
143
|
+
|
|
144
|
+
describe("the thread rules", () => {
|
|
145
|
+
it("are in all three answering prompts and no longer say a footnote is human input only", () => {
|
|
146
|
+
const prompts = renderText(() => [
|
|
147
|
+
designTriagePrompt("base"),
|
|
148
|
+
architectureAuthorPrompt(),
|
|
149
|
+
buildReviewCollectingPrompt("capture"),
|
|
150
|
+
])
|
|
151
|
+
for (const prompt of prompts) {
|
|
152
|
+
expect(prompt).toContain("- A: <reply>")
|
|
153
|
+
expect(prompt).toContain("Never start a thread yourself")
|
|
154
|
+
expect(prompt).not.toContain("human input only")
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it("make the review collector write requirements, reply in the review and run the review check", () => {
|
|
159
|
+
const prompt = renderText(() => buildReviewCollectingPrompt("capture"))
|
|
160
|
+
expect(prompt).toContain("gtd check review .gtd/REVIEW.md")
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe("code threads in prompts", () => {
|
|
165
|
+
const waiting = [
|
|
166
|
+
{ path: "src/a.ts", line: 4, waitingOn: "agent", first: "why?", faults: [] },
|
|
167
|
+
{ path: "src/b.ts", line: 9, waitingOn: "human", first: "done", faults: [] },
|
|
168
|
+
] as const
|
|
169
|
+
|
|
170
|
+
it("lists the code threads waiting on the agent in the three gates", () => {
|
|
171
|
+
const prompts = renderText(
|
|
172
|
+
() => [
|
|
173
|
+
designTriagePrompt("base"),
|
|
174
|
+
architectureAuthorPrompt(),
|
|
175
|
+
buildReviewCollectingPrompt("capture"),
|
|
176
|
+
],
|
|
177
|
+
{ codeThreads: waiting },
|
|
178
|
+
)
|
|
179
|
+
for (const prompt of prompts) {
|
|
180
|
+
expect(prompt).toContain("src/a.ts:4: why?")
|
|
181
|
+
expect(prompt).not.toContain("src/b.ts:9")
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it("says nothing when none wait", () => {
|
|
186
|
+
const prompt = renderText(() => designTriagePrompt("base"))
|
|
187
|
+
expect(prompt).not.toContain("Code threads waiting on you")
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it("separates a thread from a one-shot code comment at the review collector", () => {
|
|
191
|
+
const prompt = renderText(() => buildReviewCollectingPrompt("capture"))
|
|
192
|
+
expect(prompt).toContain("exactly one `A:` comment line")
|
|
193
|
+
expect(prompt).toContain("one-shot")
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it("teaches the syntax once in the footnote rules", () => {
|
|
197
|
+
for (const message of renderText(() => [
|
|
198
|
+
designGateAnswerMessage(),
|
|
199
|
+
architectureGateAnswerMessage(),
|
|
200
|
+
buildReviewAwaitReviewMessage("base"),
|
|
201
|
+
])) {
|
|
202
|
+
expect(message).toContain("gtd check --open-threads")
|
|
203
|
+
expect(message).toContain("phone UI does not show them")
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
})
|