@pmelab/gtd 65.0.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 +3 -2
- package/claude/hooks/drive.ts +10 -1
- package/claude/hooks/git.ts +2 -2
- package/claude/hooks/pr.ts +80 -20
- package/claude/hooks/register.tsx +19 -4
- package/dist/chunks/{flows-DGs0W3HI.mjs → flows-CmQplvh1.mjs} +134 -40
- package/dist/chunks/{steering-DEh_MS40.mjs → steering-CMrePUiY.mjs} +115 -17
- package/dist/chunks/{testkit-CgrLmpdJ.mjs → testkit-BJhAAVGt.mjs} +179 -34
- package/dist/chunks/{wire-BNS8ciNu.mjs → wire-xTbzFc26.mjs} +7 -3
- package/dist/flows.mjs +1 -1
- package/dist/gtd.bundle.mjs +430 -100
- package/dist/gtd.core.mjs +147 -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 +1 -1
- package/src/cli/Program.ts +18 -2
- package/src/edge/Edge.ts +93 -11
- package/src/flows/helpers.ts +36 -18
- package/src/flows/index.test.ts +3 -0
- package/src/flows/index.ts +5 -0
- package/src/flows/runtime.ts +123 -16
- 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/SyncIo.ts +2 -0
- package/src/platform/Workspace.test.ts +37 -0
- package/src/platform/Workspace.ts +36 -0
- package/src/replay/Replay.ts +33 -1
- package/src/replay/Trailers.ts +3 -0
- package/src/replay/Tree.ts +5 -0
- 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/RepoSnapshot.ts +4 -0
- package/src/step/planStep.ts +6 -6
- package/src/twins/EmittedScriptRecognizer.ts +1 -1
- package/src/twins/InMemRepo.ts +24 -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 +4 -2
- 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/workflow/Workflow.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/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/RepoSnapshot.ts
CHANGED
|
@@ -28,10 +28,14 @@ export type Landing =
|
|
|
28
28
|
readonly pushHistory?: string
|
|
29
29
|
/** A ship step's squash onto the user's branch, made ahead of the landing commit. */
|
|
30
30
|
readonly ship?: ShipSpec
|
|
31
|
+
/** The history ref of a landing that only records an earlier ship (`ship` is unset). */
|
|
32
|
+
readonly recordedHistory?: string
|
|
31
33
|
/** Printed after the landing: why a ship step shipped nothing. */
|
|
32
34
|
readonly note?: string
|
|
33
35
|
/** The one path this landing commits; every other pending change stays pending. */
|
|
34
36
|
readonly only?: string
|
|
37
|
+
/** Review-gate landing into an agent turn: the human's ticks are committed. */
|
|
38
|
+
readonly keepTicks?: boolean
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
/**
|
package/src/step/planStep.ts
CHANGED
|
@@ -24,11 +24,11 @@ export type StepOutcome =
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* up — a tick is read-progress, never sign-off.
|
|
27
|
+
* Clear ticks at the human review gate, a tick is read-progress. Keep them when
|
|
28
|
+
* the next step is an agent turn: the agent reads them.
|
|
30
29
|
*/
|
|
31
|
-
const uncheckStep = (snapshot: RepoSnapshot): readonly LandStep[] =>
|
|
30
|
+
const uncheckStep = (snapshot: RepoSnapshot, keepTicks: boolean): readonly LandStep[] =>
|
|
31
|
+
!keepTicks &&
|
|
32
32
|
snapshot.stepDef.actor === "human" &&
|
|
33
33
|
snapshot.stepDef.mode === "review" &&
|
|
34
34
|
snapshot.file !== undefined
|
|
@@ -82,7 +82,7 @@ export const planStep = (
|
|
|
82
82
|
...(opts.judge !== undefined && opts.judge.length > 0 ? { judge: opts.judge } : {}),
|
|
83
83
|
})
|
|
84
84
|
const subject = message.split("\n")[0]!
|
|
85
|
-
const historyRef = landing.keepHistory ?? landing.ship?.historyRef
|
|
85
|
+
const historyRef = landing.keepHistory ?? landing.ship?.historyRef ?? landing.recordedHistory
|
|
86
86
|
const outcome: LandStep =
|
|
87
87
|
landing.to === snapshot.state
|
|
88
88
|
? { kind: "outcome", outcome: { kind: "commit", subject } }
|
|
@@ -94,7 +94,7 @@ export const planStep = (
|
|
|
94
94
|
subject,
|
|
95
95
|
steps: [
|
|
96
96
|
...writeIf(landing.startBranch, (branch) => ({ kind: "switchBranch", branch })),
|
|
97
|
-
...uncheckStep(snapshot),
|
|
97
|
+
...uncheckStep(snapshot, landing.keepTicks === true),
|
|
98
98
|
{ kind: "gitWrite", write: gitWriteOf(landing, message) },
|
|
99
99
|
...writeIf(historyRef, (ref) => ({ kind: "updateRef", ref, target: "HEAD" })),
|
|
100
100
|
...writeIf(landing.leaveBranch, (branch) => ({ kind: "switchTo", branch })),
|
|
@@ -681,7 +681,7 @@ const recognizeGtdCheck = (repo: InMemRepo, block: string): BlockOutcome | undef
|
|
|
681
681
|
|
|
682
682
|
const GTD_UNCHECK_RE = /^gtd uncheck (.+)$/
|
|
683
683
|
|
|
684
|
-
/** `gtd uncheck <file>` — the review-gate reset prepended ahead of the human's commit,
|
|
684
|
+
/** `gtd uncheck <file>` — the review-gate reset prepended ahead of the human's commit, absent when the next step is an agent turn. Re-runs the REAL `clearTicks`, writing back only on an actual change. */
|
|
685
685
|
const recognizeGtdUncheck = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
|
|
686
686
|
const match = GTD_UNCHECK_RE.exec(block)
|
|
687
687
|
if (!match) return undefined
|
package/src/twins/InMemRepo.ts
CHANGED
|
@@ -27,6 +27,11 @@ function makeHash(message: string, parent: string | null, tree: Map<string, stri
|
|
|
27
27
|
return sha1(`${message}\n${parent ?? "null"}\n${treeStr}`)
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** An executable file's content carries this prefix inside the twin, so every tree copy keeps the mode for free; only the read edges strip it. */
|
|
31
|
+
const EXEC = "\u0000exec\u0000"
|
|
32
|
+
const plain = (content: string): string =>
|
|
33
|
+
content.startsWith(EXEC) ? content.slice(EXEC.length) : content
|
|
34
|
+
|
|
30
35
|
export class InMemRepo {
|
|
31
36
|
// Data-only marker read by no code path: embeds the sentinel in every
|
|
32
37
|
// instance so a leaked object still carries it.
|
|
@@ -367,7 +372,8 @@ export class InMemRepo {
|
|
|
367
372
|
if (!hash) return null
|
|
368
373
|
const c = this.getCommit(hash)
|
|
369
374
|
if (!c) return null
|
|
370
|
-
|
|
375
|
+
const content = c.files.get(path)
|
|
376
|
+
return content === undefined ? null : plain(content)
|
|
371
377
|
}
|
|
372
378
|
|
|
373
379
|
/**
|
|
@@ -400,7 +406,20 @@ export class InMemRepo {
|
|
|
400
406
|
|
|
401
407
|
/** The worktree content of `path`, or `undefined` when absent. A LIVE read — never a snapshot. */
|
|
402
408
|
readFile(path: string): string | undefined {
|
|
403
|
-
|
|
409
|
+
const content = this.worktree.get(path)
|
|
410
|
+
return content === undefined ? undefined : plain(content)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** `chmod +x` / `-x` on a working-tree file. */
|
|
414
|
+
setExecutable(path: string, on: boolean): void {
|
|
415
|
+
const content = this.readFile(path)
|
|
416
|
+
if (content !== undefined) this.worktree.set(path, on ? EXEC + content : content)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Every path's file mode at `ref`, or in the working tree when `ref` is `undefined`. */
|
|
420
|
+
modesAt(ref: string | undefined): Map<string, string> {
|
|
421
|
+
const tree = ref === undefined ? this.worktree : this.treeAt(ref)
|
|
422
|
+
return new Map([...tree].map(([path, c]) => [path, c.startsWith(EXEC) ? "100755" : "100644"]))
|
|
404
423
|
}
|
|
405
424
|
|
|
406
425
|
/** True when `path` is exactly a worktree file, or a directory prefix of one (any worktree key starts with `path/`). */
|
|
@@ -673,7 +692,9 @@ export class InMemRepo {
|
|
|
673
692
|
}
|
|
674
693
|
|
|
675
694
|
writeFile(path: string, content: string): void {
|
|
676
|
-
|
|
695
|
+
// A live overwrite keeps the mode.
|
|
696
|
+
const was = this.worktree.get(path)
|
|
697
|
+
this.worktree.set(path, was?.startsWith(EXEC) ? EXEC + content : content)
|
|
677
698
|
}
|
|
678
699
|
|
|
679
700
|
deleteFile(path: string): void {
|
package/src/twins/Layers.ts
CHANGED
|
@@ -119,6 +119,7 @@ export const makeInMemoryWorkspaceOps = (repo: InMemRepo, root: string): Workspa
|
|
|
119
119
|
Effect.succeed(
|
|
120
120
|
new Map([...(base !== undefined ? [base] : []), ...commits].map((h) => [h, treeOf(h)])),
|
|
121
121
|
),
|
|
122
|
+
modeSync: (ref) => repo.modesAt(ref),
|
|
122
123
|
worktreeSync: () =>
|
|
123
124
|
new Map(
|
|
124
125
|
repo
|
package/src/twins/SyncIo.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process"
|
|
2
|
-
import { mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"
|
|
2
|
+
import { chmodSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"
|
|
3
3
|
import { tmpdir } from "node:os"
|
|
4
4
|
import { join } from "node:path"
|
|
5
5
|
import { afterEach, describe, expect, it } from "vitest"
|
|
@@ -12,6 +12,7 @@ interface Tier {
|
|
|
12
12
|
readonly root: string
|
|
13
13
|
readonly uid: number | undefined
|
|
14
14
|
readonly symlink: (path: string, target: string) => void
|
|
15
|
+
readonly chmod: (path: string, executable: boolean) => void
|
|
15
16
|
/** Commits `files`, repository-relative, as the only change. */
|
|
16
17
|
readonly commit: (files: Record<string, string>) => void
|
|
17
18
|
}
|
|
@@ -29,6 +30,7 @@ const liveTier = (): Tier => {
|
|
|
29
30
|
root,
|
|
30
31
|
uid: process.getuid?.(),
|
|
31
32
|
symlink: (path, target) => symlinkSync(target, path),
|
|
33
|
+
chmod: (path, executable) => chmodSync(path, executable ? 0o755 : 0o644),
|
|
32
34
|
commit: (files) => {
|
|
33
35
|
for (const [path, content] of Object.entries(files))
|
|
34
36
|
nodeSyncIo.writeText(join(root, path), content)
|
|
@@ -48,6 +50,7 @@ const inMemTier = (): Tier => {
|
|
|
48
50
|
root,
|
|
49
51
|
uid: 501,
|
|
50
52
|
symlink: io.symlink,
|
|
53
|
+
chmod: io.setExecutable,
|
|
51
54
|
commit: (files) => {
|
|
52
55
|
for (const [path, content] of Object.entries(files)) {
|
|
53
56
|
io.writeText(`${root}/${path}`, content)
|
|
@@ -106,9 +109,9 @@ describe.each([
|
|
|
106
109
|
symlink(`${root}/link`, "dir")
|
|
107
110
|
symlink(`${root}/dangling`, "nowhere")
|
|
108
111
|
|
|
109
|
-
expect(io.stat(`${root}/dir/f`)).
|
|
110
|
-
expect(io.stat(`${root}/link`)).
|
|
111
|
-
expect(io.lstat(`${root}/link`)).
|
|
112
|
+
expect(io.stat(`${root}/dir/f`)).toMatchObject({ type: "file", uid })
|
|
113
|
+
expect(io.stat(`${root}/link`)).toMatchObject({ type: "directory", uid })
|
|
114
|
+
expect(io.lstat(`${root}/link`)).toMatchObject({ type: "symlink", uid })
|
|
112
115
|
expect(io.lstat(`${root}/missing`)).toBeUndefined()
|
|
113
116
|
expect(io.stat(`${root}/dangling`)).toBeUndefined()
|
|
114
117
|
expect(io.readLink(`${root}/link`)).toBe("dir")
|
|
@@ -117,6 +120,18 @@ describe.each([
|
|
|
117
120
|
expect(() => io.realPath(`${root}/dangling`)).toThrow()
|
|
118
121
|
})
|
|
119
122
|
|
|
123
|
+
it("reports the executable bit of files, kept across an overwrite", () => {
|
|
124
|
+
const { io, root, chmod } = makeTier()
|
|
125
|
+
io.writeText(`${root}/f`, "x")
|
|
126
|
+
expect(io.stat(`${root}/f`)?.executable).toBe(false)
|
|
127
|
+
chmod(`${root}/f`, true)
|
|
128
|
+
expect(io.stat(`${root}/f`)?.executable).toBe(true)
|
|
129
|
+
io.writeText(`${root}/f`, "y")
|
|
130
|
+
expect(io.lstat(`${root}/f`)?.executable).toBe(true)
|
|
131
|
+
chmod(`${root}/f`, false)
|
|
132
|
+
expect(io.stat(`${root}/f`)?.executable).toBe(false)
|
|
133
|
+
})
|
|
134
|
+
|
|
120
135
|
it("reads committed content through git", () => {
|
|
121
136
|
const { io, root, commit } = makeTier()
|
|
122
137
|
commit({ "a.txt": "committed\n" })
|
package/src/twins/SyncIo.ts
CHANGED
|
@@ -15,6 +15,8 @@ const decoder = new TextDecoder("utf-8", { ignoreBOM: true })
|
|
|
15
15
|
|
|
16
16
|
export interface InMemorySyncIo extends SyncIo {
|
|
17
17
|
readonly symlink: (path: string, target: string) => void
|
|
18
|
+
/** `chmod +x` / `-x` on a file. */
|
|
19
|
+
readonly setExecutable: (path: string, on: boolean) => void
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
/**
|
|
@@ -27,6 +29,7 @@ export const inMemorySyncIo = (
|
|
|
27
29
|
const files = new Map<string, Uint8Array>()
|
|
28
30
|
const links = new Map<string, string>()
|
|
29
31
|
const dirs = new Set<string>(["/"])
|
|
32
|
+
const execs = new Set<string>()
|
|
30
33
|
|
|
31
34
|
/** `path` with every symlink resolved, the last component's only when `followLast`. */
|
|
32
35
|
const resolve = (path: string, followLast: boolean): string => {
|
|
@@ -68,7 +71,9 @@ export const inMemorySyncIo = (
|
|
|
68
71
|
|
|
69
72
|
const info = (path: string): FileInfo | undefined => {
|
|
70
73
|
const type = typeOf(path)
|
|
71
|
-
return type === undefined
|
|
74
|
+
return type === undefined
|
|
75
|
+
? undefined
|
|
76
|
+
: { type, uid: opts.uid, ...(type === "file" ? { executable: execs.has(path) } : {}) }
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
const readFile = (path: string): Uint8Array | undefined => {
|
|
@@ -136,6 +141,8 @@ export const inMemorySyncIo = (
|
|
|
136
141
|
links.delete(target)
|
|
137
142
|
files.set(target, bytes)
|
|
138
143
|
files.delete(source)
|
|
144
|
+
execs.delete(target)
|
|
145
|
+
if (execs.delete(source)) execs.add(target)
|
|
139
146
|
},
|
|
140
147
|
stat: (path) => info(resolve(path, true)),
|
|
141
148
|
lstat: (path) => info(resolve(path, false)),
|
|
@@ -152,6 +159,11 @@ export const inMemorySyncIo = (
|
|
|
152
159
|
git,
|
|
153
160
|
gitBytes: (cwd, args) => encoder.encode(git(cwd, args)),
|
|
154
161
|
hash: pureHash,
|
|
162
|
+
setExecutable: (path, on) => {
|
|
163
|
+
const real = resolve(path, true)
|
|
164
|
+
if (on) execs.add(real)
|
|
165
|
+
else execs.delete(real)
|
|
166
|
+
},
|
|
155
167
|
symlink: (path, target) => {
|
|
156
168
|
links.set(resolve(path, false), target)
|
|
157
169
|
},
|
|
@@ -31,6 +31,7 @@ interface DocumentInput {
|
|
|
31
31
|
readonly log?: string
|
|
32
32
|
readonly session?: { readonly id: string; readonly resume: boolean }
|
|
33
33
|
readonly validate?: string
|
|
34
|
+
readonly fresh?: string | null
|
|
34
35
|
readonly changes?: readonly StatusChange[]
|
|
35
36
|
readonly next?: NextMatch | null
|
|
36
37
|
readonly cost?: number
|
|
@@ -47,6 +48,7 @@ const demandInput = (
|
|
|
47
48
|
kind: input.kind,
|
|
48
49
|
...(input.session !== undefined ? { session: input.session } : {}),
|
|
49
50
|
...(input.validate !== undefined ? { validate: input.validate } : {}),
|
|
51
|
+
...(input.fresh !== undefined ? { fresh: input.fresh } : {}),
|
|
50
52
|
})
|
|
51
53
|
|
|
52
54
|
/** `statusOf`'s own input, isolated for the same reason as `demandInput` above. */
|
|
@@ -84,6 +86,7 @@ describe("beatDocument / renderBeatJson", () => {
|
|
|
84
86
|
content: "fix it",
|
|
85
87
|
idle: false,
|
|
86
88
|
initial: false,
|
|
89
|
+
fresh: null,
|
|
87
90
|
log: ".git/gtd-loop.log",
|
|
88
91
|
state: "build.fixing",
|
|
89
92
|
actor: "agent",
|
|
@@ -138,6 +141,16 @@ describe("beatDocument / renderBeatJson", () => {
|
|
|
138
141
|
expect(parsed.validate).toBe("gtd check qa 'TODO.md'")
|
|
139
142
|
})
|
|
140
143
|
|
|
144
|
+
it("carries fresh at kind prompt and null at every other kind", () => {
|
|
145
|
+
const parsed = JSON.parse(renderJsonLine({ kind: "prompt", fresh: "p\n\nc" })) as {
|
|
146
|
+
fresh: unknown
|
|
147
|
+
}
|
|
148
|
+
expect(parsed.fresh).toBe("p\n\nc")
|
|
149
|
+
for (const kind of BEAT_KINDS.filter((k) => k !== "prompt")) {
|
|
150
|
+
expect((JSON.parse(renderJsonLine({ kind })) as { fresh: unknown }).fresh).toBeNull()
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
141
154
|
it("omits session/validate at kind prompt when the caller supplied none, never emitting null", () => {
|
|
142
155
|
const line = renderJsonLine({ kind: "prompt" })
|
|
143
156
|
const parsed = JSON.parse(line) as Record<string, unknown>
|
|
@@ -256,6 +269,7 @@ describe("beatDocument / renderBeatJson", () => {
|
|
|
256
269
|
"model",
|
|
257
270
|
"system",
|
|
258
271
|
"validate",
|
|
272
|
+
"fresh",
|
|
259
273
|
"log",
|
|
260
274
|
"state",
|
|
261
275
|
"actor",
|
|
@@ -395,6 +409,7 @@ describe("golden bytes", () => {
|
|
|
395
409
|
content: "fix it",
|
|
396
410
|
idle: false,
|
|
397
411
|
initial: false,
|
|
412
|
+
fresh: null,
|
|
398
413
|
log: ".git/gtd-loop.log",
|
|
399
414
|
state: "build.fixing",
|
|
400
415
|
actor: "agent",
|