@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +35 -25
- package/claude/hooks/assist.ts +9 -35
- package/claude/hooks/doors.ts +4 -4
- package/claude/hooks/drive.ts +10 -1
- package/claude/hooks/git.ts +56 -0
- package/claude/hooks/handoff.ts +11 -11
- package/claude/hooks/jobs.ts +8 -8
- package/claude/hooks/pr.ts +86 -26
- package/claude/hooks/register.tsx +35 -82
- package/claude/hooks/tools.ts +7 -20
- package/claude/hooks/wording.ts +3 -3
- package/dist/chunks/{flows-dnIE_qbe.mjs → flows-CmQplvh1.mjs} +146 -35
- package/dist/chunks/{steering-DEh_MS40.mjs → steering-CMrePUiY.mjs} +115 -17
- package/dist/chunks/{testkit-BUX-47TD.mjs → testkit-BJhAAVGt.mjs} +905 -99
- package/dist/chunks/{wire-CaPJ9Tme.mjs → wire-xTbzFc26.mjs} +12 -6
- package/dist/flows.mjs +1 -1
- package/dist/gtd.bundle.mjs +965 -163
- package/dist/gtd.core.mjs +150 -134
- package/dist/steering.mjs +1 -1
- package/dist/testkit/gherkin.mjs +1 -1
- package/dist/testkit.mjs +2 -2
- package/dist/web/main.css +1 -1
- package/dist/web/main.js +185 -18
- package/dist/wire.mjs +1 -1
- package/dist/workflow-kit.mjs +9 -3
- package/package.json +1 -1
- package/src/cli/Cli.ts +5 -5
- package/src/cli/History.test.ts +10 -8
- package/src/cli/Metrics.test.ts +2 -1
- package/src/cli/Program.test.ts +4 -2
- package/src/cli/Program.ts +105 -7
- package/src/edge/Edge.test.ts +37 -16
- package/src/edge/Edge.ts +380 -33
- package/src/emit/GitScript.ts +116 -15
- package/src/emit/index.ts +7 -0
- package/src/flows/config.ts +7 -1
- package/src/flows/helpers.ts +36 -18
- package/src/flows/index.test.ts +4 -0
- package/src/flows/index.ts +7 -0
- package/src/flows/runtime.ts +153 -15
- package/src/install/Install.ts +9 -2
- package/src/platform/Git.test.ts +7 -11
- package/src/platform/NodeSync.ts +2 -0
- package/src/platform/RetainedHistory.ts +3 -0
- package/src/platform/SyncIo.ts +2 -0
- package/src/platform/Workspace.test.ts +37 -0
- package/src/platform/Workspace.ts +36 -0
- package/src/platform/index.ts +1 -0
- package/src/replay/Replay.test.ts +9 -0
- package/src/replay/Replay.ts +92 -16
- package/src/replay/Trailers.ts +17 -0
- package/src/replay/Tree.ts +5 -0
- package/src/replay/index.ts +1 -1
- package/src/steering/FORMATS.md +3 -1
- package/src/steering/SteeringFormat.ts +13 -0
- package/src/steering/fixtures/review/sectioned.md +1 -1
- package/src/steering/fixtures/review/sectioned.read.json +0 -1
- package/src/steering/index.ts +1 -0
- package/src/steering/review.test.ts +181 -5
- package/src/steering/review.ts +160 -18
- package/src/step/LandStep.ts +12 -0
- package/src/step/RepoSnapshot.ts +15 -0
- package/src/step/index.ts +1 -0
- package/src/step/planStart.test.ts +4 -3
- package/src/step/planStart.ts +7 -0
- package/src/step/planStep.ts +22 -17
- package/src/step/startBranch.ts +54 -0
- package/src/twins/EmittedScriptRecognizer.ts +118 -1
- package/src/twins/EmittedScripts.test.ts +147 -1
- package/src/twins/InMemRepo.ts +140 -3
- package/src/twins/Layers.ts +1 -0
- package/src/twins/SyncIo.test.ts +19 -4
- package/src/twins/SyncIo.ts +13 -1
- package/src/wire/BeatDocument.test.ts +15 -0
- package/src/wire/BeatDocument.ts +8 -2
- package/src/wire/Demand.ts +26 -12
- package/src/wire/constants.ts +3 -0
- package/src/wire/golden/1/beat-capture.json +1 -0
- package/src/wire/golden/1/beat-message.json +1 -0
- package/src/wire/golden/1/beat-prompt.json +1 -0
- package/src/wire/golden/1/beat-script.json +1 -0
- package/src/wire/golden/1/beat-stalled.json +1 -0
- package/src/wire/types.ts +4 -0
- package/src/workflow/Workflow.ts +3 -0
- package/src/workflow/compile.ts +2 -0
- package/src/workflow/load.ts +2 -0
- package/src/workflow-kit/index.test.ts +1 -0
- package/src/workflow-kit/index.ts +1 -0
- package/src/workflow-kit/scenarios.ts +8 -0
- 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
|
-
"-
|
|
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
|
-
"-
|
|
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
|
|
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("
|
|
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
|
|
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
|
+
})
|
package/src/steering/review.ts
CHANGED
|
@@ -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) &&
|
|
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
|
-
|
|
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
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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) =>
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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" }
|
package/src/step/LandStep.ts
CHANGED
|
@@ -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 =
|
package/src/step/RepoSnapshot.ts
CHANGED
|
@@ -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"
|