@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
@@ -15461,6 +15461,7 @@ const buildHunkFile = (content, item, paragraph, token, sourceLine) => {
15461
15461
  ...parsed.line !== void 0 ? { line: parsed.line } : {},
15462
15462
  ...parsed.rangeEnd !== void 0 ? { rangeEnd: parsed.rangeEnd } : {},
15463
15463
  checked: item.checked === true,
15464
+ ...item.checked === null ? { plain: true } : {},
15464
15465
  sourceLine,
15465
15466
  endLine: hunkOwnEndLine(item, sourceLine),
15466
15467
  ...note.length > 0 ? {
@@ -15567,6 +15568,35 @@ const answerListOf = (content, body) => {
15567
15568
  })
15568
15569
  };
15569
15570
  };
15571
+ const COMMENT_LABEL_RE = /^(Impact|Fix):[ \t]*/;
15572
+ /** Splits a drafted comment at its `Impact:` / `Fix:` lines (outside fences) into labelled block sections; no labels is one What. */
15573
+ const commentSections = (comment) => {
15574
+ const parts = [{
15575
+ label: "What",
15576
+ lines: []
15577
+ }];
15578
+ let fence;
15579
+ for (const line of comment.split("\n")) {
15580
+ const marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1];
15581
+ if (marker !== void 0) {
15582
+ if (fence === void 0) fence = marker;
15583
+ else if (marker.startsWith(fence)) fence = void 0;
15584
+ }
15585
+ const label = fence === void 0 && marker === void 0 ? COMMENT_LABEL_RE.exec(line) : null;
15586
+ if (label) parts.push({
15587
+ label: label[1],
15588
+ lines: [line.slice(label[0].length)]
15589
+ });
15590
+ else parts.at(-1).lines.push(line);
15591
+ }
15592
+ return parts.map(({ label, lines }) => ({
15593
+ label,
15594
+ text: lines.join("\n").trim()
15595
+ })).filter(({ text }) => text.length > 0).map(({ label, text }) => ({
15596
+ label,
15597
+ body: blockNodesOfRun(text, parseMarkdown(text).children)
15598
+ }));
15599
+ };
15570
15600
  /** A leading severity line is the chunk's tier, never its prose. */
15571
15601
  const splitSeverity = (content, run) => {
15572
15602
  const first = run[0];
@@ -15580,28 +15610,52 @@ const splitSeverity = (content, run) => {
15580
15610
  leadingRun: run.slice(1)
15581
15611
  };
15582
15612
  };
15613
+ /** Every `listItem` under `node`, task or plain, at any depth. */
15614
+ const allListItems = (node) => {
15615
+ const items = [];
15616
+ const walk = (n) => {
15617
+ if (n.type === "listItem") items.push(n);
15618
+ n.children?.forEach((c) => walk(c));
15619
+ };
15620
+ walk(node);
15621
+ return items;
15622
+ };
15583
15623
  /** 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). */
