@pmelab/gtd 24.2.0 → 24.4.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 (81) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +41 -6
  3. package/dist/chunks/{Schema-Dh-mkwkq.mjs → Schema-B5yK7QrT.mjs} +47 -1
  4. package/dist/chunks/{reserved-ejn-LVYO.mjs → reserved-B9pG93Fc.mjs} +53 -13
  5. package/dist/chunks/{steering-D-PvRC5W.mjs → steering-DVILanVf.mjs} +16 -5
  6. package/dist/flows.mjs +9 -1
  7. package/dist/gtd.bundle.mjs +1114 -865
  8. package/dist/steering.mjs +2 -2
  9. package/dist/testkit.mjs +1135 -218
  10. package/dist/workflow-kit.mjs +9 -7
  11. package/dist/workflows/feature.mjs +152 -35
  12. package/dist/workflows/fix.mjs +2 -2
  13. package/dist/workflows/review.mjs +1 -1
  14. package/dist/workflows.mjs +223 -54
  15. package/package.json +1 -1
  16. package/src/cli/Cli.test.ts +57 -0
  17. package/src/cli/Cli.ts +89 -9
  18. package/src/cli/History.test.ts +225 -0
  19. package/src/cli/History.ts +143 -0
  20. package/src/cli/Output.ts +68 -0
  21. package/src/cli/Program.test.ts +10 -9
  22. package/src/cli/Program.ts +24 -72
  23. package/src/edge/Edge.test.ts +261 -3
  24. package/src/edge/Edge.ts +218 -41
  25. package/src/edge/index.ts +1 -0
  26. package/src/emit/GitScript.test.ts +27 -0
  27. package/src/emit/GitScript.ts +18 -2
  28. package/src/emit/index.ts +2 -0
  29. package/src/flows/scripts.ts +6 -0
  30. package/src/platform/Git.test.ts +6 -9
  31. package/src/platform/Git.ts +48 -10
  32. package/src/platform/RetainedHistory.test.ts +14 -0
  33. package/src/platform/RetainedHistory.ts +5 -0
  34. package/src/platform/index.ts +7 -1
  35. package/src/replay/Trailers.test.ts +32 -0
  36. package/src/replay/Trailers.ts +17 -0
  37. package/src/steering/FORMATS.md +1 -1
  38. package/src/steering/SteeringFormat.test.ts +9 -1
  39. package/src/steering/SteeringFormat.ts +7 -0
  40. package/src/steering/contract.test.ts +3 -0
  41. package/src/steering/index.ts +1 -0
  42. package/src/steering/review.test.ts +30 -0
  43. package/src/steering/review.ts +8 -2
  44. package/src/step/LandStep.ts +3 -1
  45. package/src/step/RepoSnapshot.ts +7 -1
  46. package/src/step/planStart.test.ts +6 -3
  47. package/src/step/planStep.test.ts +29 -1
  48. package/src/step/planStep.ts +8 -0
  49. package/src/testkit/EmittedScriptRecognizer.test.ts +29 -30
  50. package/src/testkit/EmittedScriptRecognizer.ts +149 -52
  51. package/src/testkit/EmittedScripts.test.ts +166 -0
  52. package/src/testkit/FakeGitOperations.ts +6 -2
  53. package/src/testkit/FlowScripts.test.ts +242 -0
  54. package/src/testkit/FlowScripts.ts +326 -0
  55. package/src/testkit/GitTiers.ts +269 -47
  56. package/src/testkit/InMemRepo.ts +61 -8
  57. package/src/testkit/Layers.ts +10 -2
  58. package/src/testkit/TestRepo.ts +4 -1
  59. package/src/testkit/index.ts +1 -1
  60. package/src/workflow/discovery.test.ts +147 -0
  61. package/src/workflow/discovery.ts +5 -0
  62. package/src/workflow/fingerprint.test.ts +20 -4
  63. package/src/workflow/fingerprint.ts +8 -9
  64. package/src/workflow/index.ts +1 -6
  65. package/src/workflow/load.test.ts +5 -19
  66. package/src/workflow/load.ts +94 -10
  67. package/src/workflow-kit/review.test.ts +84 -0
  68. package/src/workflow-kit/review.ts +9 -6
  69. package/src/workflows/feature/review.test.ts +238 -12
  70. package/src/workflows/feature/review.ts +172 -31
  71. package/src/workflows/feature/steps.test.ts +12 -0
  72. package/src/workflows/feature/steps.ts +8 -1
  73. package/src/workflows/shared/access.test.ts +4 -2
  74. package/src/workflows/shared/access.ts +1 -0
  75. package/src/workflows/shared/options.test.ts +8 -0
  76. package/src/workflows/shared/options.ts +1 -0
  77. package/src/workflows/shared/steps.ts +2 -0
  78. package/src/workflows/shared/tail.ts +3 -11
  79. package/src/workflows/shared/text.test.ts +106 -0
  80. package/src/workflows/shared/text.ts +57 -1
  81. package/src/workflows/shared/vars.ts +1 -0
