@pmelab/gtd 55.0.0 → 56.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 (61) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +60 -61
  3. package/dist/chunks/{reserved-DHafcW7t.mjs → reserved-B8EC-WE8.mjs} +77 -203
  4. package/dist/chunks/{review-BJGKwlYv.mjs → review-HdrX0FJ7.mjs} +30 -125
  5. package/dist/chunks/{steering-CLhu8C7q.mjs → steering-CCNvngSH.mjs} +256 -65
  6. package/dist/chunks/{tail-B9Hw2AFB.mjs → tail-BSLah2mA.mjs} +144 -56
  7. package/dist/gtd.bundle.mjs +259 -22
  8. package/dist/steering.mjs +2 -2
  9. package/dist/testkit.mjs +6 -6
  10. package/dist/web/main.css +1 -1
  11. package/dist/web/main.js +551 -66
  12. package/dist/workflow-kit.mjs +1 -1
  13. package/dist/workflows/cleanup.mjs +2 -2
  14. package/dist/workflows/feature.mjs +14 -16
  15. package/dist/workflows/fix.mjs +3 -3
  16. package/dist/workflows/review.mjs +3 -3
  17. package/dist/workflows/rollout.mjs +1 -1
  18. package/dist/workflows.mjs +522 -453
  19. package/package.json +1 -1
  20. package/skills/authoring/SKILL.md +3 -3
  21. package/src/cli/Cli.ts +5 -5
  22. package/src/edge/Edge.test.ts +1 -1
  23. package/src/steering/FORMATS.md +5 -1
  24. package/src/steering/SteeringFormats.test.ts +2 -2
  25. package/src/steering/contract.test.ts +1 -0
  26. package/src/steering/findings.test.ts +47 -0
  27. package/src/steering/findings.ts +350 -0
  28. package/src/steering/fixtures/findings/gate.md +34 -0
  29. package/src/steering/fixtures/findings/gate.read.json +87 -0
  30. package/src/steering/index.test.ts +2 -2
  31. package/src/steering/index.ts +4 -3
  32. package/src/steering/review.test.ts +1 -75
  33. package/src/steering/review.ts +1 -74
  34. package/src/workflow/compile.test.ts +1 -0
  35. package/src/workflows/feature/criteria-coverage.feature +1 -6
  36. package/src/workflows/feature/feature.test.ts +1 -1
  37. package/src/workflows/feature/feature.ts +12 -20
  38. package/src/workflows/feature/lens-review.feature +323 -171
  39. package/src/workflows/feature/refactor-absorbs.feature +1 -7
  40. package/src/workflows/feature/refactor-first.feature +2 -10
  41. package/src/workflows/feature/refactor-rebuild.feature +4 -24
  42. package/src/workflows/feature/refactor-review.feature +33 -117
  43. package/src/workflows/feature/refactor.test.ts +0 -2
  44. package/src/workflows/feature/refactor.ts +0 -1
  45. package/src/workflows/feature/review-gate.feature +6 -22
  46. package/src/workflows/feature/reviewer-walkthrough.feature +16 -103
  47. package/src/workflows/feature/standards.feature +75 -34
  48. package/src/workflows/fix/fix-lens-origin.feature +20 -7
  49. package/src/workflows/fix/fix-quality.feature +102 -23
  50. package/src/workflows/review/review-door.feature +62 -59
  51. package/src/workflows/shared/learn.test.ts +0 -14
  52. package/src/workflows/shared/learn.ts +1 -6
  53. package/src/workflows/shared/lenses.ts +149 -43
  54. package/src/workflows/shared/review.test.ts +27 -26
  55. package/src/workflows/shared/review.ts +29 -196
  56. package/src/workflows/shared/tail.ts +18 -9
  57. package/src/workflows/shared/text.test.ts +103 -288
  58. package/src/workflows/shared/text.ts +131 -329
  59. package/src/workflows/feature/review-answers.feature +0 -324
  60. package/src/workflows/feature/severity-budget.feature +0 -116
  61. package/src/workflows/feature/severity-sorted-review.feature +0 -198
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "55.0.0",
3
+ "version": "56.0.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
@@ -109,9 +109,9 @@ workflow that fails to load breaks every gtd command in the repository.
109
109
  step you add must fit one of them; there is no sixth.