15584
- const parseChunkBody = (content, lines, title, body, answerable) => {
15624
+ const parseChunkBody = (content, lines, title, body, answerable, finding) => {
15585
15625
  const answer = answerable ? answerListOf(content, body) : void 0;
15586
15626
  const isAnswer = (n) => answer !== void 0 && (n === answer.list || n === body[answer.lead]);
15587
- const firstPointerIndex = body.findIndex((n) => !isAnswer(n) && taskItems(n).some((item) => hasPointerToken(content, item)));
15627
+ const pointerItems = (n) => finding ? allListItems(n) : taskItems(n);
15628
+ const firstPointerIndex = body.findIndex((n) => !isAnswer(n) && pointerItems(n).some((item) => hasPointerToken(content, item)));
15588
15629
  const run = (firstPointerIndex === -1 ? body : body.slice(0, firstPointerIndex)).filter((n) => !isAnswer(n));
15589
- const { severity, leadingRun } = splitSeverity(content, run);
15630
+ const draftIndex = answer !== void 0 ? body.findIndex((n, i) => !isAnswer(n) && n.type === "blockquote" && body[i - 1]?.type === "paragraph" && /^draft pr comment:?$/i.test(sourceText(content, body[i - 1]).trim())) : -1;
15631
+ const comment = draftIndex === -1 ? void 0 : content.slice(body[draftIndex].position.start.offset, body[draftIndex].position.end.offset).split("\n").map((l) => l.replace(/^\s*> ?/, "")).join("\n").trim();
15632
+ const draftNodes = new Set(draftIndex === -1 ? [] : [body[draftIndex], body[draftIndex - 1]]);
15633
+ const { severity, leadingRun: fullRun } = splitSeverity(content, run);
15634
+ const leadingRun = fullRun.filter((n) => !draftNodes.has(n));
15590
15635
  const descriptionNodes = blockNodesOfRun(content, leadingRun);
15591
15636
  const descriptionInline = collapseImages(blockRunInline(content, leadingRun));
15592
- const description = leadingRun.filter((n) => n.type !== "footnoteDefinition").map((n) => sourceText(content, n)).join(" ").trim();
15637
+ const textOf = (nodes) => nodes.filter((n) => n.type !== "footnoteDefinition").map((n) => sourceText(content, n)).join(" ").trim();
15638
+ const description = textOf(fullRun);
15639
+ const detail = textOf(leadingRun);
15593
15640
  const files = [];
15594
15641
  const errors = [];
15595
- for (const item of body.filter((n) => !isAnswer(n)).flatMap((n) => taskItems(n))) {
15642
+ for (const item of body.filter((n) => !isAnswer(n)).flatMap((n) => pointerItems(n))) {
15596
15643
  const parsed = parseHunk(content, lines, title, item);
15597
15644
  if (!parsed) continue;
15645
+ if (finding && item.checked !== null) errors.push({
15646
+ message: `Chunk "${title}" finding location ${pointerDisplay(parsed.file)} must be a plain list item — drop the checkbox`,
15647
+ line: parsed.file.sourceLine,
15648
+ range: lineRange(lines, parsed.file.sourceLine)
15649
+ });
15598
15650
  files.push(parsed.file);
15599
15651
  errors.push(...parsed.errors);
15600
15652
  }
15601
15653
  return {
15602
15654
  description,
15655
+ detail,
15603
15656
  descriptionNodes,
15604
15657
  descriptionInline,
15658
+ ...comment === void 0 ? {} : { comment },
15605
15659
  files,
15606
15660
  errors,
15607
15661
  ...answer === void 0 ? {} : {
@@ -15675,7 +15729,7 @@ const parseChangesets = (tree, content, lines) => {
15675
15729
  const errors = [];
15676
15730
  for (const { heading, headingLine, body, section } of splitChunks(tree, content).chunks) {
15677
15731
  const title = headingText$1(content, heading);
15678
- const { description, descriptionNodes, descriptionInline, files, answers, answerLine, severity, boxed, errors: bodyErrors } = parseChunkBody(content, lines, title, body, section !== "walkthrough");
15732
+ const { description, detail, descriptionNodes, descriptionInline, comment, files, answers, answerLine, severity, boxed, errors: bodyErrors } = parseChunkBody(content, lines, title, body, section !== "walkthrough", section === "findings");
15679
15733
  errors.push(...bodyErrors);
15680
15734
  if (files.length === 0 && boxed) errors.push({
15681
15735
  message: `Chunk "${title}" has no file pointers`,
@@ -15685,8 +15739,10 @@ const parseChangesets = (tree, content, lines) => {
15685
15739
  changesets.push({
15686
15740
  title,
15687
15741
  description,
15742
+ detail,
15688
15743
  descriptionNodes,
15689
15744
  descriptionInline,
15745
+ ...comment === void 0 ? {} : { comment },
15690
15746
  files,
15691
15747
  headingLine,
15692
15748
  ...answers === void 0 ? {} : {
@@ -15832,7 +15888,7 @@ const decisionNodes = (lines, chunk, end) => {
15832
15888
  selectionRange: lineRange(lines, chunk.answerLine)
15833
15889
  }];
15834
15890
  };
15835
- const isOpenChunk = (chunk, markers) => chunk.files.some((file) => !file.checked) || markers > 0 || chunk.answers !== void 0 && decisionOf(chunk) === void 0;
15891
+ const isOpenChunk = (chunk, markers) => chunk.files.some((file) => !file.plain && !file.checked) || markers > 0 || chunk.answers !== void 0 && decisionOf(chunk) === void 0;
15836
15892
  /**
15837
15893
  * Document outline for `.gtd/REVIEW.md`: the chunks still left to review — an
15838
15894
  * unchecked hunk, a footnote (so a human's comment never vanishes with the
@@ -15863,7 +15919,7 @@ const reviewOutline = (content) => {
15863
15919
  ];
15864
15920
  if (!(sections.length > 0 && chunk.section === void 0) && !isOpenChunk(chunk, chunkMarkers.length)) return [];
15865
15921
  const node = {
15866
- name: chunk.files.length === 0 ? chunk.title : `${chunk.title} (${checkedCount}/${chunk.files.length})`,
15922
+ name: chunk.files.length === 0 || chunk.section === "findings" ? chunk.title : `${chunk.title} (${checkedCount}/${chunk.files.length})`,
15867
15923
  range: spanRange(lines, start, end),
15868
15924
  selectionRange: lineRange(lines, start),
15869
15925
  ...children.length > 0 ? { children } : {}
@@ -15926,6 +15982,43 @@ const footnoteBlockEnd = (content, tree, cursorLine) => {
15926
15982
  const block = blockNodeAt(tree, cursorLine);
15927
15983
  return block?.position ? toLspPosition(block.position.end).line : cursorLine;
15928
15984
  };
15985
+ const FINDING_ACTIONS = [
15986
+ {
15987
+ title: "gtd: accept finding",
15988
+ index: 0
15989
+ },
15990
+ {
15991
+ title: "gtd: reject finding",
15992
+ index: 1
15993
+ },
15994
+ {
15995
+ title: "gtd: answer finding",
15996
+ index: 2
15997
+ }
15998
+ ];
15999
+ /** Accept / reject / answer for one finding chunk; the one matching its single current tick is left out. */
16000
+ const findingActions = (content, chunk, chunkIndex) => {
16001
+ if (chunk.answers?.length !== 3) return [];
16002
+ const ticked = chunk.answers.flatMap((o, i) => o.checked ? [i] : []);
16003
+ const current = ticked.length === 1 ? ticked[0] : void 0;
16004
+ const items = answerItemsOf(content, chunkIndex);
16005
+ return FINDING_ACTIONS.filter((a) => a.index !== current).flatMap((a) => {
16006
+ const result = decisionEdits(content, chunkIndex, a.index, {});
16007
+ if (!result.ok) return [];
16008
+ const slot = items[2];
16009
+ const from = slot ? firstParagraphContentOffset(slot) : void 0;
16010
+ const to = slot?.children.find((c) => c.type === "paragraph")?.position?.end.offset;
16011
+ const select = a.index === 2 && from !== void 0 && to !== void 0 ? {
16012
+ start: toLspPositionFromOffset(content, from),
16013
+ end: toLspPositionFromOffset(content, to)
16014
+ } : void 0;
16015
+ return [{
16016
+ title: a.title,
16017
+ edits: result.edits,
16018
+ ...select ? { select } : {}
16019
+ }];
16020
+ });
16021
+ };
15929
16022
  /**
15930
16023
  * Actions for `.gtd/REVIEW.md`: "check/uncheck this hunk" when `range` sits
15931
16024
  * on a hunk line, "check/uncheck all hunks" when `range` sits anywhere in a
@@ -15943,7 +16036,7 @@ const reviewActions = (content, range) => {
15943
16036
  const actions = [...threadReplyActions(content, range.start)];
15944
16037
  changesets.forEach((chunk, i) => {
15945
16038
  const hunk = chunk.files.find((file) => cursorLine >= file.sourceLine && cursorLine <= file.endLine);
15946
- if (hunk) {
16039
+ if (hunk && !hunk.plain) {
15947
16040
  const edit = toggleFilePointer(content, hunk.sourceLine);
15948
16041
  if (edit) actions.push({
15949
16042
  title: hunk.checked ? "gtd: uncheck this hunk" : "gtd: check this hunk",
@@ -15951,7 +16044,8 @@ const reviewActions = (content, range) => {
15951
16044
  });
15952
16045
  }
15953
16046
  const chunkEnd = chunk.section === void 0 ? Math.max(chunk.headingLine, (changesets[i + 1]?.headingLine ?? lines.length) - 1) : endLines.get(chunk.headingLine) ?? chunk.headingLine;
15954
- if (chunk.files.length > 0 && cursorLine >= chunk.headingLine && cursorLine <= chunkEnd) {
16047
+ if (chunk.section === "findings" && cursorLine >= chunk.headingLine && cursorLine <= chunkEnd) actions.push(...findingActions(content, chunk, i));
16048
+ if (chunk.section !== "findings" && chunk.files.length > 0 && cursorLine >= chunk.headingLine && cursorLine <= chunkEnd) {
15955
16049
  const edits = toggleChunkEdits(content, chunk.headingLine);
15956
16050
  if (edits.length > 0) {
15957
16051
  const checkedCount = chunk.files.filter((file) => file.checked).length;
@@ -16018,9 +16112,12 @@ const hunkLinkFor = (content, item) => {
16018
16112
  const reviewDocumentLinks = (content) => {
16019
16113
  const tree = parseMarkdown(content);
16020
16114
  const links = [];
16021
- for (const block of splitChunks(tree, content).chunks) for (const item of block.body.flatMap((n) => taskItems(n))) {
16022
- const link = hunkLinkFor(content, item);
16023
- if (link) links.push(link);
16115
+ for (const block of splitChunks(tree, content).chunks) {
16116
+ const items = block.body.flatMap((n) => block.section === "findings" ? allListItems(n) : taskItems(n));
16117
+ for (const item of items) {
16118
+ const link = hunkLinkFor(content, item);
16119
+ if (link) links.push(link);
16120
+ }
16024
16121
  }
16025
16122
  return links;
16026
16123
  };
@@ -16032,9 +16129,10 @@ const reviewView = (content) => {
16032
16129
  ...shortHash ? { header: shortHash } : {},
16033
16130
  nodes: changesets.map((chunk, chunkIndex) => ({
16034
16131
  title: chunk.title,
16035
- detail: chunk.description,
16132
+ detail: chunk.detail,
16036
16133
  detailInline: chunk.descriptionInline,
16037
16134
  body: chunk.descriptionNodes,
16135
+ ...chunk.comment === void 0 ? {} : { comment: commentSections(chunk.comment) },
16038
16136
  ...chunk.severity === void 0 ? {} : { status: chunk.severity.label },
16039
16137
  anchor: {
16040
16138
  kind: "chunk",
@@ -16059,7 +16157,7 @@ const reviewView = (content) => {
16059
16157
  path: file.path,
16060
16158
  ...file.line !== void 0 ? { line: file.line } : {},
16061
16159
  ...file.rangeEnd !== void 0 ? { endLine: file.rangeEnd } : {},
16062
- checked: file.checked,
16160
+ ...file.plain ? {} : { checked: file.checked },
16063
16161
  ...file.note !== void 0 ? {
16064
16162
  detail: file.note,
16065
16163
  detailInline: file.noteInline ?? []
@@ -16214,7 +16312,7 @@ const reviewApply = (content, anchor, opts) => {
16214
16312
  const checked = opts.checked ?? true;
16215
16313
  if (anchor.kind === "hunk") {
16216
16314
  const file = changesets[anchor.chunkIndex]?.files[anchor.index];
16217
- if (!file) return {
16315
+ if (!file || file.plain) return {
16218
16316
  ok: false,
16219
16317
  reason: "anchor-not-found"
16220
16318
  };
@@ -16225,7 +16323,7 @@ const reviewApply = (content, anchor, opts) => {
16225
16323
  }
16226
16324
  if (anchor.kind === "chunk") {
16227
16325
  const chunk = changesets[anchor.index];
16228
- if (!chunk) return {
16326
+ if (!chunk || chunk.section === "findings") return {
16229
16327
  ok: false,
16230
16328
  reason: "anchor-not-found"
16231
16329
  };