@@ -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
 
@@ -383,3 +416,196 @@ describe("scenario wording in review's fix turns", () => {
383
416
  expect(log.slice(-2)).toEqual(["fix", "scenario-wording"])
384
417
  })
385
418
  })
419
+
420
+ describe("severityOf", () => {
421
+ const note = (forced = false): ReviewNote => ({
422
+ id: "n",
423
+ kind: "pointer",
424
+ anchor: "a",
425
+ before: "",
426
+ text: "t",
427
+ forced,
428
+ })
429
+ const ans = (o: Partial<Record<string, string>> = {}, p = 0.9) => {
430
+ const base: Record<string, string> = {
431
+ security: "no",
432
+ reversibility: "two-way",
433
+ blastRadius: "local",
434
+ newConcept: "no",
435
+ ...o,
436
+ }
437
+ return Object.fromEntries(Object.entries(base).map(([k, v]) => [`n.${k}`, { answer: v, p }]))
438
+ }
439
+ const j = (answers: ReturnType<typeof ans>, truncated: string[] = []) => ({ answers, truncated })
440
+
441
+ it("security or one-way is critical and names each answer", () => {
442
+ expect(severityOf(note(), j(ans({ security: "yes" })), 0.7)).toMatchObject({
443
+ tier: "critical",
444
+ reason: "critical: security",
445
+ })
446
+ expect(
447
+ severityOf(note(), j(ans({ security: "yes", reversibility: "one-way" })), 0.7),
448
+ ).toMatchObject({ tier: "critical", reason: "critical: security, one-way door", rank: 0 })
449
+ })
450
+
451
+ it("contract or new concept is should; neither is could", () => {
452
+ expect(severityOf(note(), j(ans({ blastRadius: "contract" })), 0.7).tier).toBe("should")
453
+ expect(severityOf(note(), j(ans({ newConcept: "yes" })), 0.7)).toMatchObject({
454
+ tier: "should",
455
+ reason: "should: new concept",
456
+ rank: 1,
457
+ })
458
+ expect(severityOf(note(), j(ans()), 0.7)).toMatchObject({ tier: "could", rank: 2 })
459
+ })
460
+
461
+ it("unsure is critical: unanswered, unrecognised, cut, low p, blank floor", () => {
462
+ const a = ans()
463
+ delete (a as Record<string, unknown>)["n.security"]
464
+ for (const x of [
465
+ severityOf(note(), j(a), 0.7),
466
+ severityOf(note(), j(ans({ security: "unsure" })), 0.7),
467
+ severityOf(note(), j(ans(), ["n"]), 0.7),
468
+ severityOf(note(), j(ans({}, 0.5)), 0.7),
469
+ severityOf(note(), j(ans()), Infinity),
470
+ ])
471
+ expect(x).toMatchObject({ tier: "critical", reason: "critical: judge unsure" })
472
+ })
473
+
474
+ it("forced is critical regardless of answers", () => {
475
+ expect(severityOf(note(true), j({}), 0.7)).toMatchObject({
476
+ tier: "critical",
477
+ reason: "critical: forced by `!`",
478
+ })
479
+ })
480
+ })
481
+
482
+ describe("severity routing", () => {
483
+ const sev = (id: string, a: Record<string, string>): Record<string, JudgeAnswer> =>
484
+ Object.fromEntries(Object.entries(a).map(([k, v]) => [`${id}.${k}`, verdict(v)]))
485
+ const could = {
486
+ security: "no",
487
+ reversibility: "two-way",
488
+ blastRadius: "local",
489
+ newConcept: "no",
490
+ }
491
+
492
+ it("a forced note is asked nothing, not even the verdict, and is a critical edit", async () => {
493
+ const run = await drive({ notes: ["! rename it — now", "sub"] })
494
+ expect(run.judged.filter((id) => id.startsWith("note-1"))).toEqual([])
495
+ expect(run.log).toContain("review.collecting")
496
+ expect(run.prompts.get("review.collecting")).toContain("(critical: forced by `!`)")
497
+ })
498
+
499
+ it("a confident could edit defers and, alone, returns to the review gate", async () => {
500
+ const run = await drive({
501
+ notes: ["add — quote", "sub", "mul"],
502
+ answers: { "note-1": verdict("edit"), ...sev("note-1", could) },
503
+ })
504
+ expect(run.log).toEqual([
505
+ "review.reviewing",
506
+ "review.await-review",
507
+ "review.triage",
508
+ "review.defer",
509
+ "review.await-review",
510
+ ])
511
+ })
512
+
513
+ it("severity answers on a nit leave it a nit", async () => {
514
+ const run = await drive({
515
+ notes: ["add — typo", "sub", "mul"],
516
+ answers: { "note-1": verdict("nit"), ...sev("note-1", { reversibility: "one-way" }) },
517
+ })
518
+ expect(run.log).toContain("review.fix.nits.fixing")
519
+ expect(run.log).not.toContain("review.collecting")
520
+ })
521
+
522
+ it("edits are captured critical before should, doors first, each labelled", async () => {
523
+ const run = await drive({
524
+ notes: ["add — plain", "sub — concept", "mul — door"],
525
+ answers: {
526
+ "note-1": verdict("edit"),
527
+ ...sev("note-1", { ...could, security: "yes" }),
528
+ ...sev("note-2", { ...could, newConcept: "yes" }),
529
+ ...sev("note-3", { ...could, reversibility: "one-way" }),
530
+ },
531
+ })
532
+ const capture = run.prompts.get("review.collecting") ?? ""
533
+ const at = (id: string): number => capture.indexOf(`- ${id} —`)
534
+ expect(at("note-3")).toBeLessThan(at("note-1"))
535
+ expect(at("note-1")).toBeLessThan(at("note-2"))
536
+ expect(capture).toContain("(critical: one-way door)")
537
+ expect(capture).toContain("(should: new concept)")
538
+ expect(capture).toContain("(critical: security)")
539
+ })
540
+
541
+ it("a round of only forced notes judges nothing and tiers each critical", async () => {
542
+ const run = await drive({ notes: ["! rename it", "! drop it", "mul"] })
543
+ expect(run.log).not.toContain("review.triage")
544
+ expect(run.judged).toEqual([])
545
+ expect(run.prompts.get("review.collecting")).toContain("(critical: forced by `!`)")
546
+ expect(run.prompts.get("review.collecting")).not.toContain("! rename")
547
+ })
548
+
549
+ it("a note opening with != is not forced and keeps its text", async () => {
550
+ const run = await drive({ notes: ["!= should be ===", "sub"] })
551
+ expect(run.judged).toContain("note-1.security")
552
+ })
553
+
554
+ it("the gate after a deferral accepts a clean land and lists the deferred notes", async () => {
555
+ const run = await drive({
556
+ notes: ["add — quote", "sub", "mul"],
557
+ answers: { "note-1": verdict("edit"), ...sev("note-1", could) },
558
+ })
559
+ expect(run.gates).toEqual([false, true])
560
+ expect(run.gateMessages[1]).toContain("NOT acted on")
561
+ expect(run.gateMessages[1]).toContain("note-1")
562
+ expect(run.gateMessages[0]).not.toContain("NOT acted on")
563
+ })
564
+
565
+ it("a later gate does not accept a clean land after a deferral round with a nit", async () => {
566
+ const run = await drive({
567
+ notes: ["add — quote", "sub — typo", "mul"],
568
+ answers: {
569
+ "note-1": verdict("edit"),
570
+ ...sev("note-1", could),
571
+ "note-2": verdict("nit"),
572
+ },
573
+ reviewDocs: [undefined, doc(["add", "sub"])],
574
+ })
575
+ expect(run.log).toContain("review.defer")
576
+ expect(run.log.at(-1)).toBe("review.await-review")
577
+ expect(run.gates).toEqual([false, false])
578
+ })
579
+
580
+ it("a question and a could note in one round: the gate's base is the answering commit", async () => {
581
+ const run = await drive({
582
+ notes: ["add — why?", "sub — quote", "mul"],
583
+ answers: {
584
+ "note-1": verdict("question"),
585
+ "note-2": verdict("edit"),
586
+ ...sev("note-2", could),
587
+ },
588
+ passSecondGate: true,
589
+ })
590
+ expect(run.log).toEqual([
591
+ "review.reviewing",
592
+ "review.await-review",
593
+ "review.triage",
594
+ "review.answer-review-questions",
595
+ "review.defer",
596
+ "review.await-review",
597
+ ])
598
+ // c4 is the answering commit, not the defer commit (c5) or the round (c2)
599
+ expect(run.since.at(-1)).toBe("c4")
600
+ })
601
+
602
+ it("a blank reviewSeverityMinP makes every confident could critical", async () => {
603
+ const run = await drive({
604
+ notes: ["add — quote", "sub", "mul"],
605
+ answers: { "note-1": verdict("edit"), ...sev("note-1", could) },
606
+ vars: { reviewSeverityMinP: "" },
607
+ })
608
+ expect(run.log).toContain("review.collecting")
609
+ expect(run.log).not.toContain("review.defer")
610
+ })
611
+ })
@@ -1,4 +1,12 @@
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"
@@ -15,7 +23,7 @@ import {
15
23
  import { closeReview, tail } from "../shared/tail.js"
16
24
  import * as t from "../shared/text.js"
17
25
  import type { BuiltPlan } from "./packages.js"
18
- import { answerReviewQuestions, collecting, fixNits } from "./steps.js"
26
+ import { answerReviewQuestions, collecting, deferNotes, fixNits } from "./steps.js"
19
27
 
20
28
  export type { ReviewOutcome } from "../../workflow-kit/index.js"
21
29
 
@@ -30,33 +38,124 @@ const verdictQuestion = (note: ReviewNote): JudgeQuestion => ({
30
38
  "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
39
  })
32
40
 
41
+ type Tier = "critical" | "should" | "could"
42
+
43
+ const SEVERITY_ANSWERS: Readonly<Record<string, readonly string[]>> = {
44
+ security: ["yes", "no"],
45
+ reversibility: ["one-way", "two-way"],
46
+ blastRadius: ["local", "module", "contract"],
47
+ newConcept: ["yes", "no"],
48
+ }
49
+
50
+ interface Severity {
51
+ readonly tier: Tier
52
+ readonly reason: string
53
+ /** Capture order: 0 one-way door, 1 new concept, 2 everything else. */
54
+ readonly rank: 0 | 1 | 2
55
+ }
56
+
33
57
  /**
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.
58
+ * Deferring is the risky direction: anything unanswered, unrecognised, cut or
59
+ * below `minP` is `critical`, never `could`.
38
60
  */
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
- )
61
+ export const severityOf = (note: ReviewNote, judgment: Judgment, minP: number): Severity => {
62
+ const ans = (q: string) => judgment.answers[`${note.id}.${q}`]
63
+ const a = (q: string): string | undefined => ans(q)?.answer
64
+ const rank = (): 0 | 1 | 2 =>
65
+ a("reversibility") === "one-way" ? 0 : a("newConcept") === "yes" ? 1 : 2
66
+ if (note.forced) return { tier: "critical", reason: "critical: forced by `!`", rank: 2 }
67
+ const unsure =
68
+ judgment.truncated.includes(note.id) ||
69
+ Object.entries(SEVERITY_ANSWERS).some(
70
+ ([q, allowed]) => !allowed.includes(a(q) ?? "") || !((ans(q)?.p ?? NaN) >= minP),
71
+ )
72
+ if (unsure) return { tier: "critical", reason: "critical: judge unsure", rank: 2 }
73
+ const critical = [
74
+ ...(a("security") === "yes" ? ["security"] : []),
75
+ ...(a("reversibility") === "one-way" ? ["one-way door"] : []),
76
+ ]
77
+ if (critical.length > 0)
78
+ return { tier: "critical", reason: `critical: ${critical.join(", ")}`, rank: rank() }
79
+ const should = [
80
+ ...(a("blastRadius") === "contract" ? ["contract"] : []),
81
+ ...(a("newConcept") === "yes" ? ["new concept"] : []),
82
+ ]
83
+ if (should.length > 0)
84
+ return { tier: "should", reason: `should: ${should.join(", ")}`, rank: rank() }
85
+ return { tier: "could", reason: "could", rank: 2 }
58
86
  }