110
110
 
111
111
  Options (all optional): `label`, `file` (a `.gtd/` path), `mode` (needs `file`;
112
- `qa`, `review`, or a `config.modes` name), `message` (human/judge), `model`,
113
- `system` (agent), `allowEmpty` (agent), `acceptClean` (human), `base` (the
114
- commit the step reviews since — what `gtd base` prints).
112
+ `qa`, `review`, `findings`, or a `config.modes` name), `message` (human/judge),
113
+ `model`, `system` (agent), `allowEmpty` (agent), `acceptClean` (human), `base`
114
+ (the commit the step reviews since — what `gtd base` prints).
115
115
 
116
116
  Helpers — pure reads of the commit replay stands on (the tree the last step
117
117
  left, never the live working tree): `read(path)`, `glob(pattern)`,
package/src/cli/Cli.ts CHANGED
@@ -539,11 +539,11 @@ const COMMAND_ROWS: readonly CommandRow[] = [
539
539
  arity: { names: ["mode", "file"] },
540
540
  details: [
541
541
  "Read <file> and run the built-in steering format named",
542
- "<mode> (see `gtd validate`'s modes: qa, review) over its",
543
- "contents, printing each finding one per line and exiting",
544
- "non-zero when there are any. Resolves no workflow state and",
545
- "reads no config — standalone, runnable from any directory",
546
- "with <mode>/<file> given explicitly. This is what a",
542
+ "<mode> (see `gtd validate`'s modes: qa, review, findings)",
543
+ "over its contents, printing each finding one per line and",
544
+ "exiting non-zero when there are any. Resolves no workflow",
545
+ "state and reads no config — standalone, runnable from any",
546
+ "directory with <mode>/<file> given explicitly. This is what a",
547
547
  "workflow's emitted validation script invokes as a leaf step.",
548
548
  "--open-questions runs the qa unanswered-questions predicate",
549
549
  "instead (see --help); --open-threads lists open threads.",
@@ -754,7 +754,7 @@ export default async () => {
754
754
  `)
755
755
  const exit = await provideExit(currentRest, repo)
756
756
  expect(Exit.isFailure(exit) && String(exit.cause)).toContain(
757
- 'step "idle": mode "adr" is not a mode this workflow knows (qa, review)',
757
+ 'step "idle": mode "adr" is not a mode this workflow knows (qa, review, findings)',
758
758
  )
759
759
  })
760
760
 
@@ -18,9 +18,13 @@ read them.
18
18
  its decision. A `## Findings` or `## Walkthrough` heading groups the `###`
19
19
  chunks under it; a walkthrough chunk takes no decision. Any other `##` is a
20
20
  chunk of its own.
21
+ - **`findings`**: the finding gate's file. `## ` entries, those under a
22
+ `# Dropped as noise` heading being the noise; each holds exactly one
23
+ `- [ ] fix` box. A ticked box fixes the finding; text below the box is the
24
+ human's guidance, valid only under a ticked one.
21
25
  - **Free-form**: any markdown, for a file with no mode or an unknown one. Only
22
26
  its threads are validated.
23
- - **Threads**, shared by all three: a footnote whose definition is a list of
27
+ - **Threads**, shared by all of them: a footnote whose definition is a list of
24
28
  alternating `- H:` / `- A:` entries. Any other footnote is a one-shot note.
25
29
  - **Code threads**: `H:` / `A:` line comments (`//`, `#`, `--`, `;`, chosen by
26
30
  file extension) in a changed code file. A comment run is a thread only when
@@ -11,8 +11,8 @@ const QA_FORMAT = steeringFormatFor("qa")!
11
11
  const REVIEW_FORMAT = steeringFormatFor("review")!
12
12
 
13
13
  describe("BUILT_IN_MODE_NAMES", () => {
14
- it("lists the two built-in format names", () => {
15
- expect(BUILT_IN_MODE_NAMES).toEqual(["qa", "review"])
14
+ it("lists the built-in format names", () => {
15
+ expect(BUILT_IN_MODE_NAMES).toEqual(["qa", "review", "findings"])
16
16
  })
17
17
  })
18
18
 
@@ -34,6 +34,7 @@ const fixture = (path: string): string => {
34
34
  const FORMATS: readonly (readonly [dir: string, format: SteeringFormat, ticks: boolean])[] = [
35
35
  ["qa", steeringFormatFor("qa")!, true],
36
36
  ["review", steeringFormatFor("review")!, true],
37
+ ["findings", steeringFormatFor("findings")!, true],
37
38
  ["free-form", freeFormFormat, false],
38
39
  ]
39
40
 
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { applySteeringEdits, findingEntries, steeringFormatFor } from "./index.js"
3
+
4
+ const format = steeringFormatFor("findings")!
5
+
6
+ const gate = (box: string, guidance = ""): string =>
7
+ `# Quality\n\n## a\n\nBody.\n\n${box}\n${guidance === "" ? "" : `\n${guidance}\n`}`
8
+
9
+ const messages = (content: string): string[] => format.validate(content).map((f) => f.message)
10
+
11
+ describe("the findings format", () => {
12
+ it("reads a ticked box and the guidance below it", () => {
13
+ expect(findingEntries(gate("- [x] fix", "Round half up."))).toEqual([
14
+ expect.objectContaining({ title: "a", body: "Body.", fix: true, guidance: "Round half up." }),
15
+ ])
16
+ })
17
+
18
+ it("reads guidance a formatter folded into the box's own line", () => {
19
+ expect(findingEntries(gate("- [x] fix use a map"))[0]).toMatchObject({
20
+ fix: true,
21
+ guidance: "use a map",
22
+ })
23
+ })
24
+
25
+ it("refuses guidance under an unticked box", () => {
26
+ expect(messages(gate("- [ ] fix", "Round half up."))).toEqual([
27
+ '"a" has guidance under an unticked box — tick it to fix the finding, or delete the text to drop it',
28
+ ])
29
+ })
30
+
31
+ it("refuses a finding with no box, or with two", () => {
32
+ expect(messages(gate("- [ ] maybe"))[0]).toContain("has no `- [ ] fix` box")
33
+ expect(messages(gate("- [ ] fix\n- [x] fix"))[0]).toContain("has 2 `fix` boxes")
34
+ })
35
+
36
+ it("ticks with guidance, and unticking drops the guidance with the tick", () => {
37
+ const write = (content: string, opts: { checked?: boolean; text?: string }): string => {
38
+ const result = format.apply(content, { kind: "chunk", index: 0 }, opts)
39
+ if (!result.ok) throw new Error(result.reason)
40
+ return applySteeringEdits(content, result.edits)
41
+ }
42
+ const ticked = write(gate("- [ ] fix"), { checked: true, text: "Round half up." })
43
+ expect(ticked).toBe(gate("- [x] fix", "Round half up."))
44
+ expect(write(ticked, { checked: false })).toBe(gate("- [ ] fix"))
45
+ expect(write(ticked, { checked: true, text: "Round half up." })).toBe(ticked)
46
+ })
47
+ })
@@ -0,0 +1,350 @@
1
+ import type { List, ListItem, Node, Root, RootContent } from "mdast"
2
+ import { blockNodesOfRun } from "./Blocks.js"
3
+ import { eolOf } from "./Eol.js"
4
+ import {
5
+ footnoteAttachEdits,
6
+ noteLookup,
7
+ openThreadsOf,
8
+ threadFindings,
9
+ threadOutlineNodes,
10
+ threadReplyActions,
11
+ } from "./Footnotes.js"
12
+ import { headingText, lineRange, parseMarkdown, spanRange, toLspPosition } from "./MarkdownTree.js"
13
+ import type {
14
+ SteeringAnnotateResult,
15
+ SteeringFinding,
16
+ SteeringFormat,
17
+ SteeringOutlineNode,
18
+ SteeringView,
19
+ SteeringViewNode,
20
+ } from "./SteeringFormat.js"
21
+
22
+ /** The `# ` heading the findings a judge called noise sit under. */
23
+ export const NOISE_HEADING = "Dropped as noise"
24
+ /** The label of the one box under each finding. */
25
+ export const FIX_BOX = "fix"
26
+
27
+ const OPEN_GROUP = "Open"
28
+
29
+ /** One `## ` finding of a finding-gate file. */
30
+ export interface FindingEntry {
31
+ readonly title: string
32
+ /** The finding's markdown between its heading and its box. */
33
+ readonly body: string
34
+ /** Under `# Dropped as noise`. */
35
+ readonly noise: boolean
36
+ /** Its box is ticked. */
37
+ readonly fix: boolean
38
+ /** The human's text below the box; `""` when none. */
39
+ readonly guidance: string
40
+ /** How many `fix` boxes it holds; valid only with exactly one. */
41
+ readonly boxes: number
42
+ readonly headingLine: number
43
+ }
44
+
45
+ interface Parsed extends FindingEntry {
46
+ readonly bodyNodes: readonly RootContent[]
47
+ readonly box?: ListItem
48
+ /** The box and the items after it, kept out of the body when its list starts in the body. */
49
+ readonly notBody: ReadonlySet<ListItem>
50
+ /** The guidance's last line, or the box's when there is none. */
51
+ readonly lastLine: number
52
+ readonly endLine: number
53
+ }
54
+
55
+ const MARKER = /\[\^[^\]\s]+\]/g
56
+
57
+ const isBox = (item: ListItem, content: string): boolean => {
58
+ if (typeof item.checked !== "boolean") return false
59
+ const first = item.children[0]
60
+ if (first?.type !== "paragraph" || first.position === undefined) return false
61
+ const text = content.slice(first.position.start.offset, first.position.end.offset)
62
+ return new RegExp(`^${FIX_BOX}(?![\\w-])`).test(text)
63
+ }
64
+
65
+ const slice = (content: string, node: Node): string =>
66
+ content.slice(node.position!.start.offset, node.position!.end.offset)
67
+
68
+ const lineOf = (node: Node, end = false): number =>
69
+ toLspPosition(end ? node.position!.end : node.position!.start).line
70
+
71
+ /** The box item's own text past its label: a formatter may fold a guidance line into it. */
72
+ const inlineGuidance = (content: string, item: ListItem): string => {
73
+ const first = item.children[0]!
74
+ const text = content.slice(first.position!.start.offset, item.position!.end.offset)
75
+ return text
76
+ .slice(FIX_BOX.length)
77
+ .replace(/^[\s:—–-]+/, "")
78
+ .trim()
79
+ }
80
+
81
+ interface Split {
82
+ readonly bodyNodes: readonly RootContent[]
83
+ readonly body: string
84
+ readonly notBody: readonly ListItem[]
85
+ readonly guidance: string
86
+ readonly last?: Node
87
+ }
88
+
89
+ const guidanceOf = (parts: readonly string[]): string =>
90
+ parts
91
+ .map((p) => p.replace(MARKER, "").trim())
92
+ .filter((p) => p !== "")
93
+ .join("\n\n")
94
+
95
+ /** `nodes` split at `box`: the body before it, the guidance after it. */
96
+ const splitAtBox = (content: string, nodes: readonly RootContent[], box: ListItem): Split => {
97
+ const at = nodes.findIndex((n) => n.type === "list" && n.children.includes(box))
98
+ const list = nodes[at] as List
99
+ const boxAt = list.children.indexOf(box)
100
+ // A body ending in a list runs on into the box's own list: its items before the box are body.
101
+ const bodyNodes = nodes.slice(0, boxAt > 0 ? at + 1 : at)
102
+ const trailing = list.children.slice(boxAt + 1)
103
+ const after = nodes.slice(at + 1)
104
+ return {
105
+ bodyNodes,
106
+ body: bodyNodes
107
+ .map((n) =>
108
+ n === list
109
+ ? content.slice(n.position!.start.offset, box.position!.start.offset).trimEnd()
110
+ : slice(content, n),
111
+ )
112
+ .join("\n\n"),
113
+ notBody: list.children.slice(boxAt),
114
+ guidance: guidanceOf([
115
+ inlineGuidance(content, box),
116
+ ...[...trailing, ...after].map((n) => slice(content, n)),
117
+ ]),
118
+ last: after.at(-1) ?? trailing.at(-1) ?? box,
119
+ }
120
+ }
121
+
122
+ const entryOf = (
123
+ content: string,
124
+ title: string,
125
+ noise: boolean,
126
+ headingLine: number,
127
+ nodes: readonly RootContent[],
128
+ endLine: number,
129
+ ): Parsed => {
130
+ const notes = nodes.filter((n) => n.type !== "footnoteDefinition")
131
+ const boxes = notes.flatMap((n) =>
132
+ n.type === "list" ? n.children.filter((i) => isBox(i, content)) : [],
133
+ )
134
+ const box = boxes[0]
135
+ const split: Split =
136
+ box === undefined
137
+ ? {
138
+ bodyNodes: notes,
139
+ body: notes.map((n) => slice(content, n)).join("\n\n"),
140
+ notBody: [],
141
+ guidance: "",
142
+ }
143
+ : splitAtBox(content, notes, box)
144
+ return {
145
+ title,
146
+ body: split.body,
147
+ noise,
148
+ fix: box?.checked === true,
149
+ guidance: split.guidance,
150
+ boxes: boxes.length,
151
+ headingLine,
152
+ bodyNodes: split.bodyNodes,
153
+ ...(box === undefined ? {} : { box }),
154
+ notBody: new Set(split.notBody),
155
+ lastLine: split.last === undefined ? headingLine : lineOf(split.last, true),
156
+ endLine,
157
+ }
158
+ }
159
+
160
+ const parse = (content: string): readonly Parsed[] => {
161
+ const tree: Root = parseMarkdown(content)
162
+ const entries: Parsed[] = []
163
+ let noise = false
164
+ let open: { title: string; line: number; nodes: RootContent[] } | undefined
165
+ const close = (endLine: number): void => {
166
+ if (open !== undefined)
167
+ entries.push(entryOf(content, open.title, noise, open.line, open.nodes, endLine))
168
+ open = undefined
169
+ }
170
+ for (const node of tree.children) {
171
+ if (node.type === "heading" && node.depth <= 2) {
172
+ close(lineOf(node) - 1)
173
+ const text = headingText(content, node, (t) => t.replace(MARKER, ""))
174
+ if (node.depth === 1) noise = text === NOISE_HEADING
175
+ else open = { title: text, line: lineOf(node), nodes: [] }
176
+ continue
177
+ }
178
+ open?.nodes.push(node)
179
+ }
180
+ close(content.split(/\r?\n/).length - 1)
181
+ return entries
182
+ }
183
+
184
+ /** Every `## ` finding in a finding-gate file, in file order. */
185
+ export const findingEntries = (content: string): readonly FindingEntry[] =>
186
+ parse(content).map(
187
+ ({ title, body, noise, fix, guidance, boxes, headingLine }): FindingEntry => ({
188
+ title,
189
+ body,
190
+ noise,
191
+ fix,
192
+ guidance,
193
+ boxes,
194
+ headingLine,
195
+ }),
196
+ )
197
+
198
+ const entryFindings = (content: string): readonly SteeringFinding[] =>
199
+ parse(content).flatMap((e): SteeringFinding[] => {
200
+ if (e.boxes === 0)
201
+ return [
202
+ {
203
+ message: `"${e.title}" has no \`- [ ] ${FIX_BOX}\` box — restore it, ticked to fix the finding or unticked to drop it`,
204
+ line: e.headingLine,
205
+ },
206
+ ]
207
+ if (e.boxes > 1)
208
+ return [
209
+ {
210
+ message: `"${e.title}" has ${e.boxes} \`${FIX_BOX}\` boxes — keep exactly one`,
211
+ line: e.headingLine,
212
+ },
213
+ ]
214
+ if (!e.fix && e.guidance !== "")
215
+ return [
216
+ {
217
+ message: `"${e.title}" has guidance under an unticked box — tick it to fix the finding, or delete the text to drop it`,
218
+ line: lineOf(e.box!),
219
+ },
220
+ ]
221
+ return []
222
+ })
223
+
224
+ const findingsView = (content: string): SteeringView => {
225
+ const noteAt = noteLookup(content)
226
+ return {
227
+ nodes: parse(content).map((e, index): SteeringViewNode => {
228
+ const note = noteAt(e.headingLine)
229
+ return {
230
+ title: e.title,
231
+ anchor: { kind: "chunk", index },
232
+ group: e.noise ? NOISE_HEADING : OPEN_GROUP,
233
+ ...(e.boxes === 1 ? { checked: e.fix } : {}),
234
+ ...(e.guidance === "" ? {} : { note: e.guidance }),
235
+ ...(note?.thread !== undefined ? { thread: note.thread } : {}),
236
+ body: blockNodesOfRun(content, e.bodyNodes, { skipListItem: (i) => e.notBody.has(i) }),
237
+ }
238
+ }),
239
+ }
240
+ }
241
+
242
+ const findingsOutline = (content: string): readonly SteeringOutlineNode[] => {
243
+ const lines = content.split(/\r?\n/)
244
+ return [
245
+ ...parse(content).map(
246
+ (e): SteeringOutlineNode => ({
247
+ name: e.title,
248
+ detail: e.noise ? "noise" : e.fix ? FIX_BOX : "drop",
249
+ range: spanRange(lines, e.headingLine, Math.max(e.headingLine, e.endLine)),
250
+ selectionRange: lineRange(lines, e.headingLine),
251
+ }),
252
+ ),
253
+ ...threadOutlineNodes(content).map((t) => t.node),
254
+ ]
255
+ }
256
+
257
+ const findingsAnnotate: SteeringFormat["annotate"] = (
258
+ content,
259
+ anchor,
260
+ text,
261
+ ): SteeringAnnotateResult => {
262
+ const entries = parse(content)
263
+ const lines = content.split(/\r?\n/)
264
+ const resolved = ((): { line: number; blockEndLine: number; key: string } | undefined => {
265
+ if (anchor.kind === "chunk") {
266
+ const e = entries[anchor.index]
267
+ return e && { line: e.headingLine, blockEndLine: e.headingLine, key: `chunk:${anchor.index}` }
268
+ }
269
+ if (anchor.kind !== "paragraph") return undefined
270
+ const block = entries
271
+ .flatMap((e) => e.bodyNodes)
272
+ .find((n) => anchor.line >= lineOf(n) && anchor.line <= lineOf(n, true))
273
+ return (
274
+ block && {
275
+ line: anchor.line,
276
+ blockEndLine: lineOf(block, true),
277
+ key: `paragraph:${anchor.line}`,
278
+ }
279
+ )
280
+ })()
281
+ if (resolved === undefined) return { ok: false, reason: "anchor-not-found" }
282
+ const result = footnoteAttachEdits(
283
+ content,
284
+ { ...resolved, endCharacter: (lines[resolved.line] ?? "").length },
285
+ text,
286
+ )
287
+ return result.ok ? { ok: true, edits: result.edits } : result
288
+ }
289
+
290
+ /** Ticks or unticks a finding's box; `text` replaces its guidance, and unticking drops it. */
291
+ // fallow-ignore-next-line complexity
292
+ const findingsApply: SteeringFormat["apply"] = (content, anchor, opts): SteeringAnnotateResult => {
293
+ if (anchor.kind !== "chunk") return { ok: false, reason: "anchor-not-found" }
294
+ const e = parse(content)[anchor.index]
295
+ if (e?.box === undefined || e.boxes !== 1) return { ok: false, reason: "anchor-not-found" }
296
+ const fix = opts.checked ?? e.fix
297
+ const guidance = fix ? (opts.text ?? e.guidance).trim() : ""
298
+ if (fix === e.fix && guidance === e.guidance) return { ok: true, edits: [] }
299
+ const lines = content.split(/\r?\n/)
300
+ const eol = eolOf(content)
301
+ const start = lineOf(e.box)
302
+ const box = `${lines[start]!.match(/^\s*[-*+]\s+/)![0]}[${fix ? "x" : " "}] ${FIX_BOX}`
303
+ const text = guidance === "" ? box : `${box}${eol}${eol}${guidance.split(/\r?\n/).join(eol)}`
304
+ return {
305
+ ok: true,
306
+ edits: [
307
+ {
308
+ range: { start: { line: start, character: 0 }, end: lineRange(lines, e.lastLine).end },
309
+ newText: text,
310
+ },
311
+ ],
312
+ }
313
+ }
314
+
315
+ const FINDINGS_SAMPLE = `# Quality
316
+
317
+ ## A finding a lens wrote[^nafind1]
318
+
319
+ [^nafind1]:
320
+ Attached via the phone UI, this note shows a comment on a finding with a
321
+ \`multi word code span\` that runs past eighty characters in total length.
322
+
323
+ What the lens found, and where.
324
+
325
+ - [x] fix
326
+
327
+ Fix it the simple way.
328
+
329
+ # Dropped as noise
330
+
331
+ ## A finding the judge called noise
332
+
333
+ Why the judge dropped it.
334
+
335
+ - [ ] fix
336
+ `
337
+
338
+ /** The finding gate's file: one `## ` finding each, with one `fix` box and optional guidance below it. */
339
+ export const findingsDescriptor: SteeringFormat = {
340
+ sample: FINDINGS_SAMPLE,
341
+ validate: (content) => [...entryFindings(content), ...threadFindings(content)],
342
+ outline: findingsOutline,
343
+ actions: (content, range) => threadReplyActions(content, range.start),
344
+ view: findingsView,
345
+ annotate: findingsAnnotate,
346
+ apply: findingsApply,
347
+ // Its ticks are the human's decisions, never read-progress.
348
+ clearTicks: (content) => content,
349
+ openThreads: openThreadsOf,
350
+ }
@@ -0,0 +1,34 @@
1
+ # Quality
2
+
3
+ ## AC-1 ignores floating point
4
+
5
+ _From lens correctness._
6
+
7
+ Should subtract round? The criterion does not say.
8
+
9
+ ```ts
10
+ // - [ ] fix, inside a fence, is no box
11
+ ```
12
+
13
+ - [x] fix
14
+
15
+ Round half up.
16
+
17
+ ## add has no test
18
+
19
+ _From lens test-audit. The fixer left it unresolved._
20
+
21
+ - a list in the finding's body
22
+ - with two items
23
+
24
+ - [ ] fix
25
+
26
+ # Dropped as noise
27
+
28
+ ## add could be a method
29
+
30
+ _From lens correctness. The judge called it noise (p=0.85)._
31
+
32
+ A class would read better.
33
+
34
+ - [ ] fix
@@ -0,0 +1,87 @@
1
+ {
2
+ "nodes": [
3
+ {
4
+ "title": "AC-1 ignores floating point",
5
+ "anchor": {
6
+ "kind": "chunk",
7
+ "index": 0
8
+ },
9
+ "group": "Open",
10
+ "checked": true,
11
+ "note": "Round half up.",
12
+ "body": [
13
+ {
14
+ "title": "_From lens correctness._",
15
+ "anchor": {
16
+ "kind": "paragraph",
17
+ "line": 4
18
+ }
19
+ },
20
+ {
21
+ "title": "Should subtract round? The criterion does not say.",
22
+ "anchor": {
23
+ "kind": "paragraph",
24
+ "line": 6
25
+ }
26
+ },
27
+ {
28
+ "title": "// - [ ] fix, inside a fence, is no box",
29
+ "anchor": {
30
+ "kind": "paragraph",
31
+ "line": 8
32
+ }
33
+ }
34
+ ]
35
+ },
36
+ {
37
+ "title": "add has no test",
38
+ "anchor": {
39
+ "kind": "chunk",
40
+ "index": 1
41
+ },
42
+ "group": "Open",
43
+ "checked": false,
44
+ "body": [
45
+ {
46
+ "title": "_From lens test-audit. The fixer left it unresolved._",
47
+ "anchor": {
48
+ "kind": "paragraph",
49
+ "line": 18
50
+ }
51
+ },
52
+ {
53
+ "title": "a list in the finding's body with two items",
54
+ "anchor": {
55
+ "kind": "paragraph",
56
+ "line": 20
57
+ }
58
+ }
59
+ ]
60
+ },
61
+ {
62
+ "title": "add could be a method",
63
+ "anchor": {
64
+ "kind": "chunk",
65
+ "index": 2
66
+ },
67
+ "group": "Dropped as noise",
68
+ "checked": false,
69
+ "body": [
70
+ {
71
+ "title": "_From lens correctness. The judge called it noise (p=0.85)._",
72
+ "anchor": {
73
+ "kind": "paragraph",
74
+ "line": 29
75
+ }
76
+ },
77
+ {
78
+ "title": "A class would read better.",
79
+ "anchor": {
80
+ "kind": "paragraph",
81
+ "line": 31
82
+ }
83
+ }
84
+ ]
85
+ }
86
+ ]
87
+ }
@@ -14,8 +14,8 @@ describe("steeringFormatFor / BUILT_IN_MODE_NAMES", () => {
14
14
  expect(steeringFormatFor("bogus")).toBeUndefined()
15
15
  })
16
16
 
17
- it("lists both built-in mode names, in registry order", () => {
18
- expect(BUILT_IN_MODE_NAMES).toEqual(["qa", "review"])
17
+ it("lists the built-in mode names, in registry order", () => {
18
+ expect(BUILT_IN_MODE_NAMES).toEqual(["qa", "review", "findings"])
19
19
  })
20
20
  })
21
21
 
@@ -8,6 +8,8 @@ import type {
8
8
  export { formatFinding } from "./SteeringFormat.js"
9
9
  import { qaDescriptor } from "./qa.js"
10
10
  import { reviewDescriptor } from "./review.js"
11
+ import { findingsDescriptor } from "./findings.js"
12
+ export { findingEntries, FIX_BOX, NOISE_HEADING, type FindingEntry } from "./findings.js"
11
13
  export {
12
14
  ANSWER_FIX,
13
15
  ANSWER_LEAD,
@@ -18,8 +20,6 @@ export {
18
20
  reviewRisks,
19
21
  reviewSections,
20
22
  REVIEW_SECTIONS,
21
- sectionedReview,
22
- severityLine,
23
23
  unansweredNotes,
24
24
  type ReviewChoice,
25
25
  type ReviewDecision,
@@ -50,10 +50,11 @@ export type {
50
50
  SteeringViewThread,
51
51
  } from "./SteeringFormat.js"
52
52
 
53
- /** The built-in registry: mode name → its format, in registry order. The single place `qa` and `review` are wired into names — everything else in this module dispatches over a resolved `SteeringFormat`, never a mode-name string. */
53
+ /** The built-in registry: mode name → its format, in registry order. The single place the built-in formats are wired into names — everything else in this module dispatches over a resolved `SteeringFormat`, never a mode-name string. */
54
54
  const REGISTRY: ReadonlyMap<string, SteeringFormat> = new Map([
55
55
  ["qa", qaDescriptor],
56
56
  ["review", reviewDescriptor],
57
+ ["findings", findingsDescriptor],
57
58
  ])
58
59
 
59
60
  /** Every built-in format name, in registry order. */