@pmelab/gtd 64.3.0 → 66.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 (91) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +35 -25
  3. package/claude/hooks/assist.ts +9 -35
  4. package/claude/hooks/doors.ts +4 -4
  5. package/claude/hooks/drive.ts +10 -1
  6. package/claude/hooks/git.ts +56 -0
  7. package/claude/hooks/handoff.ts +11 -11
  8. package/claude/hooks/jobs.ts +8 -8
  9. package/claude/hooks/pr.ts +86 -26
  10. package/claude/hooks/register.tsx +35 -82
  11. package/claude/hooks/tools.ts +7 -20
  12. package/claude/hooks/wording.ts +3 -3
  13. package/dist/chunks/{flows-dnIE_qbe.mjs → flows-CmQplvh1.mjs} +146 -35
  14. package/dist/chunks/{steering-DEh_MS40.mjs → steering-CMrePUiY.mjs} +115 -17
  15. package/dist/chunks/{testkit-BUX-47TD.mjs → testkit-BJhAAVGt.mjs} +905 -99
  16. package/dist/chunks/{wire-CaPJ9Tme.mjs → wire-xTbzFc26.mjs} +12 -6
  17. package/dist/flows.mjs +1 -1
  18. package/dist/gtd.bundle.mjs +965 -163
  19. package/dist/gtd.core.mjs +150 -134
  20. package/dist/steering.mjs +1 -1
  21. package/dist/testkit/gherkin.mjs +1 -1
  22. package/dist/testkit.mjs +2 -2
  23. package/dist/web/main.css +1 -1
  24. package/dist/web/main.js +185 -18
  25. package/dist/wire.mjs +1 -1
  26. package/dist/workflow-kit.mjs +9 -3
  27. package/package.json +1 -1
  28. package/src/cli/Cli.ts +5 -5
  29. package/src/cli/History.test.ts +10 -8
  30. package/src/cli/Metrics.test.ts +2 -1
  31. package/src/cli/Program.test.ts +4 -2
  32. package/src/cli/Program.ts +105 -7
  33. package/src/edge/Edge.test.ts +37 -16
  34. package/src/edge/Edge.ts +380 -33
  35. package/src/emit/GitScript.ts +116 -15
  36. package/src/emit/index.ts +7 -0
  37. package/src/flows/config.ts +7 -1
  38. package/src/flows/helpers.ts +36 -18
  39. package/src/flows/index.test.ts +4 -0
  40. package/src/flows/index.ts +7 -0
  41. package/src/flows/runtime.ts +153 -15
  42. package/src/install/Install.ts +9 -2
  43. package/src/platform/Git.test.ts +7 -11
  44. package/src/platform/NodeSync.ts +2 -0
  45. package/src/platform/RetainedHistory.ts +3 -0
  46. package/src/platform/SyncIo.ts +2 -0
  47. package/src/platform/Workspace.test.ts +37 -0
  48. package/src/platform/Workspace.ts +36 -0
  49. package/src/platform/index.ts +1 -0
  50. package/src/replay/Replay.test.ts +9 -0
  51. package/src/replay/Replay.ts +92 -16
  52. package/src/replay/Trailers.ts +17 -0
  53. package/src/replay/Tree.ts +5 -0
  54. package/src/replay/index.ts +1 -1
  55. package/src/steering/FORMATS.md +3 -1
  56. package/src/steering/SteeringFormat.ts +13 -0
  57. package/src/steering/fixtures/review/sectioned.md +1 -1
  58. package/src/steering/fixtures/review/sectioned.read.json +0 -1
  59. package/src/steering/index.ts +1 -0
  60. package/src/steering/review.test.ts +181 -5
  61. package/src/steering/review.ts +160 -18
  62. package/src/step/LandStep.ts +12 -0
  63. package/src/step/RepoSnapshot.ts +15 -0
  64. package/src/step/index.ts +1 -0
  65. package/src/step/planStart.test.ts +4 -3
  66. package/src/step/planStart.ts +7 -0
  67. package/src/step/planStep.ts +22 -17
  68. package/src/step/startBranch.ts +54 -0
  69. package/src/twins/EmittedScriptRecognizer.ts +118 -1
  70. package/src/twins/EmittedScripts.test.ts +147 -1
  71. package/src/twins/InMemRepo.ts +140 -3
  72. package/src/twins/Layers.ts +1 -0
  73. package/src/twins/SyncIo.test.ts +19 -4
  74. package/src/twins/SyncIo.ts +13 -1
  75. package/src/wire/BeatDocument.test.ts +15 -0
  76. package/src/wire/BeatDocument.ts +8 -2
  77. package/src/wire/Demand.ts +26 -12
  78. package/src/wire/constants.ts +3 -0
  79. package/src/wire/golden/1/beat-capture.json +1 -0
  80. package/src/wire/golden/1/beat-message.json +1 -0
  81. package/src/wire/golden/1/beat-prompt.json +1 -0
  82. package/src/wire/golden/1/beat-script.json +1 -0
  83. package/src/wire/golden/1/beat-stalled.json +1 -0
  84. package/src/wire/types.ts +4 -0
  85. package/src/workflow/Workflow.ts +3 -0
  86. package/src/workflow/compile.ts +2 -0
  87. package/src/workflow/load.ts +2 -0
  88. package/src/workflow-kit/index.test.ts +1 -0
  89. package/src/workflow-kit/index.ts +1 -0
  90. package/src/workflow-kit/scenarios.ts +8 -0
  91. package/claude/hooks/ship.ts +0 -730