59
87
 
88
+ const severityQuestions = (note: ReviewNote): JudgeQuestion[] => [
89
+ {
90
+ id: `${note.id}.security`,
91
+ primitive: "noul",
92
+ 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?`,
93
+ criteria: "yes: it changes or could weaken a security property. no: it does not.",
94
+ },
95
+ {
96
+ id: `${note.id}.reversibility`,
97
+ primitive: "choice",
98
+ instructions: `Once the change in note "${note.id}" ships, can it be undone without breaking anyone who already depends on it?`,
99
+ criteria:
100
+ "one-way: a published API, file format, persisted data, protocol or other contract that cannot be taken back. two-way: can be reverted freely.",
101
+ },
102
+ {
103
+ id: `${note.id}.blastRadius`,
104
+ primitive: "choice",
105
+ instructions: `How far does the change in note "${note.id}" reach?`,
106
+ criteria:
107
+ "local: one function or file. module: a whole module or several files. contract: a public interface other code or users depend on.",
108
+ },
109
+ {
110
+ id: `${note.id}.newConcept`,
111
+ primitive: "noul",
112
+ 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.`,
113
+ criteria: "yes: a genuinely new concept. no: reuses or extends an existing one.",
114
+ },
115
+ ]
116
+
117
+ /**
118
+ * One judge rest: a verdict per unforced note (a forced note is asked
119
+ * nothing) plus four severity answers. Dismissing a note is the risky
120
+ * direction, so only a confident non-`edit` verdict on uncut evidence counts;
121
+ * a cut, unanswered or below-floor note is an `edit`. Severity answers on a
122
+ * non-`edit` note are ignored. A confident `could` edit becomes `defer`.
123
+ */
124
+ const triage =
125
+ (severities: Map<string, Severity>) =>
126
+ async (notes: readonly ReviewNote[]): Promise<ReadonlyMap<string, kit.Verdict>> => {
127
+ const asked = notes.filter((n) => !n.forced)
128
+ const judgment: Judgment =
129
+ asked.length === 0
130
+ ? { answers: {}, truncated: [] }
131
+ : await judge("review.triage", {
132
+ questions: asked.flatMap((n) => [verdictQuestion(n), ...severityQuestions(n)]),
133
+ evidence: Object.fromEntries(
134
+ asked.map((n) => [
135
+ n.id,
136
+ `Anchor: ${n.anchor}\nReviewer's text: ${n.before}\nHuman's text: ${n.text}`,
137
+ ]),
138
+ ),
139
+ message: t.buildReviewTriageMessage(),
140
+ label: "Judging each note",
141
+ })
142
+ const { answers, truncated } = judgment
143
+ const minP = numeric(vars.reviewNoteActionable, Infinity)
144
+ const severityMinP = numeric(vars.reviewSeverityMinP, Infinity)
145
+ severities.clear()
146
+ return new Map(
147
+ notes.map((n): [string, kit.Verdict] => {
148
+ const verdict = n.forced
149
+ ? undefined
150
+ : VERDICTS.find((v) => v !== "edit" && answered(answers[n.id], v, minP))
151
+ if (verdict !== undefined && !truncated.includes(n.id)) return [n.id, verdict]
152
+ const severity = severityOf(n, judgment, severityMinP)
153
+ severities.set(n.id, severity)
154
+ return [n.id, severity.tier === "could" ? "defer" : "edit"]
155
+ }),
156
+ )
157
+ }
158
+
60
159
  const isCode = (path: string): boolean => path !== ".gtd" && !path.startsWith(".gtd/")
