@pmelab/gtd 23.1.0 → 24.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.
Files changed (50) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +13 -6
  3. package/dist/gtd.bundle.mjs +795 -1051
  4. package/dist/workflows.mjs +1952 -1725
  5. package/package.json +2 -1
  6. package/skills/authoring/SKILL.md +5 -4
  7. package/src/flows/factory.ts +5 -1
  8. package/src/flows/runtime.ts +3 -0
  9. package/src/workflow-kit/health.test.ts +30 -0
  10. package/src/workflow-kit/health.ts +86 -0
  11. package/src/workflow-kit/index.ts +7 -0
  12. package/src/workflow-kit/quality.test.ts +17 -0
  13. package/src/workflow-kit/quality.ts +45 -0
  14. package/src/workflow-kit/review.ts +324 -0
  15. package/src/{workflows → workflow-kit}/scenarios.ts +21 -28
  16. package/src/workflows/feature/feature.test.ts +38 -0
  17. package/src/workflows/{bundled.ts → feature/feature.ts} +11 -63
  18. package/src/workflows/{packages.test.ts → feature/packages.test.ts} +2 -2
  19. package/src/workflows/{packages.ts → feature/packages.ts} +3 -3
  20. package/src/workflows/{planning.ts → feature/planning.ts} +2 -3
  21. package/src/workflows/{review.test.ts → feature/review.test.ts} +9 -44
  22. package/src/workflows/feature/review.ts +106 -0
  23. package/src/workflows/feature/steps.test.ts +82 -0
  24. package/src/workflows/feature/steps.ts +92 -0
  25. package/src/workflows/fix/fix.test.ts +94 -0
  26. package/src/workflows/fix/fix.ts +52 -0
  27. package/src/workflows/index.ts +44 -2
  28. package/src/workflows/review/review.test.ts +43 -0
  29. package/src/workflows/review/review.ts +31 -0
  30. package/src/workflows/{access.test.ts → shared/access.test.ts} +1 -1
  31. package/src/workflows/{access.ts → shared/access.ts} +3 -5
  32. package/src/workflows/{health.test.ts → shared/health.test.ts} +1 -1
  33. package/src/workflows/{health.ts → shared/health.ts} +38 -55
  34. package/src/workflows/{options.test.ts → shared/options.test.ts} +2 -2
  35. package/src/workflows/{options.ts → shared/options.ts} +14 -2
  36. package/src/workflows/{scenarios.test.ts → shared/scenarios.test.ts} +1 -1
  37. package/src/workflows/shared/scenarios.ts +35 -0
  38. package/src/workflows/{skills.test.ts → shared/skills.test.ts} +3 -1
  39. package/src/workflows/{skills.ts → shared/skills.ts} +7 -6
  40. package/src/workflows/{steps.test.ts → shared/steps.test.ts} +1 -66
  41. package/src/workflows/{steps.ts → shared/steps.ts} +8 -98
  42. package/src/workflows/shared/tail.test.ts +205 -0
  43. package/src/workflows/shared/tail.ts +135 -0
  44. package/src/workflows/{text.fixture.ts → shared/text.fixture.ts} +2 -2
  45. package/src/workflows/{text.ts → shared/text.ts} +48 -7
  46. package/src/workflows/{threads.test.ts → shared/threads.test.ts} +1 -1
  47. package/src/workflows/review.ts +0 -362
  48. /package/src/workflows/{prose.ts → shared/prose.ts} +0 -0
  49. /package/src/workflows/{text.test.ts → shared/text.test.ts} +0 -0
  50. /package/src/workflows/{vars.ts → shared/vars.ts} +0 -0
