@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.
@@ -1,4 +1,12 @@
1
- import { agent, head, start, vars, type AgentOptions, type SummaryContext } from "../flows/index.js"
1
+ import {
2
+ agent,
3
+ codeThreads,
4
+ head,
5
+ start,
6
+ vars,
7
+ type AgentOptions,
8
+ type SummaryContext,
9
+ } from "../flows/index.js"
2
10
  import {
3
11
  skillsPreamble,
4
12
  styleBlock,
@@ -112,13 +120,26 @@ What each change does next (then run \`gtd land\`):
112
120
  - **Retry check** — edit the code and/or \`.gtd/FEEDBACK.md\` to fix the failing tests (**review-gate.check**).
113
121
  `
114
122
 
123
+ /** A bullet naming every code thread waiting on the agent; empty when none do. */
124
+ const codeThreadReplies = (): string => {
125
+ const waiting = codeThreads().filter((t) => t.waitingOn === "agent")
126
+ if (waiting.length === 0) return ""
127
+ const list = waiting.map((t) => ` - ${t.path}:${t.line}: ${t.first}`).join("\n")
128
+ return `
129
+ - Code threads waiting on you (a comment run opening with \`H:\` in a changed
130
+ file) — append exactly one \`A:\` comment line, same token and
131
+ indentation, directly below the thread's last line; or fold a
132
+ concluded thread in and delete its lines:
133
+ ${list}`
134
+ }
135
+
115
136
  export const designTriagePrompt = (base: string): string =>
116
137
  `${styleBlock}
117
138
 
118
139
  ${styleFormatContract}
119
140
 
120
141
  ${stateFileRules}
121
- ${footnoteFoldIn}
142
+ ${footnoteFoldIn}${codeThreadReplies()}
122
143
  - The only state file this turn touches is \`.gtd/REQUIREMENTS.md\`
123
144
  — no other files for notes or output
124
145
  - \`.gtd/TODO.md\` is the likely home of the sketch that started
@@ -195,27 +216,30 @@ export const designSystem = (): string =>
195
216
  ${agentConduct}`
196
217
 
197
218
  export const designGateAnswerMessage = (): string =>
198
- `Answering here closes a gap between what you want the product to
199
- do and what gets built; changing nothing and re-running says that
200
- gap is already closed.
219
+ `This gate stops on every process, even with no open question, so
220
+ you can discuss \`.gtd/REQUIREMENTS.md\`; changing nothing and re-running
221
+ accepts it as written — unless a thread is open (see below).
201
222
 
202
223
  \`.gtd/REQUIREMENTS.md\` holds the concerns under
203
224
  development. Each open question under \`## Open Questions\` offers
204
- a few options plus a \`- [ ] _your answer_\` slot. Answer EVERY
205
- question by ticking exactly one box (\`- [x]\`); for your own
206
- answer, replace \`_your answer_\` with your text and tick that
207
- line. Stepping is refused while any question is unanswered — with one
208
- escape: change nothing and re-run to advance with the questions
209
- unanswered.
225
+ a few options plus a \`- [ ] _your answer_\` slot. Tick exactly one
226
+ box (\`- [x]\`) per question; for your own answer, replace
227
+ \`_your answer_\` with your text and tick that line. A round that
228
+ only leaves notes or thread replies needs no tick. Stepping is
229
+ refused while a question is unanswered, unless the round adds a
230
+ thread for the agent to answer.
210
231
 
211
- You can also leave a footnote alongside an answer — it never
212
- substitutes for ticking a box, which is still required before
213
- stepping is allowed:
232
+ Moving on is refused while any thread is open — its last entry is
233
+ the agent's. Reply with a conclusion, or delete the thread. A reply
234
+ round returns to this same gate so you can read the agent's answer.
235
+
236
+ You can also leave a footnote alongside an answer:
214
237
 
215
238
  ${footnoteRules}
216
239
  What each change does next (then run \`gtd land\`):
217
- - **Accept as-is** — change nothing and re-run to advance with the questions unanswered — the plan stands as written.
218
- - **Revise answers** — tick exactly one option per open question (replace \`_your answer_\` for your own) to send it back for the agent to fold your answers in, or delete a question to skip it. To accept the plan as-is instead, revert everything and re-run — a clean tree is the only accept gesture.
240
+ - **Accept as-is** — change nothing and re-run; the plan stands as written. Refused while a thread is open.
241
+ - **Revise answers** — tick exactly one option per open question (replace \`_your answer_\` for your own) to send it back for the agent to fold your answers in, or delete a question to skip it.
242
+ - **Discuss** — start or continue a thread (\`- H:\`); the agent replies and this gate stops again.
219
243
  `
220
244
 
221
245
  export const architectureAuthorPrompt = (): string =>
@@ -224,7 +248,7 @@ export const architectureAuthorPrompt = (): string =>
224
248
  ${styleFormatContract}
225
249
 
226
250
  ${stateFileRules}
227
- ${footnoteFoldIn}
251
+ ${footnoteFoldIn}${codeThreadReplies()}
228
252
  - The only state files this turn touches are
229
253
  \`.gtd/ARCHITECTURE.md\` (write it) and \`.gtd/REQUIREMENTS.md\`
230
254
  (delete once folded in) — no other files for notes or output
@@ -309,27 +333,30 @@ ${stateFileRules}
309
333
  `
310
334
 
311
335
  export const architectureGateAnswerMessage = (): string =>
312
- `Answering here closes a gap between what you want built and how
313
- it actually gets built; changing nothing and re-running says
314
- that gap is already closed.
336
+ `This gate stops on every process, even with no open question, so
337
+ you can discuss \`.gtd/ARCHITECTURE.md\`; changing nothing and re-running
338
+ accepts it as written — unless a thread is open (see below).
315
339
 
316
340
  \`.gtd/ARCHITECTURE.md\` holds the technical plan under
317
341
  development. Each open question under \`## Open Questions\` offers
318
- a few options plus a \`- [ ] _your answer_\` slot. Answer EVERY
319
- question by ticking exactly one box (\`- [x]\`); for your own
320
- answer, replace \`_your answer_\` with your text and tick that
321
- line. Stepping is refused while any question is unanswered — with one
322
- escape: change nothing and re-run to advance with the questions
323
- unanswered.
342
+ a few options plus a \`- [ ] _your answer_\` slot. Tick exactly one
343
+ box (\`- [x]\`) per question; for your own answer, replace
344
+ \`_your answer_\` with your text and tick that line. A round that
345
+ only leaves notes or thread replies needs no tick. Stepping is
346
+ refused while a question is unanswered, unless the round adds a
347
+ thread for the agent to answer.
348
+
349
+ Moving on is refused while any thread is open — its last entry is
350
+ the agent's. Reply with a conclusion, or delete the thread. A reply
351
+ round returns to this same gate so you can read the agent's answer.
324
352
 
325
- You can also leave a footnote alongside an answer — it never
326
- substitutes for ticking a box, which is still required before
327
- stepping is allowed:
353
+ You can also leave a footnote alongside an answer:
328
354
 
329
355
  ${footnoteRules}
330
356
  What each change does next (then run \`gtd land\`):
331
- - **Accept as-is** — change nothing and re-run to advance with the questions unanswered — the plan stands as written.
332
- - **Revise answers** — tick exactly one option per open question (replace \`_your answer_\` for your own) to send it back for the agent to fold your answers in, or delete a question to skip it. To accept the plan as-is instead, revert everything and re-run — a clean tree is the only accept gesture.
357
+ - **Accept as-is** — change nothing and re-run; the plan stands as written. Refused while a thread is open.
358
+ - **Revise answers** — tick exactly one option per open question (replace \`_your answer_\` for your own) to send it back for the agent to fold your answers in, or delete a question to skip it.
359
+ - **Discuss** — start or continue a thread (\`- H:\`); the agent replies and this gate stops again.
333
360
  `
334
361
 
335
362
  export const packagesItemBuildingPrompt = (pkg: string): string =>
@@ -535,6 +562,11 @@ When you've been through the whole diff, run \`gtd land\`:
535
562
  whatever the boxes say. Every turn commit stays on the
536
563
  branch; run \`gtd summary\` afterward for a closing-message
537
564
  prompt.
565
+ - **Ask a question** — start a thread (\`- H: <question>\`) on a
566
+ footnote. The agent answers inside \`.gtd/REVIEW.md\` and the process
567
+ rests at this same gate again — no revert, no development lap. A
568
+ round that also leaves notes or edits folds those in the same turn
569
+ and answers the thread.
538
570
  - **Request changes** — leave a comment: a note on a
539
571
  \`.gtd/REVIEW.md\` line, a footnote anchored to a hunk, or a
540
572
  direct code edit — to send a FULL development lap
@@ -549,6 +581,9 @@ When you've been through the whole diff, run \`gtd land\`:
549
581
  genuinely non-actionable comment (an approving remark with no code
550
582
  edit) skips the lap and signs off straight away.
551
583
 
584
+ Every landing here is refused while a thread is open (its last entry
585
+ is the agent's): reply with a conclusion, or delete the thread.
586
+
552
587
  A footnote works the same way here as a line note:
553
588
 
554
589
  ${footnoteRules}
@@ -597,9 +632,16 @@ ${styleFormatContract}
597
632
  You are judging and classifying a round of review feedback.
598
633
 
599
634
  ${stateFileRules}
600
- ${footnoteFoldIn}
601
- - The only state file this turn touches is \`.gtd/REQUIREMENTS.md\` —
602
- you classify, you do not build
635
+ ${footnoteFoldIn}${codeThreadReplies()}
636
+ - This turn writes \`.gtd/REQUIREMENTS.md\` (the folded concerns) and
637
+ replies inside \`.gtd/REVIEW.md\` (thread replies only; the file stays
638
+ in the tree) — you classify, you do not build
639
+ - Fold every concluded thread, note, ticked answer and hand-edit into
640
+ \`.gtd/REQUIREMENTS.md\` and delete the folded threads from
641
+ \`.gtd/REVIEW.md\`; append one \`- A:\` reply to each thread
642
+ whose last entry is a \`- H:\` question
643
+ - Finish by running \`gtd check review .gtd/REVIEW.md\` and fix
644
+ what it reports
603
645
 
604
646
  The raw review material is:
605
647
 
@@ -612,10 +654,14 @@ The round is actionable if any of these hold:
612
654
 
613
655
  - The human left a note on \`.gtd/REVIEW.md\`. A note is a mandatory
614
656
  concern below
615
- - The human added a code comment this round, even a plain-prose
616
- one — describe it as a concern, and note the comment line
617
- itself is transient: it must not survive the lap that
618
- satisfies it
657
+ - The human added a code comment this round. A comment run whose
658
+ first line starts \`H:\` is a THREAD: when its last entry is an
659
+ \`H:\` question, write exactly one \`A:\` comment line, same token
660
+ and indentation, directly below it — no fold-in; a concluded
661
+ thread is folded into the requirements and its comment lines
662
+ deleted. Any other comment, even a plain-prose one, is a one-shot
663
+ concern — describe it, and note the comment line itself is
664
+ transient: it must not survive the lap that satisfies it
619
665
  - The human hand-edited non-comment code this round — no longer
620
666
  a committed intent to build on, but a sketch like the entry
621
667
  commit's own diff. Describe what it was reaching for; expect
@@ -0,0 +1,129 @@
1
+ import { afterEach, describe, expect, it } from "vitest"
2
+ import {
3
+ hasThreadFor,
4
+ installContext,
5
+ requireReplies,
6
+ requireThreadsClosed,
7
+ type CodeThreadInfo,
8
+ type ThreadInfo,
9
+ } from "../flows/index.js"
10
+
11
+ const withThreads = (
12
+ list: readonly ThreadInfo[],
13
+ run: () => void,
14
+ code: readonly CodeThreadInfo[] = [],
15
+ ): void => {
16
+ installContext({
17
+ refuse: (message: string): never => {
18
+ throw new Error(message)
19
+ },
20
+ read: () => "doc",
21
+ threads: () => list,
22
+ codeThreads: () => code,
23
+ } as never)
24
+ run()
25
+ }
26
+
27
+ afterEach(() => installContext(undefined))
28
+
29
+ const open: ThreadInfo = { name: "a", line: 3, waitingOn: "human", faults: [] }
30
+ const asked: ThreadInfo = { name: "b", line: 9, waitingOn: "agent", faults: [] }
31
+ const faulty: ThreadInfo = {
32
+ ...asked,
33
+ name: "c",
34
+ line: 14,
35
+ faults: ['Footnote thread "[^c]": two consecutive "H:" entries — entries must alternate'],
36
+ }
37
+
38
+ describe("requireThreadsClosed", () => {
39
+ it("refuses while any thread waits on the human, naming each", () => {
40
+ withThreads([open, asked, { name: "c", line: 12, waitingOn: "human", faults: [] }], () => {
41
+ expect(() => requireThreadsClosed("f.md")).toThrow(/f\.md:3: \[\^a\][\s\S]*f\.md:12: \[\^c\]/)
42
+ expect(() => requireThreadsClosed("f.md")).toThrow(/reply with a conclusion, or delete/)
43
+ })
44
+ })
45
+
46
+ it("passes with no open thread", () => {
47
+ withThreads([asked], () => expect(() => requireThreadsClosed("f.md")).not.toThrow())
48
+ })
49
+
50
+ it("refuses a thread with a syntax fault even when it waits on the agent", () => {
51
+ withThreads([faulty], () => {
52
+ expect(() => requireThreadsClosed("f.md")).toThrow(/f\.md:14: \[\^c\][\s\S]*two consecutive/)
53
+ })
54
+ })
55
+ })
56
+
57
+ describe("requireReplies", () => {
58
+ it("refuses while any thread waits on the agent, naming each", () => {
59
+ withThreads([open, asked], () => {
60
+ expect(() => requireReplies("f.md")).toThrow(/f\.md:9: \[\^b\]/)
61
+ })
62
+ })
63
+
64
+ it("passes when every thread has the agent's reply", () => {
65
+ withThreads([open], () => expect(() => requireReplies("f.md")).not.toThrow())
66
+ })
67
+
68
+ it("refuses a thread with a syntax fault even when it waits on the human", () => {
69
+ withThreads([{ ...faulty, waitingOn: "human" }], () => {
70
+ expect(() => requireReplies("f.md")).toThrow(/f\.md:14: \[\^c\][\s\S]*two consecutive/)
71
+ })
72
+ })
73
+ })
74
+
75
+ const codeOpen: CodeThreadInfo = {
76
+ path: "src/a.ts",
77
+ line: 7,
78
+ waitingOn: "human",
79
+ first: "why?",
80
+ faults: [],
81
+ }
82
+ const codeAsked: CodeThreadInfo = { ...codeOpen, line: 20, waitingOn: "agent", first: "how?" }
83
+ const codeFaulty: CodeThreadInfo = {
84
+ ...codeOpen,
85
+ line: 30,
86
+ first: "x",
87
+ faults: ['Code thread at src/a.ts:30: two consecutive "H:" entries'],
88
+ }
89
+
90
+ describe("code threads at the gates", () => {
91
+ it("an open code thread refuses landing, naming path:line and first entry", () => {
92
+ withThreads(
93
+ [],
94
+ () => expect(() => requireThreadsClosed("f.md")).toThrow(/src\/a\.ts:7: why\?/),
95
+ [codeOpen, codeAsked],
96
+ )
97
+ })
98
+
99
+ it("a code thread waiting on the agent does not refuse landing", () => {
100
+ withThreads([], () => expect(() => requireThreadsClosed("f.md")).not.toThrow(), [codeAsked])
101
+ })
102
+
103
+ it("a code thread waiting on the agent refuses an agent turn, naming path:line", () => {
104
+ withThreads(
105
+ [],
106
+ () => {
107
+ expect(() => requireReplies("f.md")).toThrow(/src\/a\.ts:20: how\?/)
108
+ expect(() => requireReplies("f.md")).toThrow(/one "A:" comment line/)
109
+ },
110
+ [codeOpen, codeAsked],
111
+ )
112
+ })
113
+
114
+ it("a fault refuses both ways", () => {
115
+ withThreads([], () => expect(() => requireThreadsClosed("f.md")).toThrow(/src\/a\.ts:30/), [
116
+ codeFaulty,
117
+ ])
118
+ withThreads([], () => expect(() => requireReplies("f.md")).toThrow(/two consecutive/), [
119
+ codeFaulty,
120
+ ])
121
+ })
122
+
123
+ it("hasThreadFor combines footnote and code threads", () => {
124
+ withThreads([], () => expect(hasThreadFor("agent", "f.md")).toBe(true), [codeAsked])
125
+ withThreads([asked], () => expect(hasThreadFor("agent", "f.md")).toBe(true))
126
+ withThreads([open], () => expect(hasThreadFor("agent", "f.md")).toBe(false), [codeOpen])
127
+ withThreads([open], () => expect(hasThreadFor("human")).toBe(false))
128
+ })
129
+ })
@@ -30,6 +30,7 @@ import * as t from "./text.js"
30
30
  // Every part is exported for other workflows to compose; see the modules
31
31
  // re-exported below.
32
32
 
33
+ export { threads, type ThreadInfo } from "../flows/index.js"
33
34
  export { defaults } from "./vars.js"
34
35
  export { agentWithSkills } from "./text.js"
35
36
  export * from "./steps.js"
@@ -53,10 +54,10 @@ export const unwind = (): Promise<void> => {
53
54
  export const reUnwind = async (
54
55
  feedback: Extract<ReviewOutcome, { verdict: "feedback" }>,
55
56
  ): Promise<void> => {
56
- const { base, edited } = feedback
57
+ const { base, restoreFrom, edited } = feedback
57
58
  const restore = edited.filter((c) => c.status !== "added").map((c) => c.path)
58
59
  const remove = edited.filter((c) => c.status === "added").map((c) => c.path)
59
- await run("re-unwind", restoreScript(base, { restore, remove }), {
60
+ await run("re-unwind", restoreScript(base, { restore, remove }, restoreFrom), {
60
61
  label: "Re-unwinding your review edit",
61
62
  file: REVIEW,
62
63
  base,