@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.
Files changed (63) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +3 -2
  3. package/claude/hooks/drive.ts +10 -1
  4. package/claude/hooks/git.ts +2 -2
  5. package/claude/hooks/pr.ts +80 -20
  6. package/claude/hooks/register.tsx +19 -4
  7. package/dist/chunks/{flows-DGs0W3HI.mjs → flows-CmQplvh1.mjs} +134 -40
  8. package/dist/chunks/{steering-DEh_MS40.mjs → steering-CMrePUiY.mjs} +115 -17
  9. package/dist/chunks/{testkit-CgrLmpdJ.mjs → testkit-BJhAAVGt.mjs} +179 -34
  10. package/dist/chunks/{wire-BNS8ciNu.mjs → wire-xTbzFc26.mjs} +7 -3
  11. package/dist/flows.mjs +1 -1
  12. package/dist/gtd.bundle.mjs +430 -100
  13. package/dist/gtd.core.mjs +147 -134
  14. package/dist/steering.mjs +1 -1
  15. package/dist/testkit/gherkin.mjs +1 -1
  16. package/dist/testkit.mjs +2 -2
  17. package/dist/web/main.css +1 -1
  18. package/dist/web/main.js +185 -18
  19. package/dist/wire.mjs +1 -1
  20. package/dist/workflow-kit.mjs +9 -3
  21. package/package.json +1 -1
  22. package/src/cli/Cli.ts +1 -1
  23. package/src/cli/Program.ts +18 -2
  24. package/src/edge/Edge.ts +93 -11
  25. package/src/flows/helpers.ts +36 -18
  26. package/src/flows/index.test.ts +3 -0
  27. package/src/flows/index.ts +5 -0
  28. package/src/flows/runtime.ts +123 -16
  29. package/src/install/Install.ts +9 -2
  30. package/src/platform/Git.test.ts +7 -11
  31. package/src/platform/NodeSync.ts +2 -0
  32. package/src/platform/SyncIo.ts +2 -0
  33. package/src/platform/Workspace.test.ts +37 -0
  34. package/src/platform/Workspace.ts +36 -0
  35. package/src/replay/Replay.ts +33 -1
  36. package/src/replay/Trailers.ts +3 -0
  37. package/src/replay/Tree.ts +5 -0
  38. package/src/steering/FORMATS.md +3 -1
  39. package/src/steering/SteeringFormat.ts +13 -0
  40. package/src/steering/fixtures/review/sectioned.md +1 -1
  41. package/src/steering/fixtures/review/sectioned.read.json +0 -1
  42. package/src/steering/index.ts +1 -0
  43. package/src/steering/review.test.ts +181 -5
  44. package/src/steering/review.ts +160 -18
  45. package/src/step/RepoSnapshot.ts +4 -0
  46. package/src/step/planStep.ts +6 -6
  47. package/src/twins/EmittedScriptRecognizer.ts +1 -1
  48. package/src/twins/InMemRepo.ts +24 -3
  49. package/src/twins/Layers.ts +1 -0
  50. package/src/twins/SyncIo.test.ts +19 -4
  51. package/src/twins/SyncIo.ts +13 -1
  52. package/src/wire/BeatDocument.test.ts +15 -0
  53. package/src/wire/BeatDocument.ts +8 -2
  54. package/src/wire/Demand.ts +4 -2
  55. package/src/wire/golden/1/beat-capture.json +1 -0
  56. package/src/wire/golden/1/beat-message.json +1 -0
  57. package/src/wire/golden/1/beat-prompt.json +1 -0
  58. package/src/wire/golden/1/beat-script.json +1 -0
  59. package/src/wire/golden/1/beat-stalled.json +1 -0
  60. package/src/workflow/Workflow.ts +2 -0
  61. package/src/workflow-kit/index.test.ts +1 -0
  62. package/src/workflow-kit/index.ts +1 -0
  63. package/src/workflow-kit/scenarios.ts +8 -0
@@ -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" }
@@ -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
  /**
@@ -24,11 +24,11 @@ export type StepOutcome =
24
24
  }
25
25
 
26
26
  /**
27
- * At the human review gate an unconditional `gtd uncheck '<file>'` runs
28
- * ahead of the commit, resetting every checkbox before `git add -A` picks it
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, so no tick ever reaches it. Re-runs the REAL `clearTicks`, writing back only on an actual change. */
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
@@ -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
- return c.files.get(path) ?? null
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
- return this.worktree.get(path)
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
- this.worktree.set(path, content)
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 {
@@ -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
@@ -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`)).toEqual({ type: "file", uid })
110
- expect(io.stat(`${root}/link`)).toEqual({ type: "directory", uid })
111
- expect(io.lstat(`${root}/link`)).toEqual({ type: "symlink", uid })
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" })
@@ -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 ? undefined : { type, uid: opts.uid }
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",