@pmelab/gtd 44.0.0 → 45.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.
@@ -70,6 +70,12 @@ export interface ReviewPolicy {
70
70
  readonly fixNits: (notes: readonly ReviewNote[]) => Promise<void>
71
71
  /** Told every note the human answered this round, before any of them is routed. */
72
72
  readonly decided?: (decisions: readonly ReviewDecision[]) => void
73
+ /** An answer the policy settles in `learn` itself: never routed, recorded or told to `decided`. */
74
+ readonly settles?: (decision: ReviewDecision) => boolean
75
+ /** Reads every answer of the round once it is recorded, before any is routed. */
76
+ readonly learn?: (decisions: readonly ReviewDecision[]) => Promise<void>
77
+ /** Something still waits on the human's answer: a sign-off writes the review again instead. */
78
+ readonly pending?: () => boolean
73
79
  /** Remove `file` at the end of a round at commit `round`. */
74
80
  readonly close: (round: string) => Promise<void>
75
81
  /** What a collecting turn reads. */
@@ -222,6 +228,19 @@ const record = (p: ReviewPolicy, decisions: readonly ReviewDecision[]): void =>
222
228
  for (const d of decisions) if (d.choice === "wontfix") mark("wontfix", oneLine(d.anchor))
223
229
  }
224
230
 
231
+ const routed = (p: ReviewPolicy, answers: readonly ReviewDecision[]): readonly ReviewDecision[] =>
232
+ answers.filter((d) => p.settles?.(d) !== true)
233
+
234
+ /** Record the routed answers on the gate's commit, then hand every answer to `learn`. */
235
+ const recordAndLearn = async (
236
+ p: ReviewPolicy,
237
+ decisions: readonly ReviewDecision[],
238
+ answers: readonly ReviewDecision[],
239
+ ): Promise<void> => {
240
+ record(p, decisions)
241
+ if (answers.length > 0 && p.learn !== undefined) await p.learn(answers)
242
+ }
243
+
225
244
  /**
226
245
  * Judge every note, then run each verdict's route: answers, then nit fixes,
227
246
  * then the edits' lap. Of the human's `decisions`, a fix joins the nit batch
@@ -233,8 +252,9 @@ export const routeNotes = async (
233
252
  notes: readonly ReviewNote[],
234
253
  r: Round,
235
254
  decisions: readonly ReviewDecision[] = [],
255
+ answers: readonly ReviewDecision[] = decisions,
236
256
  ): Promise<Finish> => {
237
- record(p, decisions)
257
+ await recordAndLearn(p, decisions, answers)
238
258
  const of = (c: ReviewDecision["choice"]) => decisions.filter((d) => d.choice === c)
239
259
  const fixes = of("fix").map((d, i) => decisionNote(d, `fix-${i + 1}`, d.body))
240
260
  const judged = [
@@ -313,14 +333,14 @@ const settledAfter = (
313
333
 
314
334
  const finishNotes = async (
315
335
  p: ReviewPolicy,
316
- before: string | undefined,
317
- decisions: readonly ReviewDecision[],
336
+ notes: readonly ReviewNote[],
337
+ answers: readonly ReviewDecision[],
318
338
  reopened: boolean,
319
339
  ctx: Parameters<typeof routeNotes>[2],
320
340
  ): Promise<Finish> => {
321
- // An unchanged review file has no baseline entry, which would read as all-new.
322
- const notes = reviewNotes(before ?? "", read(p.file) ?? "")
323
- if (notes.length + decisions.length > 0) return routeNotes(p, notes, ctx, decisions)
341
+ // An answer the policy settles still routes, if only to learn: there is nothing to collect.
342
+ if (notes.length + answers.length > 0)
343
+ return routeNotes(p, notes, ctx, routed(p, answers), answers)
324
344
  if (reopened) return ctx.unfolded()
325
345
  await ctx.close()
326
346
  return ctx.outcome(await collect(p, p.captures.notes(ctx.round), "notes"))
@@ -343,6 +363,12 @@ const decisionsSince = (
343
363
  return reviewDecisions(since.get(p.file)?.before ?? after, after)
344
364
  }
345
365
 
366
+ // An unchanged review file has no baseline entry, which would read as all-new.
367
+ const notesSince = (
368
+ p: ReviewPolicy,
369
+ since: ReturnType<typeof changesSince>,
370
+ ): readonly ReviewNote[] => reviewNotes(since.get(p.file)?.before ?? "", read(p.file) ?? "")
371
+
346
372
  const finish = async (
347
373
  p: ReviewPolicy,
348
374
  reviewed: string,
@@ -368,12 +394,14 @@ const finish = async (
368
394
  // Thread-only edits settled the round, but a change note or hand-edit since
369
395
  // `reviewed` must still reach a collecting turn when a dropped edit is refused.
370
396
  const reopened = isReopened(p, { settled, folded })
371
- const decisions = decisionsSince(p, since)
372
- if ((settled && !reopened && decisions.length === 0) || (edited.length === 0 && !noted))
397
+ const answers = decisionsSince(p, since)
398
+ if ((settled && !reopened && answers.length === 0) || (edited.length === 0 && !noted))
373
399
  return unfolded()
400
+ // Read before `learn`, which may add to the review file.
401
+ const notes = notesSince(p, since)
374
402
  if (edited.length > 0) {
375
403
  // A hand-edit plans a lap; its collecting turn reads the answers in the review.
376
- record(p, decisions)
404
+ await recordAndLearn(p, routed(p, answers), answers)
377
405
  await close()
378
406
  return outcome(
379
407
  await collect(p, p.captures.edits(round, reopened ? reviewed : undefined), "edits", {
@@ -382,7 +410,7 @@ const finish = async (
382
410
  }),
383
411
  )
384
412
  }
385
- return finishNotes(p, since.get(p.file)?.before, decisions, reopened, {
413
+ return finishNotes(p, notes, answers, reopened, {
386
414
  round,
387
415
  close,
388
416
  outcome,
@@ -390,6 +418,12 @@ const finish = async (
390
418
  })
391
419
  }
392
420
 
421
+ /** A sign-off while something waits on the human writes the review again instead. */
422
+ const unlessPending = (p: ReviewPolicy, result: Finish): Finish =>
423
+ "verdict" in result && result.verdict === "signoff" && p.pending?.() === true
424
+ ? { next: "rereview", carry: undefined }
425
+ : result
426
+
393
427
  /** 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. */
