@pmelab/gtd 24.3.0 → 25.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 (74) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +28 -22
  3. package/dist/chunks/{reserved-DJwU5C4z.mjs → reserved-6pAgFGgB.mjs} +119 -135
  4. package/dist/chunks/{steering-D-PvRC5W.mjs → steering-DVILanVf.mjs} +16 -5
  5. package/dist/flows.mjs +9 -1
  6. package/dist/gtd.bundle.mjs +780 -813
  7. package/dist/steering.mjs +2 -2
  8. package/dist/testkit.mjs +666 -161
  9. package/dist/workflow-kit.mjs +55 -96
  10. package/dist/workflows/feature.mjs +171 -76
  11. package/dist/workflows/fix.mjs +2 -2
  12. package/dist/workflows/review.mjs +1 -1
  13. package/dist/workflows.mjs +353 -310
  14. package/package.json +1 -2
  15. package/src/cli/Program.test.ts +3 -8
  16. package/src/cli/Program.ts +1 -8
  17. package/src/edge/Edge.test.ts +5 -2
  18. package/src/edge/Edge.ts +3 -4
  19. package/src/emit/index.ts +1 -0
  20. package/src/flows/scripts.ts +6 -0
  21. package/src/platform/Git.test.ts +6 -9
  22. package/src/replay/Replay.ts +2 -1
  23. package/src/steering/FORMATS.md +1 -1
  24. package/src/steering/SteeringFormat.test.ts +9 -1
  25. package/src/steering/SteeringFormat.ts +7 -0
  26. package/src/steering/contract.test.ts +3 -0
  27. package/src/steering/index.ts +1 -0
  28. package/src/steering/review.test.ts +30 -0
  29. package/src/steering/review.ts +8 -2
  30. package/src/testkit/EmittedScriptRecognizer.test.ts +29 -30
  31. package/src/testkit/EmittedScriptRecognizer.ts +148 -51
  32. package/src/testkit/EmittedScripts.test.ts +166 -0
  33. package/src/testkit/FlowScripts.test.ts +242 -0
  34. package/src/testkit/FlowScripts.ts +326 -0
  35. package/src/testkit/GitTiers.ts +140 -49
  36. package/src/testkit/InMemRepo.ts +35 -2
  37. package/src/testkit/Layers.ts +10 -2
  38. package/src/testkit/TestRepo.ts +4 -1
  39. package/src/testkit/index.ts +1 -1
  40. package/src/workflow/discovery.test.ts +147 -0
  41. package/src/workflow/discovery.ts +5 -0
  42. package/src/workflow/fingerprint.test.ts +20 -4
  43. package/src/workflow/fingerprint.ts +8 -9
  44. package/src/workflow/index.ts +1 -6
  45. package/src/workflow/load.test.ts +5 -19
  46. package/src/workflow/load.ts +94 -10
  47. package/src/workflow-kit/review.test.ts +84 -0
  48. package/src/workflow-kit/review.ts +9 -6
  49. package/src/workflow-kit/scenarios.test.ts +116 -0
  50. package/src/workflow-kit/scenarios.ts +53 -113
  51. package/src/workflows/feature/feature.ts +2 -5
  52. package/src/workflows/feature/packages.test.ts +17 -34
  53. package/src/workflows/feature/packages.ts +13 -52
  54. package/src/workflows/feature/planning.ts +1 -1
  55. package/src/workflows/feature/review.test.ts +233 -54
  56. package/src/workflows/feature/review.ts +178 -41
  57. package/src/workflows/feature/steps.test.ts +12 -0
  58. package/src/workflows/feature/steps.ts +8 -1
  59. package/src/workflows/index.ts +0 -2
  60. package/src/workflows/shared/access.test.ts +4 -2
  61. package/src/workflows/shared/access.ts +1 -0
  62. package/src/workflows/shared/health.test.ts +1 -21
  63. package/src/workflows/shared/health.ts +4 -39
  64. package/src/workflows/shared/options.test.ts +8 -0
  65. package/src/workflows/shared/options.ts +1 -1
  66. package/src/workflows/shared/steps.ts +18 -11
  67. package/src/workflows/shared/tail.test.ts +1 -41
  68. package/src/workflows/shared/tail.ts +17 -41
  69. package/src/workflows/shared/text.fixture.ts +1 -0
  70. package/src/workflows/shared/text.test.ts +129 -41
  71. package/src/workflows/shared/text.ts +111 -75
  72. package/src/workflows/shared/vars.ts +1 -1
  73. package/src/workflows/shared/scenarios.test.ts +0 -256
  74. package/src/workflows/shared/scenarios.ts +0 -35
