@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "15.6.0",
3
+ "version": "15.7.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
@@ -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)
@@ -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
+ })
@@ -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
- * Stop for a human only while `file` is missing or still has open questions.
30
- * Resolves `true` when the human answered (the author revises), `false` when
31
- * no question was left.
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 questionGate = async (file: string, answer: () => Promise<void>): Promise<boolean> => {
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
- requireAnswers(file)
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
- } while (await questionGate(REQUIREMENTS, answerProductQuestions))
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 await author()
54
- while (await questionGate(ARCHITECTURE, answerTechnicalQuestions))
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/")
@@ -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
- - DELETE it in this same turn — marker and definition together — the
221
- way a transient hand-written code comment is already treated. Never
222
- re-read a footnote already acted on
223
- - A footnote is human input only — you reply in prose; never write one
224
- yourself`
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`
@@ -1,21 +1,24 @@
1
1
  import {
2
2
  answered,
3
3
  changes,
4
- codeChanges,
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 commit the human's review edit landed as. */
65
+ /** The last round's commit, the re-plan base. */
63
66
  readonly base: string
64
- /** The code paths that edit changed, with their content before it. */
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 (wrote(REQUIREMENTS)) return "feedback"
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
- * A reviewer writes `.gtd/REVIEW.md` over everything since `base`, a human
112
- * reviews and signs off or comments, and a comment is classified into
113
- * requirements for another lap.
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
- export const review = async (base: string): Promise<ReviewOutcome> => {
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
- const edited = codeChanges()
125
- const round = head()
126
- const record = read(REVIEW)
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
- // The review gate clears every tick before its commit, so a changed
132
- // REVIEW.md is a note, never a tick.
133
- const noted = changes(REVIEW).length > 0
134
- const outcome = (verdict: "signoff" | "feedback"): ReviewOutcome =>
135
- verdict === "signoff" ? { verdict } : { verdict, base: round, edited }
136
- await run("review.closing", removeScript([REVIEW]), {
137
- label: "Closing the review",
138
- base: round,
139
- })
140
- if (edited.length > 0) return outcome(await collect(t.reviewEditsCapture(round)))
141
- if (!noted || !(await actionable(record))) return { verdict: "signoff" }
142
- return outcome(await collect(t.reviewNotesCapture(round)))
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
+ })