61
160
 
62
161
  /**
@@ -69,25 +168,66 @@ export const review = (
69
168
  base: string,
70
169
  escalations: EscalationCount = { rounds: 0 },
71
170
  frozen?: FrozenScenarios,
72
- ): Promise<kit.ReviewOutcome> =>
73
- kit.review(base, {
171
+ ): Promise<kit.ReviewOutcome> => {
172
+ const severities = new Map<string, Severity>()
173
+ // Armed by a defer-only round and spent at the very next gate; a fresh
174
+ // review (`reviewing`) disarms it, so a later gate never accepts a clean land.
175
+ let deferred: readonly t.NoteInput[] = []
176
+ return kit.review(base, {
74
177
  file: REVIEW,
75
178
  feedback: REQUIREMENTS,
76
179
  isCode,
77
- reviewing,
180
+ reviewing: (b, carry) => {
181
+ deferred = []
182
+ return reviewing(b, carry)
183
+ },
78
184
  fixRisks: (risks) => guarded(frozen, () => fixRisks(risks), "review")(),
79
185
  keepGreen: () => healthy(guarded(frozen, fixCheck, "review"), { escalations }),
80
- awaitReview,
186
+ awaitReview: (b) => {
187
+ const notes = deferred
188
+ deferred = []
189
+ return notes.length > 0
190
+ ? awaitReview(b, t.buildReviewAwaitAfterDeferMessage(b, notes), true)
191
+ : awaitReview(b)
192
+ },
81
193
  reviewMissing,
82
194
  collecting,
83
- triage,
195
+ triage: triage(severities),
196
+ defer: (notes) => {
197
+ deferred = notes.map((n) => ({
198
+ id: n.id,
199
+ anchor: n.anchor,
200
+ text: n.text,
201
+ label: severities.get(n.id)!.reason,
202
+ }))
203
+ return deferNotes(deferred, head())
204
+ },
84
205
  answerQuestions: answerReviewQuestions,
85
206
  fixNits: (notes) => guarded(frozen, () => fixNits(notes), "review")(),
86
207
  close: closeReview,
87
208
  captures: {
88
209
  edits: t.reviewEditsCapture,
89
210
  notes: t.reviewNotesCapture,
90
- editNotes: t.reviewEditNotesCapture,
211
+ editNotes: (round, edits, answeredAt) =>
212
+ t.reviewEditNotesCapture(
213
+ round,
214
+ edits
215
+ .map((n, i) => ({ n, i, s: severities.get(n.id) }))
216
+ // critical before should, then one-way doors, then new concepts
217
+ .sort(
218
+ (x, y) =>
219
+ Number(x.s?.tier === "should") - Number(y.s?.tier === "should") ||
220
+ (x.s?.rank ?? 2) - (y.s?.rank ?? 2) ||
221
+ x.i - y.i,
222
+ )
223
+ .map(({ n, s }) => ({
224
+ id: n.id,
225
+ anchor: n.anchor,
226
+ text: n.forced ? n.text.replace(/^!\s*/, "") : n.text,
227
+ ...(s === undefined ? {} : { label: s.reason }),
228
+ })),
229
+ answeredAt,
230
+ ),
91
231
  },