@@ -5,7 +5,8 @@ import {
5
5
  type JudgeAnswer,
6
6
  type StepRequest,
7
7
  } from "../../flows/index.js"
8
- import { review, type ReviewOutcome } from "./review.js"
8
+ import { review, severityOf, type ReviewOutcome } from "./review.js"
9
+ import type { ReviewNote } from "../../steering/index.js"
9
10
  import * as bundled from "../index.js"
10
11
  import { fixtureContext } from "../shared/text.fixture.js"
11
12
 
@@ -38,6 +39,8 @@ interface Drive {
38
39
  readonly after?: string
39
40
  /** The REVIEW.md each `review.reviewing` turn writes, in order; also raises the review-turn stop to one past the last. */
40
41
  readonly reviewDocs?: readonly (string | undefined)[]
42
+ /** Let the run pass the second gate, stopping at the first range read after it. */
43
+ readonly passSecondGate?: boolean
41
44
  }
42
45
 
43
46
  interface Run {
@@ -45,6 +48,11 @@ interface Run {
45
48
  readonly prompts: Map<string, string>
46
49
  readonly result: ReviewOutcome | "stopped"
47
50
  readonly judged: readonly string[]
51
+ /** `acceptClean` of each `review.await-review` gate, in order. */
52
+ readonly gates: readonly boolean[]
53
+ readonly gateMessages: readonly string[]
54
+ /** Every `changesSince` argument, in order. */
55
+ readonly since: readonly string[]
48
56
  }
49
57
 
50
58
  /** Run `review` against a scripted context; a second pass at the gate or a fresh review stops the run. */
@@ -52,6 +60,9 @@ const drive = async (d: Drive): Promise<Run> => {
52
60
  const log: string[] = []
53
61
  const prompts = new Map<string, string>()
54
62
  const judged: string[] = []
63
+ const gates: boolean[] = []
64
+ const gateMessages: string[] = []
65
+ const since: string[] = []
55
66
  const files = new Map<string, string>([[REVIEW, d.after ?? doc(d.notes)]])
56
67
  let steps = 0
57
68
  let nitsFixed = false
@@ -65,7 +76,7 @@ const drive = async (d: Drive): Promise<Run> => {
65
76
  if (written !== undefined) files.set(REVIEW, written)
66
77
  },
67
78
  "review.await-review": () => {
68
- if (++awaits > 1) throw new Stop()
79
+ if (++awaits > (d.passSecondGate ? 2 : 1)) throw new Stop()
69
80
  },
70
81
  "review.closing": () => void files.delete(REVIEW),
71
82
  "review.collecting": () => void files.set(REQUIREMENTS, "## Concern"),
@@ -73,6 +84,14 @@ const drive = async (d: Drive): Promise<Run> => {
73
84
  nitsFixed = true
74
85
  },
75
86
  }
87
+ const record = (request: StepRequest): void => {
88
+ if (request.kind === "human" && request.name === "review.await-review") {
89
+ gates.push(request.options.acceptClean === true)
90
+ gateMessages.push(request.options.message ?? "")
91
+ }
92
+ if (request.kind === "agent") prompts.set(request.name, request.prompt)
93
+ if (request.kind === "judge") judged.push(...request.questions.map((q) => q.id))
94
+ }
76
95
  const context = fixtureContext(
77
96
  { vars: d.vars ?? {} },
78
97
  {
@@ -80,10 +99,9 @@ const drive = async (d: Drive): Promise<Run> => {
80
99
  steps++
81
100
  if (request.kind === "restart") return Promise.resolve()
82
101
  log.push(request.name)
83
- if (request.kind === "agent") prompts.set(request.name, request.prompt)
102
+ record(request)
84
103
  effects[request.name]?.()
85
104
  if (request.kind !== "judge") return Promise.resolve()
86
- judged.push(...request.questions.map((q) => q.id))
87
105
  return Promise.resolve({ answers: d.answers ?? {}, truncated: d.truncated ?? [] })
88
106
  },
89
107
  refuse: (message: string): never => {
@@ -92,12 +110,16 @@ const drive = async (d: Drive): Promise<Run> => {
92
110
  pushScope: () => undefined,
93
111
  popScope: () => undefined,
94
112
  read: (path) => files.get(path),
95
- changesSince: (): readonly Change[] => [
96
- { path: REVIEW, status: "modified", before: BEFORE, after: files.get(REVIEW) },
97
- ...(nitsFixed
98
- ? [{ path: "src/calc.ts", status: "modified" as const, before: "a", after: "b" }]
99
- : []),
100
- ],
113
+ changesSince: (from: string): readonly Change[] => {
114
+ since.push(from)
115
+ if (d.passSecondGate && awaits === 2) throw new Stop()
116
+ return [
117
+ { path: REVIEW, status: "modified", before: BEFORE, after: files.get(REVIEW) },
118
+ ...(nitsFixed
119
+ ? [{ path: "src/calc.ts", status: "modified" as const, before: "a", after: "b" }]
120
+ : []),
121
+ ]
122
+ },
101
123
  head: () => `c${steps}`,
102
124
  start: () => "c0",
103
125
  },
@@ -110,7 +132,7 @@ const drive = async (d: Drive): Promise<Run> => {
110
132
  if (!(error instanceof Stop)) throw error
111
133
  result = "stopped"
112
134
  }
113
- return { log, prompts, result, judged }
135
+ return { log, prompts, result, judged, gates, gateMessages, since }
114
136
  }
115
137
 
116
138
  const verdict = (answer: string, p = 0.9): JudgeAnswer => ({ answer, p })
@@ -179,7 +201,18 @@ describe("review verdict routing", () => {
179
201
 
180
202
  it("judges one note per added note, as one judge rest", async () => {
181
203
  const run = await drive({ notes: ["add — a", "sub — b", "mul"] })
182
- expect(run.judged).toEqual(["note-1", "note-2"])
204
+ expect(run.judged).toEqual([
205
+ "note-1",
206
+ "note-1.security",
207
+ "note-1.reversibility",
208
+ "note-1.blastRadius",
209
+ "note-1.newConcept",
210
+ "note-2",
211
+ "note-2.security",
212
+ "note-2.reversibility",
213
+ "note-2.blastRadius",
214
+ "note-2.newConcept",
215
+ ])
183
216
  expect(run.log.filter((n) => n === "review.triage")).toHaveLength(1)
184
217
  })
185
218
 
@@ -337,49 +370,195 @@ describe("the default quality lenses", () => {
337
370
  })
338
371
  })
339
372
 
340
- describe("scenario wording in review's fix turns", () => {
341
- const frozen = { at: "h1", texts: { "e2e/a.feature": "Given a" } }
342
- const risky = doc(["Risk: drops the carry", "sub"])
373
+ describe("severityOf", () => {
374
+ const note = (forced = false): ReviewNote => ({
375
+ id: "n",
376
+ kind: "pointer",
377
+ anchor: "a",
378
+ before: "",
379
+ text: "t",
380
+ forced,
381
+ })
382
+ const ans = (o: Partial<Record<string, string>> = {}, p = 0.9) => {
383
+ const base: Record<string, string> = {
384
+ security: "no",
385
+ reversibility: "two-way",
386
+ blastRadius: "local",
387
+ newConcept: "no",
388
+ ...o,
389
+ }
390
+ return Object.fromEntries(Object.entries(base).map(([k, v]) => [`n.${k}`, { answer: v, p }]))
391
+ }
392
+ const j = (answers: ReturnType<typeof ans>, truncated: string[] = []) => ({ answers, truncated })
393
+
394
+ it("security or one-way is critical and names each answer", () => {
395
+ expect(severityOf(note(), j(ans({ security: "yes" })), 0.7)).toMatchObject({
396
+ tier: "critical",
397
+ reason: "critical: security",
398
+ })
399
+ expect(
400
+ severityOf(note(), j(ans({ security: "yes", reversibility: "one-way" })), 0.7),
401
+ ).toMatchObject({ tier: "critical", reason: "critical: security, one-way door", rank: 0 })
402
+ })
403
+
404
+ it("contract or new concept is should; neither is could", () => {
405
+ expect(severityOf(note(), j(ans({ blastRadius: "contract" })), 0.7).tier).toBe("should")
406
+ expect(severityOf(note(), j(ans({ newConcept: "yes" })), 0.7)).toMatchObject({
407
+ tier: "should",
408
+ reason: "should: new concept",
409
+ rank: 1,
410
+ })
411
+ expect(severityOf(note(), j(ans()), 0.7)).toMatchObject({ tier: "could", rank: 2 })
412
+ })
413
+
414
+ it("unsure is critical: unanswered, unrecognised, cut, low p, blank floor", () => {
415
+ const a = ans()
416
+ delete (a as Record<string, unknown>)["n.security"]
417
+ for (const x of [
418
+ severityOf(note(), j(a), 0.7),
419
+ severityOf(note(), j(ans({ security: "unsure" })), 0.7),
420
+ severityOf(note(), j(ans(), ["n"]), 0.7),
421
+ severityOf(note(), j(ans({}, 0.5)), 0.7),
422
+ severityOf(note(), j(ans()), Infinity),
423
+ ])
424
+ expect(x).toMatchObject({ tier: "critical", reason: "critical: judge unsure" })
425
+ })
426
+
427
+ it("forced is critical regardless of answers", () => {
428
+ expect(severityOf(note(true), j({}), 0.7)).toMatchObject({
429
+ tier: "critical",
430
+ reason: "critical: forced by `!`",
431
+ })
432
+ })
433
+ })
434
+
435
+ describe("severity routing", () => {
436
+ const sev = (id: string, a: Record<string, string>): Record<string, JudgeAnswer> =>
437
+ Object.fromEntries(Object.entries(a).map(([k, v]) => [`${id}.${k}`, verdict(v)]))
438
+ const could = {
439
+ security: "no",
440
+ reversibility: "two-way",
441
+ blastRadius: "local",
442
+ newConcept: "no",
443
+ }
444
+
445
+ it("a forced note is asked nothing, not even the verdict, and is a critical edit", async () => {
446
+ const run = await drive({ notes: ["! rename it — now", "sub"] })
447
+ expect(run.judged.filter((id) => id.startsWith("note-1"))).toEqual([])
448
+ expect(run.log).toContain("review.collecting")
449
+ expect(run.prompts.get("review.collecting")).toContain("(critical: forced by `!`)")
450
+ })
451
+
452
+ it("a confident could edit defers and, alone, returns to the review gate", async () => {
453
+ const run = await drive({
454
+ notes: ["add — quote", "sub", "mul"],
455
+ answers: { "note-1": verdict("edit"), ...sev("note-1", could) },
456
+ })
457
+ expect(run.log).toEqual([
458
+ "review.reviewing",
459
+ "review.await-review",
460
+ "review.triage",
461
+ "review.defer",
462
+ "review.await-review",
463
+ ])
464
+ })
465
+
466
+ it("severity answers on a nit leave it a nit", async () => {
467
+ const run = await drive({
468
+ notes: ["add — typo", "sub", "mul"],
469
+ answers: { "note-1": verdict("nit"), ...sev("note-1", { reversibility: "one-way" }) },
470
+ })
471
+ expect(run.log).toContain("review.fix.nits.fixing")
472
+ expect(run.log).not.toContain("review.collecting")
473
+ })
474
+
475
+ it("edits are captured critical before should, doors first, each labelled", async () => {
476
+ const run = await drive({
477
+ notes: ["add — plain", "sub — concept", "mul — door"],
478
+ answers: {
479
+ "note-1": verdict("edit"),
480
+ ...sev("note-1", { ...could, security: "yes" }),
481
+ ...sev("note-2", { ...could, newConcept: "yes" }),
482
+ ...sev("note-3", { ...could, reversibility: "one-way" }),
483
+ },
484
+ })
485
+ const capture = run.prompts.get("review.collecting") ?? ""
486
+ const at = (id: string): number => capture.indexOf(`- ${id} —`)
487
+ expect(at("note-3")).toBeLessThan(at("note-1"))
488
+ expect(at("note-1")).toBeLessThan(at("note-2"))
489
+ expect(capture).toContain("(critical: one-way door)")
490
+ expect(capture).toContain("(should: new concept)")
491
+ expect(capture).toContain("(critical: security)")
492
+ })
493
+
494
+ it("a round of only forced notes judges nothing and tiers each critical", async () => {
495
+ const run = await drive({ notes: ["! rename it", "! drop it", "mul"] })
496
+ expect(run.log).not.toContain("review.triage")
497
+ expect(run.judged).toEqual([])
498
+ expect(run.prompts.get("review.collecting")).toContain("(critical: forced by `!`)")
499
+ expect(run.prompts.get("review.collecting")).not.toContain("! rename")
500
+ })
501
+
502
+ it("a note opening with != is not forced and keeps its text", async () => {
503
+ const run = await drive({ notes: ["!= should be ===", "sub"] })
504
+ expect(run.judged).toContain("note-1.security")
505
+ })
506
+
507
+ it("the gate after a deferral accepts a clean land and lists the deferred notes", async () => {
508
+ const run = await drive({
509
+ notes: ["add — quote", "sub", "mul"],
510
+ answers: { "note-1": verdict("edit"), ...sev("note-1", could) },
511
+ })
512
+ expect(run.gates).toEqual([false, true])
513
+ expect(run.gateMessages[1]).toContain("NOT acted on")
514
+ expect(run.gateMessages[1]).toContain("note-1")
515
+ expect(run.gateMessages[0]).not.toContain("NOT acted on")
516
+ })
517
+
518
+ it("a later gate does not accept a clean land after a deferral round with a nit", async () => {
519
+ const run = await drive({
520
+ notes: ["add — quote", "sub — typo", "mul"],
521
+ answers: {
522
+ "note-1": verdict("edit"),
523
+ ...sev("note-1", could),
524
+ "note-2": verdict("nit"),
525
+ },
526
+ reviewDocs: [undefined, doc(["add", "sub"])],
527
+ })
528
+ expect(run.log).toContain("review.defer")
529
+ expect(run.log.at(-1)).toBe("review.await-review")
530
+ expect(run.gates).toEqual([false, false])
531
+ })
532
+
533
+ it("a question and a could note in one round: the gate's base is the answering commit", async () => {
534
+ const run = await drive({
535
+ notes: ["add — why?", "sub — quote", "mul"],
536
+ answers: {
537
+ "note-1": verdict("question"),
538
+ "note-2": verdict("edit"),
539
+ ...sev("note-2", could),
540
+ },
541
+ passSecondGate: true,
542
+ })
543
+ expect(run.log).toEqual([
544
+ "review.reviewing",
545
+ "review.await-review",
546
+ "review.triage",
547
+ "review.answer-review-questions",
548
+ "review.defer",
549
+ "review.await-review",
550
+ ])
551
+ // c4 is the answering commit, not the defer commit (c5) or the round (c2)
552
+ expect(run.since.at(-1)).toBe("c4")
553
+ })
343
554
 
344
- it("a green-keeping fix after a risk fix that rewrites a scenario stops at review.scenario-wording", async () => {
345
- const log: string[] = []
346
- let red = false
347
- let drifted = false
348
- installContext(
349
- fixtureContext(
350
- {},
351
- {
352
- step: (request: StepRequest) => {
353
- if (request.kind === "restart") return Promise.resolve()
354
- log.push(request.name)
355
- if (request.name === "health.check") red = !red && !drifted
356
- if (request.name === "fix") drifted = true
357
- if (request.name === "scenario-wording") throw new Stop()
358
- return Promise.resolve()
359
- },
360
- pushScope: () => undefined,
361
- popScope: () => undefined,
362
- read: (path) =>
363
- path === REVIEW
364
- ? risky
365
- : path === "e2e/a.feature"
366
- ? drifted
367
- ? "Given b"
368
- : "Given a"
369
- : undefined,
370
- changes: (): readonly Change[] =>
371
- red
372
- ? [{ path: ".gtd/FEEDBACK.md", status: "added", before: undefined, after: "red" }]
373
- : [],
374
- matches: (path, pattern) => path === pattern,
375
- head: () => "h2",
376
- start: () => "h0",
377
- },
378
- ),
379
- )
380
- await review("base", undefined, frozen).catch((e: unknown) => {
381
- if (!(e instanceof Stop)) throw e
555
+ it("a blank reviewSeverityMinP makes every confident could critical", async () => {
556
+ const run = await drive({
557
+ notes: ["add — quote", "sub", "mul"],
558
+ answers: { "note-1": verdict("edit"), ...sev("note-1", could) },
559
+ vars: { reviewSeverityMinP: "" },
382
560
  })
383
- expect(log.slice(-2)).toEqual(["fix", "scenario-wording"])
561
+ expect(run.log).toContain("review.collecting")
562
+ expect(run.log).not.toContain("review.defer")
384
563
  })
385
564
  })
@@ -1,8 +1,15 @@
1
- import { answered, judge, numeric, vars, type JudgeQuestion } from "../../flows/index.js"
1
+ import {
2
+ answered,
3
+ head,
4
+ judge,
5
+ numeric,
6
+ vars,
7
+ type JudgeQuestion,
8
+ type Judgment,
9
+ } from "../../flows/index.js"
2
10
  import type { ReviewNote } from "../../steering/index.js"
3
11
  import * as kit from "../../workflow-kit/index.js"
4
12
  import { healthy, type EscalationCount } from "../shared/health.js"
5
- import { guarded, type FrozenScenarios } from "../shared/scenarios.js"
6
13
  import {
7
14
  awaitReview,
8
15
  fixCheck,
@@ -14,8 +21,7 @@ import {
14
21
  } from "../shared/steps.js"
15
22
  import { closeReview, tail } from "../shared/tail.js"
16
23
  import * as t from "../shared/text.js"
17
- import type { BuiltPlan } from "./packages.js"
18
- import { answerReviewQuestions, collecting, fixNits } from "./steps.js"
24
+ import { answerReviewQuestions, collecting, deferNotes, fixNits } from "./steps.js"
19
25
 
20
26
  export type { ReviewOutcome } from "../../workflow-kit/index.js"
21
27
 
@@ -30,33 +36,124 @@ const verdictQuestion = (note: ReviewNote): JudgeQuestion => ({
30
36
  "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.",
31
37
  })
32
38
 
39
+ type Tier = "critical" | "should" | "could"
40
+
41
+ const SEVERITY_ANSWERS: Readonly<Record<string, readonly string[]>> = {
42
+ security: ["yes", "no"],
43
+ reversibility: ["one-way", "two-way"],
44
+ blastRadius: ["local", "module", "contract"],
45
+ newConcept: ["yes", "no"],
46
+ }
47
+
48
+ interface Severity {
49
+ readonly tier: Tier
50
+ readonly reason: string
51
+ /** Capture order: 0 one-way door, 1 new concept, 2 everything else. */
52
+ readonly rank: 0 | 1 | 2
53
+ }
54
+
33
55
  /**
34
- * One judge rest, one verdict per note. Dismissing a note is the risky
35
- * direction, so only a confident non-`edit` verdict on uncut evidence counts;
36
- * a cut, unanswered or below-floor note is an `edit`, and a blank floor makes
37
- * every note one.
56
+ * Deferring is the risky direction: anything unanswered, unrecognised, cut or
57
+ * below `minP` is `critical`, never `could`.
38
58
  */
39
- const triage = async (notes: readonly ReviewNote[]): Promise<ReadonlyMap<string, kit.Verdict>> => {
40
- const { answers, truncated } = await judge("review.triage", {
41
- questions: notes.map(verdictQuestion),
42
- evidence: Object.fromEntries(
43
- notes.map((n) => [
44
- n.id,
45
- `Anchor: ${n.anchor}\nReviewer's text: ${n.before}\nHuman's text: ${n.text}`,
46
- ]),
47
- ),
48
- message: t.buildReviewTriageMessage(),
49
- label: "Judging each note",
50
- })
51
- const minP = numeric(vars.reviewNoteActionable, Infinity)
52
- return new Map(
53
- notes.map((n): [string, kit.Verdict] => {
54
- const verdict = VERDICTS.find((v) => v !== "edit" && answered(answers[n.id], v, minP))
55
- return [n.id, truncated.includes(n.id) || verdict === undefined ? "edit" : verdict]
56
- }),
57
- )
59
+ export const severityOf = (note: ReviewNote, judgment: Judgment, minP: number): Severity => {
60
+ const ans = (q: string) => judgment.answers[`${note.id}.${q}`]
61
+ const a = (q: string): string | undefined => ans(q)?.answer
62
+ const rank = (): 0 | 1 | 2 =>
63
+ a("reversibility") === "one-way" ? 0 : a("newConcept") === "yes" ? 1 : 2
64
+ if (note.forced) return { tier: "critical", reason: "critical: forced by `!`", rank: 2 }
65
+ const unsure =
66
+ judgment.truncated.includes(note.id) ||
67
+ Object.entries(SEVERITY_ANSWERS).some(
68
+ ([q, allowed]) => !allowed.includes(a(q) ?? "") || !((ans(q)?.p ?? NaN) >= minP),
69
+ )
70
+ if (unsure) return { tier: "critical", reason: "critical: judge unsure", rank: 2 }
71
+ const critical = [
72
+ ...(a("security") === "yes" ? ["security"] : []),
73
+ ...(a("reversibility") === "one-way" ? ["one-way door"] : []),
74
+ ]
75
+ if (critical.length > 0)
76
+ return { tier: "critical", reason: `critical: ${critical.join(", ")}`, rank: rank() }
77
+ const should = [
78
+ ...(a("blastRadius") === "contract" ? ["contract"] : []),
79
+ ...(a("newConcept") === "yes" ? ["new concept"] : []),
80
+ ]
81
+ if (should.length > 0)
82
+ return { tier: "should", reason: `should: ${should.join(", ")}`, rank: rank() }
83
+ return { tier: "could", reason: "could", rank: 2 }
58
84
  }
59
85
 
86
+ const severityQuestions = (note: ReviewNote): JudgeQuestion[] => [
87
+ {
88
+ id: `${note.id}.security`,
89
+ primitive: "noul",
90
+ instructions: `Does the change the human asks for in note "${note.id}" (on "${note.anchor}") touch security — authentication, authorisation, secrets, untrusted input or data exposure?`,
91
+ criteria: "yes: it changes or could weaken a security property. no: it does not.",
92
+ },
93
+ {
94
+ id: `${note.id}.reversibility`,
95
+ primitive: "choice",
96
+ instructions: `Once the change in note "${note.id}" ships, can it be undone without breaking anyone who already depends on it?`,
97
+ criteria:
98
+ "one-way: a published API, file format, persisted data, protocol or other contract that cannot be taken back. two-way: can be reverted freely.",
99
+ },
100
+ {
101
+ id: `${note.id}.blastRadius`,
102
+ primitive: "choice",
103
+ instructions: `How far does the change in note "${note.id}" reach?`,
104
+ criteria:
105
+ "local: one function or file. module: a whole module or several files. contract: a public interface other code or users depend on.",
106
+ },
107
+ {
108
+ id: `${note.id}.newConcept`,
109
+ primitive: "noul",
110
+ instructions: `Does the change in note "${note.id}" introduce a concept new to this codebase — a new abstraction, entity or term? Judge from the evidence, never a glossary; reusing or extending an existing concept is not new.`,
111
+ criteria: "yes: a genuinely new concept. no: reuses or extends an existing one.",
112
+ },
113
+ ]
114
+
115
+ /**
116
+ * One judge rest: a verdict per unforced note (a forced note is asked
117
+ * nothing) plus four severity answers. Dismissing a note is the risky
118
+ * direction, so only a confident non-`edit` verdict on uncut evidence counts;
119
+ * a cut, unanswered or below-floor note is an `edit`. Severity answers on a
120
+ * non-`edit` note are ignored. A confident `could` edit becomes `defer`.
121
+ */
122
+ const triage =
123
+ (severities: Map<string, Severity>) =>
124
+ async (notes: readonly ReviewNote[]): Promise<ReadonlyMap<string, kit.Verdict>> => {
125
+ const asked = notes.filter((n) => !n.forced)
126
+ const judgment: Judgment =
127
+ asked.length === 0
128
+ ? { answers: {}, truncated: [] }
129
+ : await judge("review.triage", {
130
+ questions: asked.flatMap((n) => [verdictQuestion(n), ...severityQuestions(n)]),
131
+ evidence: Object.fromEntries(
132
+ asked.map((n) => [
133
+ n.id,
134
+ `Anchor: ${n.anchor}\nReviewer's text: ${n.before}\nHuman's text: ${n.text}`,
135
+ ]),
136
+ ),
137
+ message: t.buildReviewTriageMessage(),
138
+ label: "Judging each note",
139
+ })
140
+ const { answers, truncated } = judgment
141
+ const minP = numeric(vars.reviewNoteActionable, Infinity)
142
+ const severityMinP = numeric(vars.reviewSeverityMinP, Infinity)
143
+ severities.clear()
144
+ return new Map(
145
+ notes.map((n): [string, kit.Verdict] => {
146
+ const verdict = n.forced
147
+ ? undefined
148
+ : VERDICTS.find((v) => v !== "edit" && answered(answers[n.id], v, minP))
149
+ if (verdict !== undefined && !truncated.includes(n.id)) return [n.id, verdict]
150
+ const severity = severityOf(n, judgment, severityMinP)
151
+ severities.set(n.id, severity)
152
+ return [n.id, severity.tier === "could" ? "defer" : "edit"]
153
+ }),
154
+ )
155
+ }
156
+
60
157
  const isCode = (path: string): boolean => path !== ".gtd" && !path.startsWith(".gtd/")
61
158
 
62
159
  /**
@@ -68,26 +165,66 @@ const isCode = (path: string): boolean => path !== ".gtd" && !path.startsWith(".
68
165
  export const review = (
69
166
  base: string,
70
167
  escalations: EscalationCount = { rounds: 0 },
71
- frozen?: FrozenScenarios,
72
- ): Promise<kit.ReviewOutcome> =>
73
- kit.review(base, {
168
+ ): Promise<kit.ReviewOutcome> => {
169
+ const severities = new Map<string, Severity>()
170
+ // Armed by a defer-only round and spent at the very next gate; a fresh
171
+ // review (`reviewing`) disarms it, so a later gate never accepts a clean land.
172
+ let deferred: readonly t.NoteInput[] = []
173
+ return kit.review(base, {
74
174
  file: REVIEW,
75
175
  feedback: REQUIREMENTS,
76
176
  isCode,
77
- reviewing,
78
- fixRisks: (risks) => guarded(frozen, () => fixRisks(risks), "review")(),
79
- keepGreen: () => healthy(guarded(frozen, fixCheck, "review"), { escalations }),
80
- awaitReview,
177
+ reviewing: (b, carry) => {
178
+ deferred = []
179
+ return reviewing(b, carry)
180
+ },
181
+ fixRisks,
182
+ keepGreen: () => healthy(fixCheck, { escalations }),
183
+ awaitReview: (b) => {
184
+ const notes = deferred
185
+ deferred = []
186
+ return notes.length > 0
187
+ ? awaitReview(b, t.buildReviewAwaitAfterDeferMessage(b, notes), true)
188
+ : awaitReview(b)
189
+ },
81
190
  reviewMissing,
82
191
  collecting,
83
- triage,
192
+ triage: triage(severities),
193
+ defer: (notes) => {
194
+ deferred = notes.map((n) => ({
195
+ id: n.id,
196
+ anchor: n.anchor,
197
+ text: n.text,
198
+ label: severities.get(n.id)!.reason,
199
+ }))
200
+ return deferNotes(deferred, head())
201
+ },
84
202
  answerQuestions: answerReviewQuestions,
85
- fixNits: (notes) => guarded(frozen, () => fixNits(notes), "review")(),
203
+ fixNits,
86
204
  close: closeReview,
87
205
  captures: {
88
206
  edits: t.reviewEditsCapture,
89
207
  notes: t.reviewNotesCapture,
90
- editNotes: t.reviewEditNotesCapture,
208
+ editNotes: (round, edits, answeredAt) =>
209
+ t.reviewEditNotesCapture(
210
+ round,
211
+ edits
212
+ .map((n, i) => ({ n, i, s: severities.get(n.id) }))
213
+ // critical before should, then one-way doors, then new concepts
214
+ .sort(
215
+ (x, y) =>
216
+ Number(x.s?.tier === "should") - Number(y.s?.tier === "should") ||
217
+ (x.s?.rank ?? 2) - (y.s?.rank ?? 2) ||
218
+ x.i - y.i,
219
+ )
220
+ .map(({ n, s }) => ({
221
+ id: n.id,
222
+ anchor: n.anchor,
223
+ text: n.forced ? n.text.replace(/^!\s*/, "") : n.text,
224
+ ...(s === undefined ? {} : { label: s.reason }),
225
+ })),
226
+ answeredAt,
227
+ ),
91
228
  },
92
229
  refusals: {
93
230
  deleted:
@@ -96,11 +233,11 @@ export const review = (
96
233
  "gtd land: no declared pattern matches the pending changes — write .gtd/REQUIREMENTS.md from the feedback, or change nothing when it asks for nothing",
97
234
  },
98
235
  })
236
+ }
99
237
 
100
- /** The build tail: fix (when entered red), keep green, the quality lap, then human review since `base`. */
238
+ /** The build tail: fix (when entered red), keep green unless `green`, the quality lap, then human review since `base`. */
101
239
  export const buildTail = (
102
240
  fixFirst: boolean,
103
241
  base: string,
104
- built?: BuiltPlan,
105
- ): Promise<kit.ReviewOutcome> =>
106
- tail(fixFirst, (escalations, frozen) => review(base, escalations, frozen), built)
242
+ green = false,
243
+ ): Promise<kit.ReviewOutcome> => tail(fixFirst, (escalations) => review(base, escalations), green)
@@ -79,4 +79,16 @@ describe("the feature workflow's own steps declare skills — a step's rendered
79
79
  expect(request.prompt).toContain("semicolon")
80
80
  expect(request.prompt).toContain("Leave `.gtd/REVIEW.md` untouched")
81
81
  })
82
+
83
+ it("deferNotes rests at build.review.defer as a script writing .gtd/TODO.md", async () => {
84
+ const request = await capture(
85
+ () =>
86
+ steps.deferNotes([{ id: "note-1", anchor: "a", text: "x", label: "could" }], "abcdef123"),
87
+ "build",
88
+ )
89
+ if (request.kind !== "run") throw new Error("unreachable")
90
+ expect(request.name).toBe("review.defer")
91
+ expect(request.body).toContain("## Deferred review notes (abcdef1)")
92
+ expect(request.options).toMatchObject({ file: ".gtd/TODO.md" })
93
+ })
82
94
  })
@@ -1,4 +1,4 @@
1
- import { human } from "../../flows/index.js"
1
+ import { human, run } from "../../flows/index.js"
2
2
  import * as t from "../shared/text.js"
3
3
  import { ARCHITECTURE, coder, FEEDBACK, planner, REQUIREMENTS, REVIEW } from "../shared/steps.js"
4
4
 
@@ -90,3 +90,10 @@ export const collecting = (capture: string): Promise<void> =>
90
90
  system: t.reviewerSystem(),
91
91
  allowEmpty: true,
92
92
  })
93
+
94
+ /** `build.review.defer`: append `notes` to `.gtd/TODO.md` as one section; the step's landing is the commit. */
95
+ export const deferNotes = (notes: readonly t.NoteInput[], round: string): Promise<void> =>
96
+ run("review.defer", t.deferNotesScript(notes, round), {
97
+ label: "Deferring could-notes to TODO.md",
98
+ file: ".gtd/TODO.md",
99
+ })