@@ -1,362 +0,0 @@
1
- import {
2
- answered,
3
- changes,
4
- changesSince,
5
- hasThreadFor,
6
- head,
7
- judge,
8
- numeric,
9
- read,
10
- refuse,
11
- removeScript,
12
- requireReplies,
13
- requireThreadsClosed,
14
- run,
15
- scope,
16
- vars,
17
- type Change,
18
- type JudgeQuestion,
19
- } from "../flows/index.js"
20
- import { reviewNotes, reviewRisks, stripCodeThreads, type ReviewNote } from "../steering/index.js"
21
- import { escalation, FIX_CAP, healthy, type EscalationCount } from "./health.js"
22
- import type { BuiltPlan } from "./packages.js"
23
- import { guarded, type FrozenScenarios } from "./scenarios.js"
24
- import {
25
- answerReviewQuestions,
26
- ARCHITECTURE,
27
- awaitReview,
28
- collecting,
29
- fixCheck,
30
- fixNits,
31
- fixQuality,
32
- fixRisks,
33
- QUALITY,
34
- REQUIREMENTS,
35
- REVIEW,
36
- reviewing,
37
- reviewMissing,
38
- reviewQuality,
39
- } from "./steps.js"
40
- import * as t from "./text.js"
41
-
42
- /** Split a `qualityReviews` value into its lenses: on `,`, trimmed, blanks dropped. */
43
- export const lensesOf = (value: string): readonly string[] =>
44
- value
45
- .split(",")
46
- .map((lens) => lens.trim())
47
- .filter((lens) => lens.length > 0)
48
-
49
- /**
50
- * One review turn per lens over the whole change, each appending every
51
- * finding to `.gtd/QUALITY.md`. Resolves `"findings"` when that file
52
- * has any.
53
- */
54
- export const qualityLap = async (): Promise<"clean" | "findings"> => {
55
- for (const lens of lensesOf(vars.qualityReviews ?? "")) await reviewQuality(lens)
56
- return (read(QUALITY) ?? "").length > 0 ? "findings" : "clean"
57
- }
58
-
59
- /** Resolves `true` once `.gtd/QUALITY.md` is resolved, `false` when the fix cap escalated instead. */
60
- export const fixQualityFindings = async (
61
- escalations: EscalationCount,
62
- frozen?: FrozenScenarios,
63
- ): Promise<boolean> => {
64
- for (let turns = 0; turns < FIX_CAP; turns++) {
65
- await guarded(frozen, fixQuality)()
66
- // Not `changes()`: a wording gate after the fix would be the last step.
67
- if (read(QUALITY) === undefined) return true
68
- }
69
- await escalation(escalations)
70
- return false
71
- }
72
-
73
- /** How a review round ended. `feedback` carries what the human edited, for the re-unwind to undo. */
74
- export type ReviewOutcome =
75
- | { readonly verdict: "signoff" }
76
- | {
77
- readonly verdict: "feedback"
78
- /** The last round's commit, the re-plan base. */
79
- readonly base: string
80
- /** The commit `.gtd/REVIEW.md` was written at; edits are restored from it. */
81
- readonly restoreFrom: string
82
- /** The code paths the rounds changed, with their content before. */
83
- readonly edited: readonly Change[]
84
- }
85
-
86
- type Verdict = "edit" | "question" | "nit" | "praise"
87
-
88
- const VERDICTS: readonly Verdict[] = ["edit", "question", "nit", "praise"]
89
-
90
- /** One four-way choice per note the human added to `.gtd/REVIEW.md`. */
91
- const verdictQuestion = (note: ReviewNote): JudgeQuestion => ({
92
- id: note.id,
93
- primitive: "choice",
94
- instructions: `Classify the human's note on review item "${note.anchor}" (in .gtd/REVIEW.md). Its evidence holds the anchor, the reviewer's text and the human's text.`,
95
- criteria:
96
- "edit: a request to change behaviour, design or scope — anything needing a plan, or any note you are unsure about. question: asks something and wants an answer, with no change requested. nit: a small, local, unambiguous fix (naming, typo, formatting, comment wording) needing no re-plan. praise: an approving remark with nothing to do.",
97
- })
98
-
99
- /**
100
- * One judge rest, one verdict per note. Dismissing a note is the risky
101
- * direction, so only a confident non-`edit` verdict on uncut evidence counts;
102
- * a cut, unanswered or below-floor note is an `edit`, and a blank floor makes
103
- * every note one.
104
- */
105
- const triage = async (notes: readonly ReviewNote[]): Promise<ReadonlyMap<string, Verdict>> => {
106
- const { answers, truncated } = await judge("review.triage", {
107
- questions: notes.map(verdictQuestion),
108
- evidence: Object.fromEntries(
109
- notes.map((n) => [
110
- n.id,
111
- `Anchor: ${n.anchor}\nReviewer's text: ${n.before}\nHuman's text: ${n.text}`,
112
- ]),
113
- ),
114
- message: t.buildReviewTriageMessage(),
115
- label: "Judging each note",
116
- })
117
- const minP = numeric(vars.reviewNoteActionable, Infinity)
118
- return new Map(
119
- notes.map((n): [string, Verdict] => {
120
- const verdict = VERDICTS.find((v) => v !== "edit" && answered(answers[n.id], v, minP))
121
- return [n.id, truncated.includes(n.id) || verdict === undefined ? "edit" : verdict]
122
- }),
123
- )
124
- }
125
-
126
- const isCode = (path: string): boolean => path !== ".gtd" && !path.startsWith(".gtd/")
127
-
128
- /** A code edit, not a thread-only change: lines that only add, answer or remove code threads don't count. */
129
- const isCodeEdit = (c: Change): boolean =>
130
- isCode(c.path) &&
131
- stripCodeThreads(c.path, c.before ?? "") !== stripCodeThreads(c.path, c.after ?? "")
132
-
133
- const collect = async (capture: string): Promise<"signoff" | "feedback"> => {
134
- await collecting(capture)
135
- if (read(REQUIREMENTS) !== undefined) return "feedback"
136
- if (changes().length === 0) return "signoff"
137
- return refuse(
138
- "gtd land: no declared pattern matches the pending changes — write .gtd/REQUIREMENTS.md from the feedback, or change nothing when it asks for nothing",
139
- )
140
- }
141
-
142
- /** A review with ticks, footnote markers and thread definitions removed: what is left is line notes. */
143
- const notesOf = (text: string): string =>
144
- text
145
- .replace(/^\[\^[^\]]+\]:[^\n]*(\n(?: {2,}|\t)[^\n]*|\n(?=\s*\n(?: {2,}|\t)))*/gm, "")
146
- .replace(/\[\^[^\]]+\]/g, "")
147
- .replace(/\[x\]/gi, "[ ]")
148
- .replace(/\s+/g, " ")
149
- .trim()
150
-
151
- /** Whether anything but thread edits and ticks landed in `.gtd/REVIEW.md` since `commit`. */
152
- const notedSince = (commit: string): boolean =>
153
- changesSince(commit).some(
154
- (c) => c.path === REVIEW && notesOf(c.before ?? "") !== notesOf(read(REVIEW) ?? ""),
155
- )
156
-
157
- /**
158
- * The human gate and its reply rounds: a question gets an answer inside
159
- * `.gtd/REVIEW.md` and rests at the gate again, without a lap. Resolves the
160
- * commit of the last `review.collecting` turn, `"missing"` when the round
161
- * left no review, or `undefined` when no such turn ran.
162
- */
163
- const converse = async (base: string, collectedBefore?: string): Promise<string | undefined> => {
164
- let collectedAt = collectedBefore
165
- for (;;) {
166
- await awaitReview(base)
167
- if (changes(REVIEW).some((c) => c.status === "deleted")) {
168
- refuse(
169
- "gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note (or edit code) to request changes.",
170
- )
171
- }
172
- if (read(REVIEW) === undefined) {
173
- await reviewMissing(head())
174
- return "missing"
175
- }
176
- requireThreadsClosed(REVIEW)
177
- if (!hasThreadFor("agent", REVIEW)) return collectedAt
178
- await collecting(t.reviewEditsCapture(head()))
179
- collectedAt = head()
180
- requireReplies(REVIEW)
181
- if (!hasThreadFor("human", REVIEW)) return collectedAt
182
- }
183
- }
184
-
185
- /**
186
- * What the in-loop `review.collecting` turn left: `folded` when it wrote
187
- * REQUIREMENTS.md (those folds need their lap whatever is judged later),
188
- * `settled` when only thread edits and ticks landed after it.
189
- */
190
- const afterCollect = (collectedAt: string | undefined): { folded: boolean; settled: boolean } =>
191
- // While the collecting turn is still pending its commit has no hash, so no
192
- // range can start there; the flow rests at `review.closing` before it matters.
193
- collectedAt === "" ? { folded: false, settled: false } : settledAfter(collectedAt)
194
-
195
- const settledAfter = (collectedAt: string | undefined): { folded: boolean; settled: boolean } => ({
196
- folded: collectedAt !== undefined && read(REQUIREMENTS) !== undefined,
197
- settled:
198
- collectedAt !== undefined &&
199
- changesSince(collectedAt).every((c) => !isCodeEdit(c)) &&
200
- !notedSince(collectedAt),
201
- })
202
-
203
- /** What `finish` hands back to `review`: an outcome, or another lap at the gate. */
204
- type Finish =
205
- | ReviewOutcome
206
- | { readonly next: "await"; readonly reviewed: string }
207
- | { readonly next: "rereview"; readonly carry: string | undefined }
208
-
209
- /** What a round's routing needs from `finish`: its closing step and the ways it ends. */
210
- interface Round {
211
- readonly round: string
212
- readonly escalations: EscalationCount
213
- readonly frozen: FrozenScenarios | undefined
214
- readonly close: () => Promise<void>
215
- readonly outcome: (verdict: "signoff" | "feedback") => ReviewOutcome
216
- readonly unfolded: () => Promise<Finish>
217
- }
218
-
219
- /** Judge every note, then run each verdict's route: answers, then nit fixes, then the edits' lap. */
220
- const routeNotes = async (notes: readonly ReviewNote[], r: Round): Promise<Finish> => {
221
- const verdicts = await triage(notes)
222
- const of = (v: Verdict): ReviewNote[] => notes.filter((n) => verdicts.get(n.id) === v)
223
- const [edits, questions, nits] = [of("edit"), of("question"), of("nit")]
224
- if (edits.length + questions.length + nits.length === 0) return r.unfolded()
225
-
226
- let answeredAt: string | undefined
227
- if (questions.length > 0) {
228
- await answerReviewQuestions(questions)
229
- answeredAt = head()
230
- }
231
- if (nits.length > 0) {
232
- await guarded(r.frozen, () => fixNits(nits), "review")()
233
- await healthy(guarded(r.frozen, fixCheck, "review"), { escalations: r.escalations })
234
- }
235
- if (edits.length > 0) {
236
- await r.close()
237
- return r.outcome(await collect(t.reviewEditNotesCapture(r.round, edits, answeredAt)))
238
- }
239
- if (nits.length > 0) {
240
- await r.close()
241
- return { next: "rereview", carry: answeredAt }
242
- }
243
- return { next: "await", reviewed: answeredAt! }
244
- }
245
-
246
- const finish = async (
247
- reviewed: string,
248
- collectedAt: string | undefined,
249
- escalations: EscalationCount,
250
- frozen: FrozenScenarios | undefined,
251
- ): Promise<Finish> => {
252
- // Everything the human did is read before any agent turn runs, so a nit fix
253
- // is never counted as a hand-edit.
254
- const round = head()
255
- const since = changesSince(reviewed)
256
- const edited = since.filter(isCodeEdit)
257
- const baselineText = since.get(REVIEW)?.before ?? ""
258
- const record = read(REVIEW) ?? ""
259
- const { folded, settled } = afterCollect(collectedAt)
260
- // The review gate clears every tick before its commit, so a changed
261
- // REVIEW.md is a note, never a tick.
262
- const noted = since.some((c) => c.path === REVIEW)
263
- const close = (): Promise<void> =>
264
- run("review.closing", removeScript([REVIEW]), { label: "Closing the review", base: round })
265
- const outcome = (verdict: "signoff" | "feedback"): ReviewOutcome =>
266
- verdict === "signoff" ? { verdict } : { verdict, base: round, restoreFrom: reviewed, edited }
267
- const unfolded = async (): Promise<Finish> => {
268
- await close()
269
- return outcome(folded ? "feedback" : "signoff")
270
- }
271
-
272
- const collectWith = async (capture: string): Promise<Finish> => {
273
- await close()
274
- return outcome(await collect(capture))
275
- }
276
- if (settled || (edited.length === 0 && !noted)) return unfolded()
277
- if (edited.length > 0) return collectWith(t.reviewEditsCapture(round))
278
- const notes = reviewNotes(baselineText, record)
279
- if (notes.length === 0) return collectWith(t.reviewNotesCapture(round))
280
- return routeNotes(notes, { round, escalations, frozen, close, outcome, unfolded })
281
- }
282
-
283
- /** 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. */
284
- const reviewOnce = async (
285
- base: string,
286
- carry: string | undefined,
287
- escalations: EscalationCount,
288
- frozen: FrozenScenarios | undefined,
289
- ): Promise<void> => {
290
- await reviewing(base, carry)
291
- const risks = reviewRisks(read(REVIEW) ?? "")
292
- if (risks.length === 0) return
293
- await guarded(frozen, () => fixRisks(risks), "review")()
294
- await healthy(guarded(frozen, fixCheck, "review"), { escalations })
295
- await reviewing(base, carry)
296
- }
297
-
298
- /**
299
- * A reviewer writes `.gtd/REVIEW.md` over everything since `base`, a human
300
- * reviews and signs off or comments, and each note is judged: edits go to
301
- * another planning lap, questions are answered in place, nits fixed in one
302
- * batch, praise dropped.
303
- */
304
- export const review = async (
305
- base: string,
306
- escalations: EscalationCount = { rounds: 0 },
307
- frozen?: FrozenScenarios,
308
- ): Promise<ReviewOutcome> => {
309
- let carry: string | undefined
310
- for (;;) {
311
- await reviewOnce(base, carry, escalations, frozen)
312
- carry = undefined
313
- let reviewed = head()
314
- let collectedAt: string | undefined
315
- for (;;) {
316
- collectedAt = await converse(base, collectedAt)
317
- if (collectedAt === "missing") break
318
- const result = await finish(reviewed, collectedAt, escalations, frozen)
319
- if (!("next" in result)) return result
320
- if (result.next === "rereview") {
321
- carry = result.carry
322
- break
323
- }
324
- reviewed = result.reviewed
325
- }
326
- }
327
- }
328
-
329
- /** The build tail: fix (when entered red), keep green, the quality lap, then human review since `base`. */
330
- export const buildTail = (
331
- fixFirst: boolean,
332
- base: string,
333
- built?: BuiltPlan,
334
- ): Promise<ReviewOutcome> =>
335
- scope("build", async () => {
336
- const frozen = built?.frozen
337
- const escalations: EscalationCount = { rounds: 0 }
338
- const guardedFix = guarded(frozen, () => fixCheck())
339
- let redFirst = fixFirst
340
- // fixFirst's own fix + healthy below already is the full run.
341
- if (!fixFirst) {
342
- await healthy(
343
- guarded(frozen, () => fixCheck(built)),
344
- { escalations, sweepOnGreen: [ARCHITECTURE] },
345
- )
346
- }
347
- // The lap runs once a tail: after its findings are fixed, review follows.
348
- let lapped = false
349
- for (;;) {
350
- if (redFirst) {
351
- await guardedFix()
352
- await healthy(guardedFix, { fixesSoFar: 1, escalations })
353
- redFirst = false
354
- }
355
- const lap = lapped ? "clean" : await qualityLap()
356
- lapped = true
357
- if (lap === "clean") return review(base, escalations, frozen)
358
- if (await fixQualityFindings(escalations, frozen)) {
359
- await healthy(guardedFix, { escalations })
360
- } else redFirst = true
361
- }
362
- })
File without changes
File without changes