@@ -27,6 +27,7 @@ describe("parseReviewDoc", () => {
27
27
  title: "Add calculator",
28
28
  headingLine: 4,
29
29
  description: "New add function for the calculator.",
30
+ detail: "New add function for the calculator.",
30
31
  descriptionInline: [{ kind: "text", value: "New add function for the calculator." }],
31
32
  descriptionNodes: [
32
33
  {
@@ -1557,6 +1558,23 @@ describe("review.apply", () => {
1557
1558
  ).toEqual({ ok: false, reason: "anchor-not-found" })
1558
1559
  })
1559
1560
 
1561
+ it("a chunk anchor on a findings chunk refuses anchor-not-found", () => {
1562
+ const doc = [
1563
+ "# Review: abc1234",
1564
+ "",
1565
+ "## Findings",
1566
+ "",
1567
+ "### Token check",
1568
+ "",
1569
+ "- ./src/auth.ts#1-2",
1570
+ "",
1571
+ ].join("\n")
1572
+ expect(review.apply(doc, { kind: "chunk", index: 0 }, { checked: true })).toEqual({
1573
+ ok: false,
1574
+ reason: "anchor-not-found",
1575
+ })
1576
+ })
1577
+
1560
1578
  it("a question anchor — not this format's own kind — refuses anchor-not-found", () => {
1561
1579
  expect(review.apply(NESTED_CONTENT, { kind: "question", index: 0 }, { checked: true })).toEqual(
1562
1580
  { ok: false, reason: "anchor-not-found" },
@@ -1574,7 +1592,7 @@ describe("review decisions — a finding's answer boxes", () => {
1574
1592
  "",
1575
1593
  "### Token check",
1576
1594
  "",
1577
- "- [ ] ./src/auth.ts#1-2",
1595
+ "- ./src/auth.ts#1-2",
1578
1596
  "",
1579
1597
  "Tick exactly one:",
1580
1598
  "",
@@ -2169,7 +2187,7 @@ describe("review — sectioned layout", async () => {
2169
2187
  "",
2170
2188
  "Leaks a token.",
2171
2189
  "",
2172
- "- [ ] ./src/a.ts#1-2 — here",
2190
+ "- ./src/a.ts#1-2 — here",
2173
2191
  "",
2174
2192
  ...boxes(ticked),
2175
2193
  "",
@@ -2228,16 +2246,16 @@ describe("review — sectioned layout", async () => {
2228
2246
  it("outlines the sections as containers, each finding's decision a node of its own", () => {
2229
2247
  const outline = viewOf(review, sectioned()).outline
2230
2248
  expect(outline.map((n) => n.name)).toEqual(["Findings", "Walkthrough", "Fixed automatically"])
2231
- expect(outline[0]!.children!.map((n) => n.name)).toEqual(["A finding (0/1)"])
2249
+ expect(outline[0]!.children!.map((n) => n.name)).toEqual(["A finding"])
2232
2250
  expect(outline[0]!.children![0]!.children!.map((n) => n.name)).toEqual(["Decision: open"])
2233
2251
  expect(outline[0]!.children![0]!.children![0]!.selectionRange.start.line).toBe(14)
2234
2252
  // A fully ticked walkthrough chunk is reviewed: its section is left empty.
2235
2253
  expect(outline[1]!.children).toEqual([])
2236
2254
  })
2237
2255
 
2238
- it("names the ticked decision, and keeps a decided finding while a hunk is unticked", () => {
2256
+ it("drops a decided finding from the outline: its location carries no read tick", () => {
2239
2257
  const outline = viewOf(review, sectioned(1)).outline
2240
- expect(outline[0]!.children![0]!.children!.map((n) => n.name)).toEqual(["Decision: won't fix"])
2258
+ expect(outline[0]!.children).toEqual([])
2241
2259
  })
2242
2260
 
2243
2261
  it("groups the view's chunks by section, anchors unchanged", () => {
@@ -2256,3 +2274,161 @@ describe("review — sectioned layout", async () => {
2256
2274
  expect(titles.some((t) => t.includes("A finding"))).toBe(false)
2257
2275
  })
2258
2276
  })
2277
+
2278
+ describe("review — finding locations and decision actions", () => {
2279
+ const finding = (pointer: string, ticked?: 0 | 1 | 2): string =>
2280
+ [
2281
+ "# Review: abc1234",
2282
+ "",
2283
+ "<!-- base: abc1234def5678901234567890123456789abcd -->",
2284
+ "",
2285
+ "## Findings",
2286
+ "",
2287
+ "### A finding",
2288
+ "",
2289
+ pointer,
2290
+ "",
2291
+ "Tick exactly one:",
2292
+ "",
2293
+ `- [${ticked === 0 ? "x" : " "}] fix as suggested`,
2294
+ `- [${ticked === 1 ? "x" : " "}] won't fix`,
2295
+ `- [${ticked === 2 ? "x" : " "}] _your answer_`,
2296
+ "",
2297
+ ].join("\n")
2298
+ const at = (content: string, line: number) =>
2299
+ review.actions!(content, { start: { line, character: 0 }, end: { line, character: 0 } })
2300
+
2301
+ it("links a plain location and resolves go-to-definition to its line", () => {
2302
+ const content = finding("- ./src/a.ts#5-6 — here")
2303
+ expect(review.validate(content)).toEqual([])
2304
+ expect(review.documentLinks!(content)).toEqual([
2305
+ expect.objectContaining({ path: "./src/a.ts", line: 4 }),
2306
+ ])
2307
+ expect(review.pointerAt!(content, { line: 8, character: 3 })).toEqual({
2308
+ path: "./src/a.ts",
2309
+ line: 4,
2310
+ })
2311
+ })
2312
+
2313
+ it("refuses a checkbox pointer in a finding at its own line", () => {
2314
+ for (const box of ["[ ]", "[x]"]) {
2315
+ const found = review.validate(finding(`- ${box} ./src/a.ts#5-6`))
2316
+ expect(found.some((f) => f.line === 8)).toBe(true)
2317
+ }
2318
+ })
2319
+
2320
+ it("offers no hunk tick and no read count for a finding location", () => {
2321
+ const content = finding("- ./src/a.ts#5-6")
2322
+ const titles = at(content, 8).map((a) => a.title)
2323
+ expect(titles.some((t) => t.includes("hunk"))).toBe(false)
2324
+ expect(viewOf(review, content).outline[0]!.children![0]!.name).toBe("A finding")
2325
+ const hunk = review.view(content).nodes[0]!.children![0]!
2326
+ expect(hunk.checked).toBeUndefined()
2327
+ expect(review.apply(content, hunk.anchor, { checked: true }).ok).toBe(false)
2328
+ })
2329
+
2330
+ it("accepts, rejects and answers from any line, leaving out the current tick's action", () => {
2331
+ const content = finding("- ./src/a.ts#5-6")
2332
+ for (const line of [6, 8, 12]) {
2333
+ expect(at(content, line).map((a) => a.title)).toEqual(
2334
+ expect.arrayContaining([
2335
+ "gtd: accept finding",
2336
+ "gtd: reject finding",
2337
+ "gtd: answer finding",
2338
+ ]),
2339
+ )
2340
+ }
2341
+ const run = (title: string, text: string) =>
2342
+ applySteeringEdits(text, at(text, 8).find((a) => a.title === title)!.edits)
2343
+ const accepted = run("gtd: accept finding", finding("- ./src/a.ts#5-6", 1))
2344
+ expect(accepted).toBe(finding("- ./src/a.ts#5-6", 0))
2345
+ expect(at(accepted, 8).map((a) => a.title)).not.toContain("gtd: accept finding")
2346
+ expect(run("gtd: reject finding", accepted)).toBe(finding("- ./src/a.ts#5-6", 1))
2347
+ expect(run("gtd: answer finding", accepted)).toBe(finding("- ./src/a.ts#5-6", 2))
2348
+ })
2349
+
2350
+ it("selects the whole free-text slot on answer", () => {
2351
+ const content = finding("- ./src/a.ts#5-6")
2352
+ const answer = at(content, 8).find((a) => a.title === "gtd: answer finding")!
2353
+ expect(answer.select).toEqual({
2354
+ start: { line: 14, character: 6 },
2355
+ end: { line: 14, character: 19 },
2356
+ })
2357
+ })
2358
+
2359
+ it("offers no finding actions on a top-level comments chunk", () => {
2360
+ const content = finding("- ./src/a.ts#5-6").replace(
2361
+ "## Findings\n\n### A finding",
2362
+ "## Your comments:",
2363
+ )
2364
+ expect(
2365
+ at(content, 6)
2366
+ .map((a) => a.title)
2367
+ .join(),
2368
+ ).not.toContain("finding")
2369
+ })
2370
+ })
2371
+
2372
+ describe("review view drafted comment", () => {
2373
+ const doc = (pointer: string) => `# Review
2374
+
2375
+ ## Findings
2376
+
2377
+ ### Token check
2378
+
2379
+ _correctness · judge: fix_
2380
+
2381
+ ${pointer}Draft PR comment:
2382
+
2383
+ > nit: Uses \`==\`.
2384
+ > Impact: Timing leak.
2385
+ > Fix: Use \`timingSafeEqual\`.
2386
+ >
2387
+ > \`\`\`suggestion
2388
+ > return ok
2389
+ > \`\`\`
2390
+
2391
+ Tick exactly one:
2392
+
2393
+ - [ ] fix as suggested
2394
+ - [ ] won't fix
2395
+ - [ ] _your answer_
2396
+ `
2397
+ for (const [name, pointer] of [
2398
+ ["with a pointer", "- [ ] ./src/auth.ts#1-3\n\n"],
2399
+ ["without a pointer", ""],
2400
+ ] as const) {
2401
+ it(`splits the comment into What, Impact and Fix ${name}`, () => {
2402
+ const node = steeringFormatFor("review")!.view(doc(pointer)).nodes[0]!
2403
+ expect(node.comment?.map((s) => s.label)).toEqual(["What", "Impact", "Fix"])
2404
+ const fix = node.comment![2]!.body
2405
+ expect(fix.some((n) => n.block?.kind === "code" && n.block.text === "return ok")).toBe(true)
2406
+ expect(node.detail).not.toContain("Draft PR comment")
2407
+ })
2408
+ }
2409
+
2410
+ it("keeps an unlabelled comment as one What", () => {
2411
+ const node = steeringFormatFor("review")!.view(
2412
+ doc("")
2413
+ .replace(/Impact:[^]*?```\n\nTick/, "Plain.\n\nTick")
2414
+ .replace("> nit: Uses `==`.\n> ", "> nit: Uses."),
2415
+ ).nodes[0]!
2416
+ expect(node.comment?.map((s) => s.label)).toEqual(["What"])
2417
+ })
2418
+
2419
+ it("keeps a Fix: line inside a suggestion fence in its code block", () => {
2420
+ const node = steeringFormatFor("review")!.view(
2421
+ doc("").replace("> return ok", "> Fix: inside\n> return ok"),
2422
+ ).nodes[0]!
2423
+ expect(node.comment?.map((s) => s.label)).toEqual(["What", "Impact", "Fix"])
2424
+ const code = node.comment![2]!.body.find((n) => n.block?.kind === "code")
2425
+ expect(code?.block?.text).toContain("Fix: inside")
2426
+ })
2427
+
2428
+ it("leaves the comment in the body of a chunk with no answer list", () => {
2429
+ const node = steeringFormatFor("review")!.view(doc("").replace(/Tick exactly one:[^]*$/, ""))
2430
+ .nodes[0]!
2431
+ expect(node.comment).toBeUndefined()
2432
+ expect(node.detail).toContain("Draft PR comment")
2433
+ })
2434
+ })
@@ -37,6 +37,7 @@ import {
37
37
  } from "./MarkdownTree.js"
38
38
  import { FREE_TEXT_PLACEHOLDER, normalizeFreeTextAnswer } from "./qa.js"
39
39
  import type {
40
+ SteeringAction,
40
41
  SteeringAnchor,
41
42
  SteeringAnnotateResult,
42
43
  SteeringEdit,
@@ -46,6 +47,7 @@ import type {
46
47
  SteeringOutlineNode,
47
48
  SteeringView,
48
49
  SteeringViewNode,
50
+ SteeringViewSection,
49
51
  } from "./SteeringFormat.js"
50
52
 
51
53
  interface ReviewFile {
@@ -55,6 +57,8 @@ interface ReviewFile {
55
57
  /** 1-based post-image end line of a `#<start>-<end>` range — `line` holds the start. Absent for a bare `#<line>` pointer or a `#`-less one. */
56
58
  readonly rangeEnd?: number
57
59
  readonly checked: boolean
60
+ /** A finding's location: a plain list item with no tick box. */
61
+ readonly plain?: boolean
58
62
  /** The pointer's explanation: same-line text (if any) first, then the lines gathered from BELOW it, joined with " ". */
59
63
  readonly note?: string
60
64
  /** `note`'s own inline structure, image-collapsed (R2) — feeds the hunk node's `detailInline`. Absent exactly where `note` is. */
@@ -68,10 +72,14 @@ interface ReviewFile {
68
72
  interface Changeset {
69
73
  readonly title: string
70
74
  readonly description: string
75
+ /** `description` without the drafted comment: what the view shows. */
76
+ readonly detail: string
71
77
  /** The same leading run collapsed into `description`, projected as block nodes (`Blocks.ts#blockNodesOfRun`) instead of flattened text — `Review.tsx`'s chunk screen renders these through the shared prose-block rendering. */
72
78
  readonly descriptionNodes: readonly SteeringViewNode[]
73
79
  /** `description`'s own inline structure, image-collapsed (R2) — feeds the chunk node's `detailInline`. */
74
80
  readonly descriptionInline: readonly InlineNode[]
81
+ /** The drafted PR comment (de-quoted markdown) of a decision chunk, when it carries one. */
82
+ readonly comment?: string
75
83
  readonly files: readonly ReviewFile[]
76
84
  /** 0-based index of this chunk's `##` heading. */
77
85
  readonly headingLine: number
@@ -375,6 +383,7 @@ const buildHunkFile = (
375
383
  ...(parsed.line !== undefined ? { line: parsed.line } : {}),
376
384
  ...(parsed.rangeEnd !== undefined ? { rangeEnd: parsed.rangeEnd } : {}),
377
385
  checked: item.checked === true,
386
+ ...(item.checked === null ? { plain: true } : {}),
378
387
  sourceLine,
379
388
  endLine: hunkOwnEndLine(item, sourceLine),
380
389
  ...(note.length > 0 ? { note, noteInline } : {}),
@@ -530,6 +539,31 @@ const answerListOf = (
530
539
  return { lead, list, options }
531
540
  }
532
541
 
542
+ const COMMENT_LABEL_RE = /^(Impact|Fix):[ \t]*/
543
+
544
+ /** Splits a drafted comment at its `Impact:` / `Fix:` lines (outside fences) into labelled block sections; no labels is one What. */
545
+ const commentSections = (comment: string): readonly SteeringViewSection[] => {
546
+ const parts: { label: string; lines: string[] }[] = [{ label: "What", lines: [] }]
547
+ let fence: string | undefined
548
+ for (const line of comment.split("\n")) {
549
+ const marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1]
550
+ if (marker !== undefined) {
551
+ if (fence === undefined) fence = marker
552
+ else if (marker.startsWith(fence)) fence = undefined
553
+ }
554
+ const label = fence === undefined && marker === undefined ? COMMENT_LABEL_RE.exec(line) : null
555
+ if (label) parts.push({ label: label[1]!, lines: [line.slice(label[0].length)] })
556
+ else parts.at(-1)!.lines.push(line)
557
+ }
558
+ return parts
559
+ .map(({ label, lines }) => ({ label, text: lines.join("\n").trim() }))
560
+ .filter(({ text }) => text.length > 0)
561
+ .map(({ label, text }) => ({
562
+ label,
563
+ body: blockNodesOfRun(text, parseMarkdown(text).children),
564
+ }))
565
+ }
566
+
533
567
  /** A leading severity line is the chunk's tier, never its prose. */
534
568
  const splitSeverity = (
535
569
  content: string,
@@ -550,17 +584,32 @@ const splitSeverity = (
550
584
  }
551
585
  }
552
586
 
587
+ /** Every `listItem` under `node`, task or plain, at any depth. */
588
+ const allListItems = (node: RootContent): readonly ListItem[] => {
589
+ const items: ListItem[] = []
590
+ const walk = (n: { type: string; children?: readonly unknown[] }): void => {
591
+ if (n.type === "listItem") items.push(n as unknown as ListItem)
592
+ n.children?.forEach((c) => walk(c as { type: string; children?: readonly unknown[] }))
593
+ }
594
+ walk(node)
595
+ return items
596
+ }
597
+
553
598
  /** Splits one chunk's body nodes into its file pointers (hunk pointers are task items, collected recursively at ANY nesting depth via `taskItems` — a nested hunk is the same kind of hunk as a top-level one) and description prose. The description is the chunk's own leading run and never a node that contains a hunk pointer: it stops at the first node whose `taskItems` include a real pointer (at any depth — a blockquote wrapping a pointer list stops it just as a top-level list would). Every node kind in what's left before that point contributes — a `footnoteDefinition` is the one exception, excluded by `blockNodesOfRun` itself (it's a note, never document content). */
599
+ // fallow-ignore-next-line complexity
554
600
  const parseChunkBody = (
555
601
  content: string,
556
602
  lines: readonly string[],
557
603
  title: string,
558
604
  body: readonly RootContent[],
559
605
  answerable: boolean,
606
+ finding: boolean,
560
607
  ): {
561
608
  readonly description: string
609
+ readonly detail: string
562
610
  readonly descriptionNodes: readonly SteeringViewNode[]
563
611
  readonly descriptionInline: readonly InlineNode[]
612
+ readonly comment?: string
564
613
  readonly files: readonly ReviewFile[]
565
614
  readonly errors: readonly SteeringFinding[]
566
615
  readonly answers?: readonly AnswerOption[]
@@ -573,33 +622,70 @@ const parseChunkBody = (
573
622
  const answer = answerable ? answerListOf(content, body) : undefined
574
623
  const isAnswer = (n: RootContent): boolean =>
575
624
  answer !== undefined && (n === answer.list || n === body[answer.lead])
625
+ const pointerItems = (n: RootContent): readonly ListItem[] =>
626
+ finding ? allListItems(n) : taskItems(n)
576
627
  const firstPointerIndex = body.findIndex(
577
- (n) => !isAnswer(n) && taskItems(n).some((item) => hasPointerToken(content, item)),
628
+ (n) => !isAnswer(n) && pointerItems(n).some((item) => hasPointerToken(content, item)),
578
629
  )
579
630
  const run = (firstPointerIndex === -1 ? body : body.slice(0, firstPointerIndex)).filter(
580
631
  (n) => !isAnswer(n),
581
632
  )
582
- const { severity, leadingRun } = splitSeverity(content, run)
633
+ // The drafted comment: the blockquote right after a `Draft PR comment:` line, wherever the pointer sits.
634
+ const draftIndex =
635
+ answer !== undefined
636
+ ? body.findIndex(
637
+ (n, i) =>
638
+ !isAnswer(n) &&
639
+ n.type === "blockquote" &&
640
+ body[i - 1]?.type === "paragraph" &&
641
+ /^draft pr comment:?$/i.test(sourceText(content, body[i - 1]!).trim()),
642
+ )
643
+ : -1
644
+ const comment =
645
+ draftIndex === -1
646
+ ? undefined
647
+ : content
648
+ .slice(body[draftIndex]!.position!.start.offset!, body[draftIndex]!.position!.end.offset!)
649
+ .split("\n")
650
+ .map((l) => l.replace(/^\s*> ?/, ""))
651
+ .join("\n")
652
+ .trim()
653
+ const draftNodes = new Set(draftIndex === -1 ? [] : [body[draftIndex], body[draftIndex - 1]])
654
+ const { severity, leadingRun: fullRun } = splitSeverity(content, run)
655
+ const leadingRun = fullRun.filter((n) => !draftNodes.has(n))
583
656
  const descriptionNodes = blockNodesOfRun(content, leadingRun)
584
657
  const descriptionInline = collapseImages(blockRunInline(content, leadingRun))
585
- const description = leadingRun
586
- .filter((n) => n.type !== "footnoteDefinition")
587
- .map((n) => sourceText(content, n))
588
- .join(" ")
589
- .trim()
658
+ const textOf = (nodes: readonly RootContent[]): string =>
659
+ nodes
660
+ .filter((n) => n.type !== "footnoteDefinition")
661
+ .map((n) => sourceText(content, n))
662
+ .join(" ")
663
+ .trim()
664
+ // `description` stays whole: it is the decision body gtd hands to the fixer.
665
+ const description = textOf(fullRun)
666
+ const detail = textOf(leadingRun)
590
667
 
591
668
  const files: ReviewFile[] = []
592
669
  const errors: SteeringFinding[] = []
593
- for (const item of body.filter((n) => !isAnswer(n)).flatMap((n) => taskItems(n))) {
670
+ for (const item of body.filter((n) => !isAnswer(n)).flatMap((n) => pointerItems(n))) {
594
671
  const parsed = parseHunk(content, lines, title, item)
595
672
  if (!parsed) continue
673
+ if (finding && item.checked !== null) {
674
+ errors.push({
675
+ message: `Chunk "${title}" finding location ${pointerDisplay(parsed.file)} must be a plain list item — drop the checkbox`,
676
+ line: parsed.file.sourceLine,
677
+ range: lineRange(lines, parsed.file.sourceLine),
678
+ })
679
+ }
596
680
  files.push(parsed.file)
597
681
  errors.push(...parsed.errors)
598
682
  }
599
683
  return {
600
684
  description,
685
+ detail,
601
686
  descriptionNodes,
602
687
  descriptionInline,
688
+ ...(comment === undefined ? {} : { comment }),
603
689
  files,
604
690
  errors,
605
691
  ...(answer === undefined
@@ -690,15 +776,24 @@ const parseChangesets = (
690
776
  const title = headingText(content, heading)
691
777
  const {
692
778
  description,
779
+ detail,
693
780
  descriptionNodes,
694
781
  descriptionInline,
782
+ comment,
695
783
  files,
696
784
  answers,
697
785
  answerLine,
698
786
  severity,
699
787
  boxed,
700
788
  errors: bodyErrors,
701
- } = parseChunkBody(content, lines, title, body, section !== "walkthrough")
789
+ } = parseChunkBody(
790
+ content,
791
+ lines,
792
+ title,
793
+ body,
794
+ section !== "walkthrough",
795
+ section === "findings",
796
+ )
702
797
  errors.push(...bodyErrors)
703
798
  // A prose-only chunk is a note gtd composed; a box with no pointer is a pointer gone wrong.
704
799
  if (files.length === 0 && boxed) {
@@ -711,8 +806,10 @@ const parseChangesets = (
711
806
  changesets.push({
712
807
  title,
713
808
  description,
809
+ detail,
714
810
  descriptionNodes,
715
811
  descriptionInline,
812
+ ...(comment === undefined ? {} : { comment }),
716
813
  files,
717
814
  headingLine,
718
815
  ...(answers === undefined ? {} : { answers, answerLine: answerLine! }),
@@ -878,7 +975,7 @@ const decisionNodes = (
878
975
  }
879
976
 
880
977
  const isOpenChunk = (chunk: Changeset, markers: number): boolean =>
881
- chunk.files.some((file) => !file.checked) ||
978
+ chunk.files.some((file) => !file.plain && !file.checked) ||
882
979
  markers > 0 ||
883
980
  (chunk.answers !== undefined && decisionOf(chunk) === undefined)
884
981
 
@@ -917,7 +1014,7 @@ const reviewOutline = (content: string): readonly SteeringOutlineNode[] => {
917
1014
  if (!listed && !isOpenChunk(chunk, chunkMarkers.length)) return []
918
1015
  const node: SteeringOutlineNode = {
919
1016
  name:
920
- chunk.files.length === 0
1017
+ chunk.files.length === 0 || chunk.section === "findings"
921
1018
  ? chunk.title
922
1019
  : `${chunk.title} (${checkedCount}/${chunk.files.length})`,
923
1020
  range: spanRange(lines, start, end),
@@ -991,6 +1088,39 @@ const footnoteBlockEnd = (content: string, tree: Root, cursorLine: number): numb
991
1088
  return block?.position ? toLspPosition(block.position.end).line : cursorLine
992
1089
  }
993
1090
 
1091
+ const FINDING_ACTIONS = [
1092
+ { title: "gtd: accept finding", index: 0 },
1093
+ { title: "gtd: reject finding", index: 1 },
1094
+ { title: "gtd: answer finding", index: 2 },
1095
+ ] as const
1096
+
1097
+ /** Accept / reject / answer for one finding chunk; the one matching its single current tick is left out. */
1098
+ const findingActions = (
1099
+ content: string,
1100
+ chunk: Changeset,
1101
+ chunkIndex: number,
1102
+ ): Array<{ title: string; edits: readonly SteeringEdit[]; select?: SteeringAction["select"] }> => {
1103
+ if (chunk.answers?.length !== 3) return []
1104
+ const ticked = chunk.answers.flatMap((o, i) => (o.checked ? [i] : []))
1105
+ const current = ticked.length === 1 ? ticked[0] : undefined
1106
+ const items = answerItemsOf(content, chunkIndex)
1107
+ return FINDING_ACTIONS.filter((a) => a.index !== current).flatMap((a) => {
1108
+ const result = decisionEdits(content, chunkIndex, a.index, {})
1109
+ if (!result.ok) return []
1110
+ const slot = items[2]
1111
+ const from = slot ? firstParagraphContentOffset(slot) : undefined
1112
+ const to = slot?.children.find((c) => c.type === "paragraph")?.position?.end.offset
1113
+ const select =
1114
+ a.index === 2 && from !== undefined && to !== undefined
1115
+ ? {
1116
+ start: toLspPositionFromOffset(content, from),
1117
+ end: toLspPositionFromOffset(content, to),
1118
+ }
1119
+ : undefined
1120
+ return [{ title: a.title, edits: result.edits, ...(select ? { select } : {}) }]
1121
+ })
1122
+ }
1123
+
994
1124
  /**
995
1125
  * Actions for `.gtd/REVIEW.md`: "check/uncheck this hunk" when `range` sits
996
1126
  * on a hunk line, "check/uncheck all hunks" when `range` sits anywhere in a
@@ -1014,7 +1144,7 @@ const reviewActions: SteeringFormat["actions"] = (content, range) => {
1014
1144
  const hunk = chunk.files.find(
1015
1145
  (file) => cursorLine >= file.sourceLine && cursorLine <= file.endLine,
1016
1146
  )
1017
- if (hunk) {
1147
+ if (hunk && !hunk.plain) {
1018
1148
  const edit = toggleFilePointer(content, hunk.sourceLine)
1019
1149
  if (edit) {
1020
1150
  actions.push({
@@ -1029,7 +1159,15 @@ const reviewActions: SteeringFormat["actions"] = (content, range) => {
1029
1159
  chunk.section === undefined
1030
1160
  ? Math.max(chunk.headingLine, (changesets[i + 1]?.headingLine ?? lines.length) - 1)
1031
1161
  : (endLines.get(chunk.headingLine) ?? chunk.headingLine)
1032
- if (chunk.files.length > 0 && cursorLine >= chunk.headingLine && cursorLine <= chunkEnd) {
1162
+ if (chunk.section === "findings" && cursorLine >= chunk.headingLine && cursorLine <= chunkEnd) {
1163
+ actions.push(...findingActions(content, chunk, i))
1164
+ }
1165
+ if (
1166
+ chunk.section !== "findings" &&
1167
+ chunk.files.length > 0 &&
1168
+ cursorLine >= chunk.headingLine &&
1169
+ cursorLine <= chunkEnd
1170
+ ) {
1033
1171
  const edits = toggleChunkEdits(content, chunk.headingLine)
1034
1172
  if (edits.length > 0) {
1035
1173
  const checkedCount = chunk.files.filter((file) => file.checked).length
@@ -1113,7 +1251,10 @@ const reviewDocumentLinks = (content: string): readonly SteeringLink[] => {
1113
1251
  const tree = parseMarkdown(content)
1114
1252
  const links: SteeringLink[] = []
1115
1253
  for (const block of splitChunks(tree, content).chunks) {
1116
- for (const item of block.body.flatMap((n) => taskItems(n))) {
1254
+ const items = block.body.flatMap((n) =>
1255
+ block.section === "findings" ? allListItems(n) : taskItems(n),
1256
+ )
1257
+ for (const item of items) {
1117
1258
  const link = hunkLinkFor(content, item)
1118
1259
  if (link) links.push(link)
1119
1260
  }
@@ -1129,9 +1270,10 @@ const reviewView = (content: string): SteeringView => {
1129
1270
  ...(shortHash ? { header: shortHash } : {}),
1130
1271
  nodes: changesets.map((chunk, chunkIndex) => ({
1131
1272
  title: chunk.title,
1132
- detail: chunk.description,
1273
+ detail: chunk.detail,
1133
1274
  detailInline: chunk.descriptionInline,
1134
1275
  body: chunk.descriptionNodes,
1276
+ ...(chunk.comment === undefined ? {} : { comment: commentSections(chunk.comment) }),
1135
1277
  ...(chunk.severity === undefined ? {} : { status: chunk.severity.label }),
1136
1278
  anchor: { kind: "chunk", index: chunkIndex },
1137
1279
  ...(chunk.section === undefined ? {} : { group: REVIEW_SECTIONS[chunk.section] }),
@@ -1151,7 +1293,7 @@ const reviewView = (content: string): SteeringView => {
1151
1293
  path: file.path,
1152
1294
  ...(file.line !== undefined ? { line: file.line } : {}),
1153
1295
  ...(file.rangeEnd !== undefined ? { endLine: file.rangeEnd } : {}),
1154
- checked: file.checked,
1296
+ ...(file.plain ? {} : { checked: file.checked }),
1155
1297
  ...(file.note !== undefined
1156
1298
  ? { detail: file.note, detailInline: file.noteInline ?? [] }
1157
1299
  : {}),
@@ -1339,12 +1481,12 @@ const reviewApply: SteeringFormat["apply"] = (content, anchor, opts) => {
1339
1481
  const checked = opts.checked ?? true
1340
1482
  if (anchor.kind === "hunk") {
1341
1483
  const file = changesets[anchor.chunkIndex]?.files[anchor.index]
1342
- if (!file) return { ok: false, reason: "anchor-not-found" }
1484
+ if (!file || file.plain) return { ok: false, reason: "anchor-not-found" }
1343
1485
  return { ok: true, edits: setFileTickEdits(content, [file], checked) }
1344
1486
  }
1345
1487
  if (anchor.kind === "chunk") {
1346
1488
  const chunk = changesets[anchor.index]
1347
- if (!chunk) return { ok: false, reason: "anchor-not-found" }
1489
+ if (!chunk || chunk.section === "findings") return { ok: false, reason: "anchor-not-found" }
1348
1490
  return { ok: true, edits: setFileTickEdits(content, chunk.files, checked) }
1349
1491
  }
1350
1492
  return { ok: false, reason: "anchor-not-found" }
@@ -12,6 +12,18 @@ export type GitWrite =
12
12
  | { readonly kind: "commitClean"; readonly message: string }
13
13
  | { readonly kind: "updateRef"; readonly ref: string; readonly target: "HEAD" }
14
14
  | { readonly kind: "keepTurn"; readonly ref: string; readonly message: string }
15
+ | { readonly kind: "switchBranch"; readonly branch: string }
16
+ | { readonly kind: "switchTo"; readonly branch: string }
17
+ | { readonly kind: "pushRef"; readonly remote: string; readonly ref: string }
18
+ | ({ readonly kind: "ship"; readonly message: string } & ShipSpec)
19
+
20
+ /** A ship step's squash: `shipMessage` on `branch`, CAS against `parent`, its `Gtd-History` naming `historyRef`. */
21
+ export interface ShipSpec {
22
+ readonly branch: string
23
+ readonly parent: string
24
+ readonly shipMessage: string
25
+ readonly historyRef: string
26
+ }
15
27
 
16
28
  /** A landed step's trailing report, rendered by `GitScript.ts` into a `printf` line. */
17
29
  export type Outcome =
@@ -1,5 +1,6 @@
1
1
  import type { CommitSpec } from "../replay/index.js"
2
2
  import type { PendingChange, StateName, StepDef } from "../workflow/index.js"
3
+ import type { ShipSpec } from "./LandStep.js"
3
4
 
4
5
  /**
5
6
  * What landing the pending turn does, decided at the edge by replaying the
@@ -19,8 +20,22 @@ export type Landing =
19
20
  readonly keepHistory?: string
20
21
  /** The kept ref this landing's turn goes to; the branch gets a marker commit instead. */
21
22
  readonly keepTurn?: string
23
+ /** The `gtd/<b>` branch a starting landing switches to before it commits. */
24
+ readonly startBranch?: string
25
+ /** The user's branch `<b>` a finishing landing switches back to, after its history ref. */
26
+ readonly leaveBranch?: string
27
+ /** A remote the closing or shipping landing force-pushes its history ref to, after the commit. */
28
+ readonly pushHistory?: string
29
+ /** A ship step's squash onto the user's branch, made ahead of the landing commit. */
30
+ readonly ship?: ShipSpec
31
+ /** The history ref of a landing that only records an earlier ship (`ship` is unset). */
32
+ readonly recordedHistory?: string
33
+ /** Printed after the landing: why a ship step shipped nothing. */
34
+ readonly note?: string
22
35
  /** The one path this landing commits; every other pending change stays pending. */
23
36
  readonly only?: string
37
+ /** Review-gate landing into an agent turn: the human's ticks are committed. */
38
+ readonly keepTicks?: boolean
24
39
  }
25
40
 
26
41
  /**
package/src/step/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { planStep, type JudgeVerdict } from "./planStep.js"
2
2
  export { accessRefusal } from "./accessRefusal.js"
3
3
  export { planStart } from "./planStart.js"
4
+ export { startBranchFor, GTD_BRANCH_PREFIX, userBranchOf } from "./startBranch.js"
4
5
  export type { GitWrite, LandStep, Outcome } from "./LandStep.js"
5
6
  export type { Landing, RepoSnapshot } from "./RepoSnapshot.js"
6
7
  export { strayPaths } from "./strayPaths.js"