92
232
  refusals: {
93
233
  deleted:
@@ -96,6 +236,7 @@ export const review = (
96
236
  "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
237
  },
98
238
  })
239
+ }
99
240
 
100
241
  /** The build tail: fix (when entered red), keep green, the quality lap, then human review since `base`. */
101
242
  export const buildTail = (
@@ -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
+ })
@@ -13,6 +13,7 @@ describe("the bundled workflow's access export", () => {
13
13
  architecture: { write: [".gtd/REQUIREMENTS.md"] },
14
14
  "architecture.decompose": { write: [".gtd/packages/**", ".gtd/ARCHITECTURE.md"] },
15
15
  "build.review": { write: [".gtd/REVIEW.md"] },
16
+ "build.review.defer": { write: [".gtd/TODO.md"] },
16
17
  "build.review.fix.nits": {},
17
18
  "build.review.fix.risks": {},
18
19
  "build.quality.correctness": { write: [] },
@@ -33,8 +34,9 @@ describe("the bundled workflow's access export", () => {
33
34
  ).toEqual(["build.quality.correctness", "build.quality.my-lens"])
34
35
  })
35
36
 
36
- it("keys only scopes that run a turn (every key is a skills key)", () => {
37
- const known = Object.keys(workflow.skills(defaults))
37
+ it("keys only turn or script scopes", () => {
38
+ // `build.review.defer` is a script scope: no turn, so no skills entry.
39
+ const known = [...Object.keys(workflow.skills(defaults)), "build.review.defer"]
38
40
  for (const key of Object.keys(bundled)) expect(known).toContain(key)
39
41
  })
40
42
  })
@@ -13,6 +13,7 @@ export const access = (
13
13
  architecture: { write: [".gtd/REQUIREMENTS.md"] },
14
14
  "architecture.decompose": { write: [".gtd/packages/**", ".gtd/ARCHITECTURE.md"] },
15
15
  "build.review": { write: [".gtd/REVIEW.md"] },
16
+ "build.review.defer": { write: [".gtd/TODO.md"] },
16
17
  // `{}` reopens what `build.review` restricted: the fixes edit code.
17
18
  "build.review.fix.nits": {},
18
19
  "build.review.fix.risks": {},
@@ -22,6 +22,14 @@ describe("the shipped workflow factories", () => {
22
22
  })
23
23
  })
24
24
 
25
+ it("default reviewSeverityMinP to 0.7 and let a factory override win", () => {
26
+ expect(defaults.reviewSeverityMinP).toBe("0.7")
27
+ expect(configure(feature({ reviewSeverityMinP: "0.9" }))).toMatchObject({
28
+ ok: true,
29
+ build: { defaults: { reviewSeverityMinP: "0.9" } },
30
+ })
31
+ })
32
+
25
33
  it("build the shipped settings unchanged with no options", () => {
26
34
  for (const factory of [feature, fix, review]) {
27
35
  expect(configure(factory)).toMatchObject({ ok: true, build: { defaults, envDefaults } })
@@ -15,6 +15,7 @@ export const ShippedOptions = Schema.Struct({
15
15
  judgeBudgetBytes: setting,
16
16
  judgeIdenticalMinP: setting,
17
17
  reviewNoteActionable: setting,
18
+ reviewSeverityMinP: setting,
18
19
  qualityReviews: setting,
19
20
  testCommand: setting,
20
21
  fastTestCommand: setting,