394
428
  const reviewOnce = async (
395
429
  p: Pick<ReviewPolicy, "file" | "reviewing" | "fixRisks" | "keepGreen">,
@@ -420,7 +454,7 @@ export const review = async (base: string, p: ReviewPolicy): Promise<ReviewOutco
420
454
  for (;;) {
421
455
  collectedAt = await converse(p, base, reviewed, collectedAt)
422
456
  if (collectedAt === "missing") break
423
- const result = await finish(p, reviewed, collectedAt)
457
+ const result = unlessPending(p, await finish(p, reviewed, collectedAt))
424
458
  if (!("next" in result)) return result
425
459
  if (result.next === "rereview") {
426
460
  carry = result.carry
@@ -114,12 +114,16 @@ Feature: every note in the review is answered with one tick
114
114
  Then landing is refused with "answer-completeness"
115
115
 
116
116
  Scenario: won't fix drops the note, records it, and signs off
117
+ Given judge step "build.review.standards.judge" answers:
118
+ """
119
+ [{"id":"standard-1","answer":"no","p":0.9}]
120
+ """
117
121
  When the process is driven
118
122
  And the text "- [ ] won't fix" in ".gtd/REVIEW.md" is replaced with "- [x] won't fix"
119
123
  And gtd lands
120
124
  And the process is driven
121
125
  Then the process rests at "idle"
122
- And the commit "gtd(human): build.review.await-review → build.review.closing" has a body containing "Gtd-Mark: wontfix=AC-1 ignores floating point"
126
+ And the commit "gtd(human): build.review.await-review → build.review.standards.judge" has a body containing "Gtd-Mark: wontfix=AC-1 ignores floating point"
123
127
  And ".gtd/TODO.md" does not exist
124
128
  And no commit body contains "build.review.triage"
125
129
 
@@ -145,7 +149,11 @@ Feature: every note in the review is answered with one tick
145
149
  Then the process rests at "idle"
146
150
 
147
151
  Scenario: an own answer is judged like a note
148
- Given judge step "build.review.triage" answers:
152
+ Given judge step "build.review.standards.judge" answers:
153
+ """
154
+ [{"id":"standard-1","answer":"no","p":0.9}]
155
+ """
156
+ And judge step "build.review.triage" answers:
149
157
  """
150
158
  [{"id":"answer-1","answer":"nit","p":0.9}]
151
159
  """
@@ -163,7 +171,8 @@ Feature: every note in the review is answered with one tick
163
171
  Then the process rests at "build.review.await-review"
164
172
  And the commit subjects end with:
165
173
  """
166
- gtd(human): build.review.await-review → build.review.triage
174
+ gtd(human): build.review.await-review → build.review.standards.judge
175
+ gtd(judge): build.review.standards.judge → build.review.triage
167
176
  gtd(judge): build.review.triage → build.review.fix.nits.fixing
168
177
  gtd(agent): build.review.fix.nits.fixing → build.health.check
169
178
  gtd(check): build.health.check → build.review.closing
@@ -0,0 +1,286 @@
1
+ @inmem
2
+ Feature: the review learns standards from the human's answers
3
+
4
+ A won't-fix tick or an own answer at the review gate is a candidate
5
+ standard. A judge asks whether it is reusable; above `standardMinP` it is
6
+ kept in `.gtd/STANDARDS.md` with no further question, and every later agent
7
+ turn — lenses and coder alike — is told to follow it. One that contradicts
8
+ a standard already kept becomes a note in `.gtd/REVIEW.md` the review cannot
9
+ sign off without: keep the new one, keep the old one, or write the merged
10
+ wording.
11
+
12
+ Background:
13
+ Given the shipped workflows, as gtd install scaffolds them
14
+ And an environment variable "GTD_QUALITYREVIEWS" set to "correctness"
15
+ And an environment variable "GTD_FLAGMODE" set to "false"
16
+ And a file "src/calc.ts" with:
17
+ """
18
+ export const add = (a: number, b: number) => a + b
19
+ """
20
+ And the working tree is committed as "feat: add calculator"
21
+ And a file "TODO.md" with:
22
+ """
23
+ # Add subtract
24
+
25
+ subtract(a, b) returns the difference.
26
+ """
27
+ And step "unwind" changes nothing
28
+ And step "start-gate.check" changes nothing
29
+ And step "design.triage" writes ".gtd/REQUIREMENTS.md" with:
30
+ """
31
+ # Requirements
32
+
33
+ ## Concerns
34
+
35
+ ### Subtract
36
+
37
+ - `subtract(a, b)` returns `a - b`.
38
+
39
+ ## Acceptance Criteria
40
+
41
+ - AC-1: subtract(a, b) returns a - b.
42
+ """
43
+ And step "design.gate.answer" changes nothing
44
+ And step "seam.naive" writes "src/calc.test.ts" with:
45
+ """
46
+ it("[AC-1] subtracts", () => {})
47
+ """
48
+ And step "seam.naive" writes "src/calc.ts" with:
49
+ """
50
+ export const add = (a: number, b: number) => a + b
51
+ export const subtract = (a: number, b: number) => a - b
52
+ """
53
+ And step "seam.check" changes nothing
54
+ And step "implement.applying" runs its script
55
+ And step "implement.health.check" changes nothing
56
+ And step "implement.closing" runs its script
57
+ And judge step "implement.coverage" answers:
58
+ """
59
+ [{"id":"AC-1","answer":"yes","p":0.9}]
60
+ """
61
+ And step "build.quality.correctness.reviewing" writes ".gtd/QUALITY.md" with:
62
+ """
63
+ # Quality
64
+
65
+ ## subtract has no class
66
+
67
+ Calculators read better as classes.
68
+ """
69
+ And step "build.quality.sorting" runs its script
70
+ And step "build.review.composing" runs its script
71
+ And judge step "build.quality.triage" answers:
72
+ """
73
+ [{"id":"Q1","answer":"design","p":0.9}]
74
+ """
75
+ And step "build.review.closing" runs its script
76
+ And step "build.review.standards.recording" runs its script
77
+ And step "build.review.standards.raising" runs its script
78
+
79
+ Scenario: a won't-fix the judge calls reusable is kept, and the review signs off
80
+ Given judge step "build.review.standards.judge" answers:
81
+ """
82
+ [{"id":"standard-1","answer":"yes","p":0.9}]
83
+ """
84
+ When the process is driven
85
+ And the text "- [ ] won't fix" in ".gtd/REVIEW.md" is replaced with "- [x] won't fix"
86
+ And gtd lands
87
+ And the process is driven
88
+ Then the process rests at "idle"
89
+ And ".gtd/STANDARDS.md" contains:
90
+ """
91
+ - Won't fix: subtract has no class — Calculators read better as classes.
92
+ """
93
+ And the commit subjects end with:
94
+ """
95
+ gtd(human): build.review.await-review → build.review.standards.judge
96
+ gtd(judge): build.review.standards.judge → build.review.standards.recording
97
+ gtd(check): build.review.standards.recording → build.review.closing
98
+ gtd(check): build.review.closing → idle
99
+ """
100
+
101
+ Scenario: the next review lists the standards added
102
+ Given judge step "build.review.standards.judge" answers:
103
+ """
104
+ [{"id":"standard-1","answer":"yes","p":0.9}]
105
+ """
106
+ And judge step "build.review.triage" answers:
107
+ """
108
+ [{"id":"answer-1","answer":"nit","p":0.9}]
109
+ """
110
+ And step "build.review.fix.nits.fixing" writes "src/calc.ts" with:
111
+ """
112
+ export function add(a: number, b: number) { return a + b }
113
+ export function subtract(a: number, b: number) { return a - b }
114
+ """
115
+ And step "build.health.check" changes nothing
116
+ When the process is driven
117
+ And the text "- [ ] _your answer_" in ".gtd/REVIEW.md" is replaced with "- [x] Calculators are plain functions."
118
+ And gtd lands
119
+ And the process is driven
120
+ Then the process rests at "build.review.await-review"
121
+ And ".gtd/REVIEW.md" contains:
122
+ """
123
+ ## Standards added
124
+
125
+ Learned from your answers and kept in `.gtd/STANDARDS.md`:
126
+
127
+ - Calculators are plain functions. (re: subtract has no class)
128
+ """
129
+
130
+ Scenario: an answer below the threshold is not kept
131
+ Given judge step "build.review.standards.judge" answers:
132
+ """
133
+ [{"id":"standard-1","answer":"yes","p":0.6}]
134
+ """
135
+ When the process is driven
136
+ And the text "- [ ] won't fix" in ".gtd/REVIEW.md" is replaced with "- [x] won't fix"
137
+ And gtd lands
138
+ And the process is driven
139
+ Then the process rests at "idle"
140
+ And ".gtd/STANDARDS.md" does not exist
141
+
142
+ Scenario: a kept standard reaches the lenses and the coder of a later process
143
+ Given a file ".gtd/STANDARDS.md" with:
144
+ """
145
+ # Standards
146
+
147
+ - Won't fix: subtract has no class — Calculators read better as classes.
148
+ """
149
+ And the working tree is committed as "chore: keep a standard"
150
+ And a file "TODO.md" with:
151
+ """
152
+ # Add subtract
153
+
154
+ Please add subtract(a, b).
155
+ """
156
+ And step "seam.naive" is prompted with "- Won't fix: subtract has no class"
157
+ And step "build.quality.correctness.reviewing" is prompted with "- Won't fix: subtract has no class"
158
+ When the process is driven
159
+ Then the process rests at "build.review.await-review"
160
+
161
+ Scenario: a contradicting standard blocks sign-off until the human picks one
162
+ Given a file ".gtd/STANDARDS.md" with:
163
+ """
164
+ # Standards
165
+
166
+ - Calculators are classes.
167
+ """
168
+ And the working tree is committed as "chore: keep a standard"
169
+ And a file "TODO.md" with:
170
+ """
171
+ # Add subtract
172
+
173
+ Please add subtract(a, b).
174
+ """
175
+ And judge step "build.review.standards.judge" answers:
176
+ """
177
+ [{"id":"standard-1","answer":"yes","p":0.9},{"id":"standard-1.contradicts-1","answer":"yes","p":0.8}]
178
+ """
179
+ And judge step "build.review.triage" answers:
180
+ """
181
+ [{"id":"answer-1","answer":"praise","p":0.9}]
182
+ """
183
+ When the process is driven
184
+ And the text "- [ ] _your answer_" in ".gtd/REVIEW.md" is replaced with "- [x] Calculators are plain functions."
185
+ And gtd lands
186
+ And the process is driven
187
+ Then the process rests at "build.review.await-review"
188
+ And ".gtd/STANDARDS.md" contains:
189
+ """
190
+ - Calculators are classes.
191
+
192
+ ## Undecided
193
+
194
+ - Calculators are plain functions. (re: subtract has no class)
195
+ - contradicts: Calculators are classes.
196
+ """
197
+ And ".gtd/REVIEW.md" contains "## Standard conflict "
198
+ And ".gtd/REVIEW.md" contains:
199
+ """
200
+ New: Calculators are plain functions. (re: subtract has no class)
201
+
202
+ Old:
203
+
204
+ - Calculators are classes.
205
+
206
+ Tick exactly one:
207
+
208
+ - [ ] keep the new one (drop the old)
209
+ - [ ] keep the old one (discard the new)
210
+ - [ ] _your answer_
211
+ """
212
+ And ".gtd/REVIEW.md" does not contain "## subtract has no class"
213
+ And landing is refused with "answer-completeness: 1 note(s) in .gtd/REVIEW.md not answered"
214
+ When the text "- [ ] keep the new one (drop the old)" in ".gtd/REVIEW.md" is replaced with "- [x] keep the new one (drop the old)"
215
+ And gtd lands
216
+ And the process is driven
217
+ Then the process rests at "idle"
218
+ And ".gtd/STANDARDS.md" contains "- Calculators are plain functions. (re: subtract has no class)"
219
+ And ".gtd/STANDARDS.md" does not contain "Calculators are classes."
220
+ And ".gtd/STANDARDS.md" does not contain "Undecided"
221
+
222
+ Scenario: keeping the old standard discards the new one
223
+ Given a file ".gtd/STANDARDS.md" with:
224
+ """
225
+ # Standards
226
+
227
+ - Calculators are classes.
228
+ """
229
+ And the working tree is committed as "chore: keep a standard"
230
+ And a file "TODO.md" with:
231
+ """
232
+ # Add subtract
233
+
234
+ Please add subtract(a, b).
235
+ """
236
+ And judge step "build.review.standards.judge" answers:
237
+ """
238
+ [{"id":"standard-1","answer":"yes","p":0.9},{"id":"standard-1.contradicts-1","answer":"yes","p":0.8}]
239
+ """
240
+ When the process is driven
241
+ And the text "- [ ] won't fix" in ".gtd/REVIEW.md" is replaced with "- [x] won't fix"
242
+ And gtd lands
243
+ And the process is driven
244
+ And the text "- [ ] keep the old one (discard the new)" in ".gtd/REVIEW.md" is replaced with "- [x] keep the old one (discard the new)"
245
+ And gtd lands
246
+ And the process is driven
247
+ Then the process rests at "idle"
248
+ And ".gtd/STANDARDS.md" contains:
249
+ """
250
+ # Standards
251
+
252
+ Kept from your review answers for every agent turn. One standard per bullet.
253
+
254
+ - Calculators are classes.
255
+ """
256
+ And ".gtd/STANDARDS.md" does not contain "Won't fix"
257
+
258
+ Scenario: an own answer to a conflict replaces both with its wording
259
+ Given a file ".gtd/STANDARDS.md" with:
260
+ """
261
+ # Standards
262
+
263
+ - Calculators are classes.
264
+ """
265
+ And the working tree is committed as "chore: keep a standard"
266
+ And a file "TODO.md" with:
267
+ """
268
+ # Add subtract
269
+
270
+ Please add subtract(a, b).
271
+ """
272
+ And judge step "build.review.standards.judge" answers:
273
+ """
274
+ [{"id":"standard-1","answer":"yes","p":0.9},{"id":"standard-1.contradicts-1","answer":"yes","p":0.8}]
275
+ """
276
+ When the process is driven
277
+ And the text "- [ ] won't fix" in ".gtd/REVIEW.md" is replaced with "- [x] won't fix"
278
+ And gtd lands
279
+ And the process is driven
280
+ And the text "- [ ] _your answer_" in ".gtd/REVIEW.md" is replaced with "- [x] Calculators with state are classes."
281
+ And gtd lands
282
+ And the process is driven
283
+ Then the process rests at "idle"
284
+ And ".gtd/STANDARDS.md" contains "- Calculators with state are classes."
285
+ And ".gtd/STANDARDS.md" does not contain "- Calculators are classes."
286
+ And ".gtd/STANDARDS.md" does not contain "Won't fix"
@@ -59,6 +59,10 @@ Feature: the review door fixes its notes in the same process
59
59
  """
60
60
 
61
61
  Scenario: a won't-fix signs off, and no review is left for fix
62
+ Given judge step "build.review.standards.judge" answers:
63
+ """
64
+ [{"id":"standard-1","answer":"no","p":0.9}]
65
+ """
62
66
  When gtd opens door "review" with "HEAD~1"
63
67
  And the process is driven
64
68
  And the text "- [ ] won't fix" in ".gtd/REVIEW.md" is replaced with "- [x] won't fix"
@@ -96,7 +100,11 @@ Feature: the review door fixes its notes in the same process
96
100
  And ".gtd/REVIEW.orig.md" does not exist
97
101
 
98
102
  Scenario: an own answer asking for a change is fixed in place, then reviewed again
99
- Given judge step "build.review.triage" answers:
103
+ Given judge step "build.review.standards.judge" answers:
104
+ """
105
+ [{"id":"standard-1","answer":"no","p":0.9}]
106
+ """
107
+ And judge step "build.review.triage" answers:
100
108
  """
101
109
  [{"id":"answer-1","answer":"edit","p":0.9}]
102
110
  """
@@ -123,7 +131,8 @@ Feature: the review door fixes its notes in the same process
123
131
  Then the process rests at "build.review.await-review"
124
132
  And the commit subjects end with:
125
133
  """
126
- gtd(human): build.review.await-review → build.review.triage
134
+ gtd(human): build.review.await-review → build.review.standards.judge
135
+ gtd(judge): build.review.standards.judge → build.review.triage
127
136
  gtd(judge): build.review.triage → build.review.closing
128
137
  gtd(check): build.review.closing → build.review.collecting
129
138
  gtd(agent): build.review.collecting → re-unwind
@@ -0,0 +1,154 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest"
2
+
3
+ const state = vi.hoisted(() => ({
4
+ files: {} as Record<string, string | undefined>,
5
+ vars: { standardMinP: "0.8" } as Record<string, string>,
6
+ answers: {} as Record<string, { answer: string; p: number } | undefined>,
7
+ judged: [] as string[][],
8
+ written: {} as Record<string, string>,
9
+ }))
10
+
11
+ vi.mock("../../flows/index.js", async (original) => ({
12
+ ...(await original<typeof import("../../flows/index.js")>()),
13
+ read: (path: string) => state.files[path],
14
+ vars: state.vars,
15
+ judge: async (_: string, spec: { questions: { id: string }[] }) => {
16
+ state.judged.push(spec.questions.map((q) => q.id))
17
+ return { answers: state.answers, truncated: [] }
18
+ },
19
+ run: async (_: string, script: string) => {
20
+ const path = /> '([^']+)'/.exec(script)?.[1] ?? ""
21
+ const content = /printf '%s' '((?:[^']|'\\'')*)'/.exec(script)?.[1] ?? ""
22
+ state.written[path] = content.replaceAll("'\\''", "'")
23
+ },
24
+ }))
25
+
26
+ import type { ReviewDecision } from "../../steering/index.js"
27
+ import { learnStandards } from "./learn.js"
28
+
29
+ const STANDARDS = ".gtd/STANDARDS.md"
30
+
31
+ const file = (...lines: string[]): string =>
32
+ [
33
+ "# Standards",
34
+ "",
35
+ "Kept from your review answers for every agent turn. One standard per bullet.",
36
+ "",
37
+ ...lines,
38
+ "",
39
+ ].join("\n")
40
+
41
+ const answer = (anchor: string, text: string): ReviewDecision => ({
42
+ anchor,
43
+ body: "",
44
+ choice: "answer",
45
+ text,
46
+ })
47
+
48
+ const yes = { answer: "yes", p: 0.9 }
49
+ const no = { answer: "no", p: 0.9 }
50
+ const kept = () => state.written[STANDARDS]
51
+
52
+ beforeEach(() => {
53
+ state.files = { ".gtd/REVIEW.md": "# Review\n" }
54
+ state.vars.standardMinP = "0.8"
55
+ state.answers = {}
56
+ state.judged = []
57
+ state.written = {}
58
+ })
59
+
60
+ describe("learnStandards", () => {
61
+ it("asks a later candidate about each earlier one, and holds a contradiction for the human", async () => {
62
+ state.answers = { "standard-1": yes, "standard-2": yes, "standard-2.contradicts-new-1": yes }
63
+ await learnStandards([answer("A", "Use classes."), answer("B", "Never use classes.")])
64
+ expect(state.judged[0]).toContain("standard-2.contradicts-new-1")
65
+ expect(kept()).toBe(
66
+ file(
67
+ "- Use classes. (re: A)",
68
+ "",
69
+ "## Undecided",
70
+ "",
71
+ "- Never use classes. (re: B)",
72
+ " - contradicts: Use classes. (re: A)",
73
+ ),
74
+ )
75
+ })
76
+
77
+ it("raises a contradiction ruled out below the threshold", async () => {
78
+ state.files[STANDARDS] = file("- Old.")
79
+ state.answers = { "standard-1": yes, "standard-1.contradicts-1": { answer: "no", p: 0.5 } }
80
+ await learnStandards([answer("A", "New.")])
81
+ expect(kept()).toBe(
82
+ file("- Old.", "", "## Undecided", "", "- New. (re: A)", " - contradicts: Old."),
83
+ )
84
+ })
85
+
86
+ it("keeps one the judge confidently calls compatible", async () => {
87
+ state.files[STANDARDS] = file("- Old.")
88
+ state.answers = { "standard-1": yes, "standard-1.contradicts-1": no }
89
+ await learnStandards([answer("A", "New.")])
90
+ expect(kept()).toBe(file("- Old.", "- New. (re: A)"))
91
+ })
92
+
93
+ it("asks no judge when standardMinP is blank", async () => {
94
+ state.vars.standardMinP = ""
95
+ await learnStandards([answer("A", "New.")])
96
+ expect(state.judged).toEqual([])
97
+ expect(state.written).toEqual({})
98
+ })
99
+
100
+ it("drops a replaced standard from another open conflict", async () => {
101
+ state.files[STANDARDS] = file(
102
+ "- O.",
103
+ "",
104
+ "## Undecided",
105
+ "",
106
+ "- A.",
107
+ " - contradicts: O.",
108
+ "- B.",
109
+ " - contradicts: O.",
110
+ )
111
+ // The conflict note's title hashes its new standard, "A.".
112
+ await learnStandards([
113
+ { anchor: "Standard conflict 01248e26", body: "", choice: "fix", text: "" },
114
+ ])
115
+ expect(kept()).toBe(file("- A.", "", "## Undecided", "", "- B."))
116
+ })
117
+ })
118
+
119
+ describe("learnStandards, from a won't-fix", () => {
120
+ it("states the finding, not gtd's banner", async () => {
121
+ state.answers = { "standard-1": yes }
122
+ await learnStandards([
123
+ {
124
+ anchor: "Slow loop",
125
+ // As the review parser hands it over: paragraphs joined by a space.
126
+ body: "_The fixer left this unresolved._ The loop is quadratic.",
127
+ choice: "wontfix",
128
+ text: "",
129
+ },
130
+ ])
131
+ expect(kept()).toBe(file("- Won't fix: Slow loop — The loop is quadratic."))
132
+ })
133
+
134
+ it("replaces an old standard hand-edited in case only", async () => {
135
+ state.files[STANDARDS] = file(
136
+ "- use classes.",
137
+ "",
138
+ "## Undecided",
139
+ "",
140
+ "- A.",
141
+ " - contradicts: Use classes.",
142
+ )
143
+ await learnStandards([
144
+ { anchor: "Standard conflict 01248e26", body: "", choice: "fix", text: "" },
145
+ ])
146
+ expect(kept()).toBe(file("- A."))
147
+ })
148
+
149
+ it("does not learn again what a formatter rewrote", async () => {
150
+ state.files[STANDARDS] = file("- Prefer _named_ exports. (re: A)")
151
+ await learnStandards([answer("A", "Prefer *named* exports.")])
152
+ expect(state.judged).toEqual([])
153
+ })
154
+ })