@shiyuphan/follow-me 0.6.0 → 0.8.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 (101) hide show
  1. package/README.md +7 -1
  2. package/dist/src/cli.js +210 -23
  3. package/dist/src/comments.js +152 -8
  4. package/dist/src/coverage.js +411 -0
  5. package/dist/src/git.js +18 -0
  6. package/dist/src/host.js +27 -0
  7. package/dist/src/plan.js +128 -6
  8. package/dist/src/prompt.js +113 -25
  9. package/dist/src/server.js +115 -11
  10. package/dist/src/session.js +34 -0
  11. package/dist/src/types.js +2 -0
  12. package/dist/ui/dist/assets/{abnfDiagram-WEYOIA3C-fYSYLdMl.js → abnfDiagram-WEYOIA3C-Bp14ygmz.js} +1 -1
  13. package/dist/ui/dist/assets/{arc-DGuGSnwa.js → arc-DUR6uzhI.js} +1 -1
  14. package/dist/ui/dist/assets/{architecture-WOLXFQ4H-VFox_hV1.js → architecture-WOLXFQ4H-CyfnP65O.js} +1 -1
  15. package/dist/ui/dist/assets/{architectureDiagram-ZHVYKT53-CBVK3nHZ.js → architectureDiagram-ZHVYKT53-dJTicIk2.js} +1 -1
  16. package/dist/ui/dist/assets/{blockDiagram-RJAE7QA3-BFdsIWEr.js → blockDiagram-RJAE7QA3-BYiqh5Ib.js} +1 -1
  17. package/dist/ui/dist/assets/{c4Diagram-XDPPPE54-BGntcPId.js → c4Diagram-XDPPPE54-DHHPQ7xE.js} +1 -1
  18. package/dist/ui/dist/assets/channel-CY2cQobm.js +1 -0
  19. package/dist/ui/dist/assets/{chunk-2E7WHVMD-CNMuK9zj.js → chunk-2E7WHVMD-BODm_awL.js} +1 -1
  20. package/dist/ui/dist/assets/{chunk-4EMNKR2T-ByeTTJWS.js → chunk-4EMNKR2T-CpZfjf2T.js} +1 -1
  21. package/dist/ui/dist/assets/{chunk-55XCU76K-99xE7cLH.js → chunk-55XCU76K-CoP2pJTQ.js} +1 -1
  22. package/dist/ui/dist/assets/{chunk-7S6XSTFO-ZVmT7eMZ.js → chunk-7S6XSTFO-CaQcUvXY.js} +1 -1
  23. package/dist/ui/dist/assets/{chunk-AQDTSKZB-B981JORL.js → chunk-AQDTSKZB-BZ9Czvbu.js} +1 -1
  24. package/dist/ui/dist/assets/{chunk-DSNLSHJZ-B2z7FX4h.js → chunk-DSNLSHJZ-CNjUo110.js} +1 -1
  25. package/dist/ui/dist/assets/{chunk-HAGFOTED-CHFbBS-4.js → chunk-HAGFOTED-BTmBE_D3.js} +1 -1
  26. package/dist/ui/dist/assets/{chunk-HNE4Q36P-rq11xv9X.js → chunk-HNE4Q36P-D2cDuT0C.js} +1 -1
  27. package/dist/ui/dist/assets/{chunk-NQOKRVIF-BYmg9kSX.js → chunk-NQOKRVIF-AZcemFgG.js} +1 -1
  28. package/dist/ui/dist/assets/{chunk-UHW7PFBP-JvL9IXWg.js → chunk-UHW7PFBP-BszwjUqG.js} +1 -1
  29. package/dist/ui/dist/assets/{chunk-VQ5W62QI-CYMQaAKn.js → chunk-VQ5W62QI-vFSenYSe.js} +1 -1
  30. package/dist/ui/dist/assets/{chunk-WQQL4CDK-CXnR0PHT.js → chunk-WQQL4CDK-DZR6is1Q.js} +1 -1
  31. package/dist/ui/dist/assets/{chunk-XXDRQBXY-CDUWJCXi.js → chunk-XXDRQBXY-CACFh-si.js} +1 -1
  32. package/dist/ui/dist/assets/{chunk-YZO67HFG-mZfXIx0h.js → chunk-YZO67HFG-DRRTjGuW.js} +1 -1
  33. package/dist/ui/dist/assets/{chunk-ZAUX2LTX-DBxVTCHX.js → chunk-ZAUX2LTX-Bxuy-Roa.js} +1 -1
  34. package/dist/ui/dist/assets/{chunk-ZCHMQSOR-DrWA4IBo.js → chunk-ZCHMQSOR-rYXk__1o.js} +1 -1
  35. package/dist/ui/dist/assets/{classDiagram-v2-4MYWR3UA-Beah5uxA.js → classDiagram-v2-4MYWR3UA-RiF44tTz.js} +1 -1
  36. package/dist/ui/dist/assets/{cose-bilkent-JH36ORCC-UaE-jnHk.js → cose-bilkent-JH36ORCC-0oiQoThS.js} +1 -1
  37. package/dist/ui/dist/assets/{cynefin-EF2NZ3EQ-DKfGaCR8.js → cynefin-EF2NZ3EQ-BAssFK5K.js} +1 -1
  38. package/dist/ui/dist/assets/{cynefinDiagram-WF24UDRP-tTUzTs3D.js → cynefinDiagram-WF24UDRP-Bmd05bak.js} +1 -1
  39. package/dist/ui/dist/assets/{dagre-NK4GIZVX-jSn35_T7.js → dagre-NK4GIZVX-QJkLjPxj.js} +1 -1
  40. package/dist/ui/dist/assets/{diagram-CBK3MWTC-7W2uw8Zc.js → diagram-CBK3MWTC-PSaSiYXz.js} +1 -1
  41. package/dist/ui/dist/assets/{diagram-I5XIIV2D-CcfyQVh9.js → diagram-I5XIIV2D-BmU5X_hr.js} +1 -1
  42. package/dist/ui/dist/assets/{diagram-ODIL2UCW-BjGvaLis.js → diagram-ODIL2UCW-AAV39kUu.js} +1 -1
  43. package/dist/ui/dist/assets/{diagram-PFBEJUXE-jvdCrt8B.js → diagram-PFBEJUXE-DWlV-oxd.js} +1 -1
  44. package/dist/ui/dist/assets/{diagram-RJA3I4VV-D65_Vpi_.js → diagram-RJA3I4VV-D8wHRCNX.js} +1 -1
  45. package/dist/ui/dist/assets/{diagram-TYUNFSNR-C52roQBE.js → diagram-TYUNFSNR-B_JQEsNY.js} +1 -1
  46. package/dist/ui/dist/assets/{dist-BJd4jp9Y.js → dist-DxQIK_Ay.js} +1 -1
  47. package/dist/ui/dist/assets/{ebnfDiagram-V4P3TTPT-DXysoIBq.js → ebnfDiagram-V4P3TTPT-D83qV8mv.js} +1 -1
  48. package/dist/ui/dist/assets/{elk-QONTBCTQ-BVZcsgV3.js → elk-QONTBCTQ-BKFAKVSD.js} +1 -1
  49. package/dist/ui/dist/assets/{erDiagram-KYQIGQAA-BJnT4fVU.js → erDiagram-KYQIGQAA-CHYRd4aM.js} +1 -1
  50. package/dist/ui/dist/assets/{eventmodeling-K75KTNOO-C5MzxCMY.js → eventmodeling-K75KTNOO-BDJDv3Zf.js} +1 -1
  51. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-Bke3CfUH.js +1 -0
  52. package/dist/ui/dist/assets/{ganttDiagram-R7GPNTJY-CZRrnuah.js → ganttDiagram-R7GPNTJY-gTHjYkFj.js} +1 -1
  53. package/dist/ui/dist/assets/{gitGraph-VSP46ZUC-l58aGSkh.js → gitGraph-VSP46ZUC-D4cWkxKB.js} +1 -1
  54. package/dist/ui/dist/assets/{gitGraphDiagram-PDYL7XHY-D4ks1WO-.js → gitGraphDiagram-PDYL7XHY-D3M9wYMr.js} +1 -1
  55. package/dist/ui/dist/assets/index-BUOFihod.js +134 -0
  56. package/dist/ui/dist/assets/index-DLNJT0VS.css +1 -0
  57. package/dist/ui/dist/assets/{info-OHQRW6UA-BSqfzkdk.js → info-OHQRW6UA-B9y0ksVq.js} +1 -1
  58. package/dist/ui/dist/assets/{infoDiagram-MVDRKAX5-B6NgVF_N.js → infoDiagram-MVDRKAX5-s4FMwb8r.js} +1 -1
  59. package/dist/ui/dist/assets/{ishikawaDiagram-HPEHV2GE-DvtfssEZ.js → ishikawaDiagram-HPEHV2GE-CnQdxybf.js} +1 -1
  60. package/dist/ui/dist/assets/{journeyDiagram-BOSHW7O7-DM64gT5v.js → journeyDiagram-BOSHW7O7-I-9OpDWY.js} +1 -1
  61. package/dist/ui/dist/assets/{kanban-definition-FNSA4HC5-AliyRJHy.js → kanban-definition-FNSA4HC5-DqUbIdH4.js} +1 -1
  62. package/dist/ui/dist/assets/{line-CHncXbKO.js → line-BELSui4d.js} +1 -1
  63. package/dist/ui/dist/assets/{linear-BWEWydI0.js → linear-77hmn2iQ.js} +1 -1
  64. package/dist/ui/dist/assets/{mermaid-parser.core-DGpOP8AM.js → mermaid-parser.core-Bxa71wW-.js} +3 -3
  65. package/dist/ui/dist/assets/{mermaid.core-BJtBFbJx.js → mermaid.core-DBI9F4bh.js} +4 -4
  66. package/dist/ui/dist/assets/{mindmap-definition-6OY57UG3-NjavnWBi.js → mindmap-definition-6OY57UG3-BxXMOapP.js} +1 -1
  67. package/dist/ui/dist/assets/{packet-JDAUHWVQ-zuhpVsME.js → packet-JDAUHWVQ-DTT77cQh.js} +1 -1
  68. package/dist/ui/dist/assets/{pegDiagram-ECIUJNBX-sL4grM97.js → pegDiagram-ECIUJNBX-HzrOQPT5.js} +1 -1
  69. package/dist/ui/dist/assets/{pie-XZMESJXO-C9l1Mp09.js → pie-XZMESJXO-iHwL1Qw-.js} +1 -1
  70. package/dist/ui/dist/assets/{pieDiagram-GNMPY74T-rlRBr2U4.js → pieDiagram-GNMPY74T-DZ1tLVjw.js} +1 -1
  71. package/dist/ui/dist/assets/{prism-core-DmuST30i.js → prism-core-NtO0YF9C.js} +1 -1
  72. package/dist/ui/dist/assets/{quadrantDiagram-D57K4MC3-4oQMip2Q.js → quadrantDiagram-D57K4MC3-BjTi9H5L.js} +1 -1
  73. package/dist/ui/dist/assets/{radar-ABXABTNO-DvGg96L7.js → radar-ABXABTNO-D2hYgXSB.js} +1 -1
  74. package/dist/ui/dist/assets/{railroad-I3PHUGI6-8wdFKMZp.js → railroad-I3PHUGI6-DpakXN74.js} +1 -1
  75. package/dist/ui/dist/assets/{railroad-abnf-LNEFI6M7-DUtOO0gn.js → railroad-abnf-LNEFI6M7-B8ZFR3iy.js} +1 -1
  76. package/dist/ui/dist/assets/{railroad-ebnf-DWYD2UWJ-5YCGY1_d.js → railroad-ebnf-DWYD2UWJ-S1XUyTsC.js} +1 -1
  77. package/dist/ui/dist/assets/{railroad-peg-JR7BVNR7-Bwywxjhs.js → railroad-peg-JR7BVNR7-BbeHC_6K.js} +1 -1
  78. package/dist/ui/dist/assets/{railroadDiagram-Y72NRBL7-CwO6HSNQ.js → railroadDiagram-Y72NRBL7-fd-2iyBo.js} +1 -1
  79. package/dist/ui/dist/assets/{requirementDiagram-EQYSW5RA-Cp0IbfNr.js → requirementDiagram-EQYSW5RA-B9-HHKH-.js} +1 -1
  80. package/dist/ui/dist/assets/{sankeyDiagram-SY24JHO5-DeQKsO68.js → sankeyDiagram-SY24JHO5-CeQN1ZEG.js} +1 -1
  81. package/dist/ui/dist/assets/{sequenceDiagram-BLEHNHQU-DTDalCxF.js → sequenceDiagram-BLEHNHQU-Vff7tx4t.js} +1 -1
  82. package/dist/ui/dist/assets/{src-XnrqevnL.js → src-D5-ZzLwt.js} +1 -1
  83. package/dist/ui/dist/assets/{stateDiagram-v2-A6IESKWZ-2DZ-tKo9.js → stateDiagram-v2-A6IESKWZ-Bzn_ma9c.js} +1 -1
  84. package/dist/ui/dist/assets/{swimlanes-B3ENRKFQ-66yZJeRU.js → swimlanes-B3ENRKFQ-CevqLchV.js} +1 -1
  85. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-Bkv8hAmN.js +8 -0
  86. package/dist/ui/dist/assets/{timeline-definition-JXYKGLGB-Dm1xGU-d.js → timeline-definition-JXYKGLGB-D9jSDGJI.js} +1 -1
  87. package/dist/ui/dist/assets/{treeView-D4JQ5SDB-BvgsbSot.js → treeView-D4JQ5SDB-CoXKj11m.js} +1 -1
  88. package/dist/ui/dist/assets/{treemap-SAJKECNS-C3lnbG1a.js → treemap-SAJKECNS-cHsansGe.js} +1 -1
  89. package/dist/ui/dist/assets/{usecaseDiagram-WPOLBNNX-B6iPNIhZ.js → usecaseDiagram-WPOLBNNX-CW9i3bW6.js} +1 -1
  90. package/dist/ui/dist/assets/{vennDiagram-Q3Y3IDCW-D5dgflg9.js → vennDiagram-Q3Y3IDCW-DuU3qEKJ.js} +1 -1
  91. package/dist/ui/dist/assets/{wardley-7MLQ67FV-Dn9mLIB4.js → wardley-7MLQ67FV-dl9RgHUI.js} +1 -1
  92. package/dist/ui/dist/assets/{wardleyDiagram-IVAIDKWA-W1EwzN_X.js → wardleyDiagram-IVAIDKWA-BOXhrK-a.js} +1 -1
  93. package/dist/ui/dist/assets/{xychartDiagram-4URNHDU4-CKRzMUvp.js → xychartDiagram-4URNHDU4-CLQNGrh4.js} +1 -1
  94. package/dist/ui/dist/index.html +2 -2
  95. package/package.json +1 -1
  96. package/skills/follow-me/SKILL.md +4 -0
  97. package/dist/ui/dist/assets/channel-gz6cS9Z1.js +0 -1
  98. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-BqUgZSCz.js +0 -1
  99. package/dist/ui/dist/assets/index-BW95UA5q.css +0 -1
  100. package/dist/ui/dist/assets/index-DOGXgoHF.js +0 -134
  101. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-rbX6bYG4.js +0 -8
package/README.md CHANGED
@@ -75,13 +75,19 @@ The red blob in the party hat is your branch tip. Nothing follow-me does moves i
75
75
  ![Cmd-clicking format in pow opens a peek of its definition, which lives outside this branch](assets/screenshots/definition.png)
76
76
  *Cmd-click a name to jump to where it is defined, even in a later commit, and Alt+Left to come back. A definition the branch does not change opens as a peek.*
77
77
 
78
+ ![Coverage while reading: gutter bars mark covered, partly covered and uncovered lines, with counts per commit, per file and for the branch, and a popover listing the test that ran a line](assets/screenshots/coverage.png)
79
+ *Coverage while reading. Your agent runs the tests with coverage and passes the lcov to `follow-me coverage`; follow-me never runs anything. Bars show covered, partly covered and uncovered lines, `x` jumps to the next uncovered line, and clicking a bar lists the tests that ran it.*
80
+
81
+ ![A state machine reply: each state is marked by whether a test reached it, and clicking a state jumps to its code](assets/screenshots/state-machine.png)
82
+ *Your agent offers what it can draw, like "a state machine for this guard", as chips under the commit. Tap one and it answers with the diagram, each state marked by whether a test reached it. Click a state to jump to its code.*
83
+
78
84
  ![A diagram opened in the lightbox at full size](assets/screenshots/diagram.png)
79
85
  *Diagrams sit full width under the commit or reply they explain, and open full size on click.*
80
86
 
81
87
  ![The hand-off page: every kept item with its original-branch path and line, an include checkbox, and copy buttons](assets/screenshots/handoff.png)
82
88
  *Submit review. Each item carries the original branch's path and line, so your agent can post it to the merge request as is.*
83
89
 
84
- Keys: `[` shows or hides the index, `j` and `k` move between commits, Cmd-click (Ctrl-click) goes to a definition and Alt+Left comes back, arrows and Shift+arrows pick rows inside a hunk, `c` comments on the pick, `v` marks a commit viewed, `?` lists the rest. An unfinished review autosaves and comes back when the page is opened again.
90
+ Keys: `[` shows or hides the index, `j` and `k` move between commits, Cmd-click (Ctrl-click) goes to a definition and Alt+Left comes back, arrows and Shift+arrows pick rows inside a hunk, `c` comments on the pick, `v` marks a commit viewed, `g` shows or hides coverage and `x` jumps to the next uncovered line, `?` lists the rest. An unfinished review autosaves and comes back when the page is opened again.
85
91
 
86
92
  ### Reviewing someone else's branch
87
93
 
package/dist/src/cli.js CHANGED
@@ -1,22 +1,28 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { readFileSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { dirname, join, relative } from "node:path";
4
+ import { dirname, join, relative, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { mapOffTip, parseStateMap } from "./comments.js";
7
+ import { changedLines, LcovError, PREVIEW_LINES, parseLcov, parseLocArg, recordRun, runOf, tipPlacer, totalOf, } from "./coverage.js";
6
8
  import { definitionIndex, isIdentifier } from "./definition.js";
7
- import { DiffParseError, formatUnitRef, isUnitId } from "./diff.js";
8
- import { BranchRefusedError, branchHead, GitError, isOwnWork, signing, treeOf, writeBranch, } from "./git.js";
9
- import { fetchCarried, fetchComments, fetchReport, findServer, followEvents, idleMs, listSessions, openBrowser, pollEvents, portFallbackMessage, postComment, postReply, probeHealth, readServerRecord, registerWith, sessionUrl, spawnDetached, startOrAttach, stateDir, stillHosted, stopServer, swapVersion, } from "./host.js";
9
+ import { DiffParseError, formatUnitRef, isRepoPath, isUnitId, splitLines } from "./diff.js";
10
+ import { BranchRefusedError, blobsAt, branchHead, GitError, isOwnWork, pathsAt, pathsChanged, signing, treeOf, writeBranch, } from "./git.js";
11
+ import { fetchCarried, fetchComments, fetchReport, fetchWaitingOffers, findServer, followEvents, idleMs, listSessions, openBrowser, pollEvents, portFallbackMessage, postComment, postCoverage, postReply, probeHealth, readServerRecord, registerWith, sessionUrl, spawnDetached, startOrAttach, stateDir, stillHosted, stopServer, swapVersion, } from "./host.js";
10
12
  import { findSkillDir, InstallError, runInstall, runUninstall, syncSkills, } from "./install.js";
11
13
  import { naiveDraft } from "./naive.js";
12
- import { isCommentId, isConcernId, isFields, serializePlanFile, validatePlan, } from "./plan.js";
14
+ import { isCommentId, isConcernId, isFields, OFFERS_PER_COMMIT, OFFERS_PER_PLAN, serializePlanFile, validatePlan, } from "./plan.js";
13
15
  import { renderEvents, renderPrompt, renderReport, renderShownComment, renderShownConcern, renderShownDefinitions, renderShownUnit, reportItems, } from "./prompt.js";
14
16
  import { RegistrationError, sessionIdOf } from "./server.js";
15
- import { BranchMovedError, cleanSessions, extractSession, MissingDraftError, NotARepositoryError, openSession, SessionFileError, SessionLockedError, sessionComments, sinceLastPlan, } from "./session.js";
17
+ import { BranchMovedError, cleanSessions, coverageAt, extractSession, MissingDraftError, NotARepositoryError, openSession, SessionFileError, SessionLockedError, sessionComments, sinceLastPlan, } from "./session.js";
16
18
  import { checkSyntax } from "./syntax.js";
17
19
  class UsageError extends Error {
18
20
  name = "UsageError";
19
21
  }
22
+ /** A test run follow-me will not record, with the reason the agent can act on. */
23
+ class CoverageError extends Error {
24
+ name = "CoverageError";
25
+ }
20
26
  const SHORT_USAGE = `usage: follow-me [--wait <seconds>] [--branch <name>]
21
27
 
22
28
  Run follow-me with no command. Each call does the next thing that is missing and returns: it
@@ -26,9 +32,10 @@ prints what they flagged or asked. It is interactive: do what its "next:" line s
26
32
  again until it prints the report.
27
33
 
28
34
  Other commands:
29
- reply <commentId> --body <text> [--diagram <file>]
35
+ reply <commentId> --body <text> [--diagram <file> [--map <file>]]
30
36
  answer under a comment on the page; the diagram is
31
- Mermaid source
37
+ Mermaid source, the map JSON tying its states to
38
+ tip lines
32
39
  explain [--commit <n>] [--unit <id>] [--lines <a-b>] --body <text> [--diagram <file>]
33
40
  start a thread on the page without being asked
34
41
  show <h_…|c_…|k_…|path:line[@n|@base]|name>
@@ -37,6 +44,9 @@ Other commands:
37
44
  is defined, instead of reading files
38
45
  report [--json] [--markdown] print the report of the latest review; --json lists
39
46
  the items to post to a merge request
47
+ coverage <lcov> [--test <id> [--at <path:line>]] [--reset]
48
+ record a test run taken at the tip for the page;
49
+ --reset alone clears it before per-test runs
40
50
 
41
51
  help --all lists every command.`;
42
52
  const USAGE = `usage: follow-me <command> [options]
@@ -79,9 +89,10 @@ Interactive loop (while the review page is open):
79
89
  reports the end of the review. Your own replies are
80
90
  left out. --follow streams every event until the
81
91
  review ends instead of stopping at the first
82
- reply <commentId> --body <text> [--diagram <file>]
92
+ reply <commentId> --body <text> [--diagram <file> [--map <file>]]
83
93
  answer under a comment on the page; the diagram is
84
- Mermaid source
94
+ Mermaid source, the map JSON tying its states to
95
+ tip lines
85
96
  explain [--commit <n>] [--unit <id>] [--lines <a-b>] --body <text> [--diagram <file>]
86
97
  start a thread on the page without being asked;
87
98
  --lines are change lines of the unit, commits count
@@ -100,6 +111,12 @@ Interactive loop (while the review page is open):
100
111
  line in both voices, then questions with answers;
101
112
  --json lists the items to post to a merge request
102
113
  (path, lines, severity, body, thread, excluded)
114
+ coverage <lcov> [--test <id> [--at <path:line>]] [--reset] [--branch <name>]
115
+ record the lcov of a test run at the branch tip;
116
+ refuses when a covered file in the working tree
117
+ differs from the tip. A plain run replaces the last;
118
+ --test runs add one test each, --at is where it is.
119
+ --reset starts fresh; alone it clears the last run
103
120
  apply, while the page is open, swaps it to the new plan version in place, same URL.
104
121
 
105
122
  Old names, still working:
@@ -279,6 +296,12 @@ function formatIssue(issue) {
279
296
  return `$.commits[${issue.commit}]: concern: ${issue.reason}`;
280
297
  case "too-many-concerns":
281
298
  return `${issue.unit} has more than one concern, in commits ${issue.commits.join(" and ")}; keep the one that matters`;
299
+ case "bad-offer":
300
+ return `$.commits[${issue.commit}]: offer: ${issue.reason}`;
301
+ case "too-many-offers":
302
+ return issue.commit === null
303
+ ? `the plan has ${issue.count} offers; at most ${OFFERS_PER_PLAN}, and most plans need none`
304
+ : `$.commits[${issue.commit}] has ${issue.count} offers; at most ${OFFERS_PER_COMMIT} per commit`;
282
305
  case "final-mismatch": {
283
306
  const tip = issue.expected ? `${issue.expected.mode} ${issue.expected.blob}` : "absent";
284
307
  return `${show(issue.path)}: the last commit has ${issue.got}, the tip has ${tip}`;
@@ -293,6 +316,9 @@ function formatWarning(w) {
293
316
  if (w.kind === "long-title") {
294
317
  return `warning: $.commits[${w.commit}]: title is ${w.length} characters; keep it to 50`;
295
318
  }
319
+ if (w.kind === "section-order") {
320
+ return `warning: $.commits[${w.commit}]: section ${w.section} comes after ${w.after}; sections read story, steps, details, wiring`;
321
+ }
296
322
  if (w.kind === "question-addressed") {
297
323
  return `warning: question ${w.comment} is marked addressed; explain it with "answered" and "at" unless answering it changed the plan`;
298
324
  }
@@ -909,8 +935,8 @@ function reviewedPlan(session) {
909
935
  return null;
910
936
  }
911
937
  /** Prints the finished review's report, which is what the agent works from from here on. */
912
- function finished(session, stored) {
913
- const text = renderStored(session, stored);
938
+ async function finished(session, stored) {
939
+ const text = await renderStored(session, stored);
914
940
  if (text === null)
915
941
  return 1;
916
942
  // A review that swapped versions holds what its earlier versions said too.
@@ -1041,6 +1067,13 @@ function waitOf(args) {
1041
1067
  /** One call fits inside a harness's tool timeout, so the agent's loop is one command per turn. */
1042
1068
  const ENTRY_WAIT_SECONDS = 90;
1043
1069
  const MAX_ENTRY_WAIT_SECONDS = 300;
1070
+ /** The step that names the oldest accepted offer the agent has not answered, or null. */
1071
+ async function offerStep(port, summary) {
1072
+ const [first] = await fetchWaitingOffers(port, summary);
1073
+ if (first === undefined)
1074
+ return null;
1075
+ return `${STILL_WORKING}; offer ${first.offer} waits for your diagram: follow-me reply ${first.comment} --diagram <file> [--map <file>], then run follow-me again`;
1076
+ }
1044
1077
  /**
1045
1078
  * Listens to the hosted review for up to --wait seconds and prints what the reviewer did. It
1046
1079
  * returns at once with news, and with the report when the reviewer submitted or ended the review.
@@ -1100,11 +1133,13 @@ async function listen(session, open, args) {
1100
1133
  next("done; the user can port it with follow-me report --json");
1101
1134
  return 0;
1102
1135
  }
1103
- next(`${STILL_WORKING}; answer with follow-me reply <id> --body "<markdown>" [--diagram file], then run follow-me again`);
1136
+ next((await offerStep(port, summary)) ??
1137
+ `${STILL_WORKING}; answer with follow-me reply <id> --body "<markdown>" [--diagram file], then run follow-me again`);
1104
1138
  return 0;
1105
1139
  }
1106
1140
  if (left <= 0) {
1107
- next(`${STILL_WORKING}; run follow-me again to keep listening`);
1141
+ next((await offerStep(port, summary)) ??
1142
+ `${STILL_WORKING}; run follow-me again to keep listening`);
1108
1143
  return 0;
1109
1144
  }
1110
1145
  }
@@ -1173,6 +1208,9 @@ async function entry(cwd, args) {
1173
1208
  const result = await hostReview(session, mode, detached, async () => prep, reportSaved);
1174
1209
  if (result.kind !== "elsewhere")
1175
1210
  return exitCode(result);
1211
+ if (session.coverage() === null) {
1212
+ next("if the project has tests, run them with coverage at the tip and pass the lcov to follow-me coverage");
1213
+ }
1176
1214
  say("follow-me is interactive: keep running it until it prints the report");
1177
1215
  return listen(session, result, args);
1178
1216
  }
@@ -1230,7 +1268,7 @@ async function walkPlan(session, args) {
1230
1268
  return alreadyReviewed(session, latest.version, args) ? null : latest;
1231
1269
  }
1232
1270
  /** The consolidated report of a saved review, or null after saying why it cannot be rendered. */
1233
- function renderStored(session, stored, format = "markdown") {
1271
+ async function renderStored(session, stored, format = "markdown") {
1234
1272
  const plan = session.plans().find((p) => p.version === stored.version);
1235
1273
  if (plan === undefined) {
1236
1274
  console.error(`follow-me: review v${stored.version} refers to a plan that is missing`);
@@ -1258,7 +1296,13 @@ function renderStored(session, stored, format = "markdown") {
1258
1296
  concerns: stored.review.carriedConcerns ?? [],
1259
1297
  },
1260
1298
  };
1261
- const opts = { branch: session.anchor.branch, responses, ...earlier };
1299
+ const coverage = await coverageAt(session.root, session.dir, session.anchor.tip);
1300
+ const opts = {
1301
+ branch: session.anchor.branch,
1302
+ responses,
1303
+ ...earlier,
1304
+ ...(coverage === null ? {} : { coverage }),
1305
+ };
1262
1306
  if (format === "json") {
1263
1307
  const items = reportItems(stored.review, verdict.plan.commits, session.diff.units, opts);
1264
1308
  return `${JSON.stringify(items, null, 2)}\n`;
@@ -1288,12 +1332,92 @@ async function report(cwd, args, walkOnly = false) {
1288
1332
  console.error("follow-me: no review is open and none has ended; run `follow-me review` first");
1289
1333
  return 1;
1290
1334
  }
1291
- const text = renderStored(session, stored, format);
1335
+ const text = await renderStored(session, stored, format);
1292
1336
  if (text === null)
1293
1337
  return 1;
1294
1338
  process.stdout.write(text);
1295
1339
  return 0;
1296
1340
  }
1341
+ const COVERAGE_USAGE = "usage: follow-me coverage <lcov> [--test <id> [--at <path:line>]] [--reset]\n follow-me coverage --reset";
1342
+ /**
1343
+ * Records the lcov of a test run the agent made at the tip in the session's coverage.json and
1344
+ * prints how much of the branch's changed code it ran. follow-me never runs the tests itself.
1345
+ */
1346
+ async function coverage(cwd, args) {
1347
+ const [file, extra] = args.positionals;
1348
+ const id = args.values.get("test");
1349
+ const atArg = args.values.get("at");
1350
+ const reset = args.flags.has("reset");
1351
+ if (file === undefined && reset && id === undefined && atArg === undefined) {
1352
+ const session = await openFresh(cwd, args);
1353
+ session.clearCoverage();
1354
+ say("coverage cleared; the next run starts fresh");
1355
+ return 0;
1356
+ }
1357
+ if (file === undefined || extra !== undefined)
1358
+ throw new UsageError(COVERAGE_USAGE);
1359
+ if (atArg !== undefined && id === undefined) {
1360
+ throw new UsageError(`--at needs --test\n${COVERAGE_USAGE}`);
1361
+ }
1362
+ let text;
1363
+ try {
1364
+ text = readFileSync(resolve(cwd, file), "utf8");
1365
+ }
1366
+ catch {
1367
+ throw new UsageError(`cannot read ${file}`);
1368
+ }
1369
+ const records = parseLcov(text);
1370
+ const session = await openFresh(cwd, args);
1371
+ const { tip } = session.anchor;
1372
+ const place = tipPlacer(session.root, cwd, await pathsAt(session.root, tip));
1373
+ const run = runOf(records, place);
1374
+ if (run.files.size === 0) {
1375
+ throw new CoverageError(`none of the ${plural(new Set(records.map((r) => r.path)).size, "file")} in ${file} is in ${session.anchor.branch} at the tip; run follow-me coverage from the directory the tests ran in`);
1376
+ }
1377
+ const changed = await pathsChanged(session.root, tip, null);
1378
+ const differ = [...run.files.keys()].filter((p) => changed.has(p));
1379
+ if (differ.length > 0) {
1380
+ throw new CoverageError(`coverage must be taken on the tip; these files differ: ${differ.map(show).join(", ")}`);
1381
+ }
1382
+ let test;
1383
+ if (id !== undefined) {
1384
+ const at = atArg === undefined ? undefined : parseLocArg(atArg, place);
1385
+ if (typeof at === "string")
1386
+ throw new UsageError(at);
1387
+ const preview = at === undefined ? undefined : await testPreview(session, at);
1388
+ test = {
1389
+ id,
1390
+ ...(at === undefined ? {} : { at }),
1391
+ ...(preview === undefined ? {} : { preview }),
1392
+ };
1393
+ }
1394
+ const takenAt = new Date().toISOString();
1395
+ const recorded = recordRun(reset ? null : session.coverage(), run, {
1396
+ tip,
1397
+ takenAt,
1398
+ ...(test === undefined ? {} : { test }),
1399
+ });
1400
+ session.writeCoverage(recorded);
1401
+ const open = await openOnServer(session, null);
1402
+ if (open !== null)
1403
+ await postCoverage(open.port, open.summary);
1404
+ const { covered, executable } = totalOf(changedLines(session.diff.units, recorded.files).values());
1405
+ say(`coverage for ${plural(recorded.files.size, "file")} at ${short(tip)}; ${covered} of ${executable} changed lines covered`);
1406
+ return 0;
1407
+ }
1408
+ /**
1409
+ * Up to PREVIEW_LINES tip lines from where a test starts, when the branch leaves its file alone:
1410
+ * the page shows the branch's own files from the plan, and the others need this to peek.
1411
+ */
1412
+ async function testPreview(session, at) {
1413
+ const changed = new Set([...session.diff.units.values()].map((u) => u.path));
1414
+ if (changed.has(at.path))
1415
+ return undefined;
1416
+ const bytes = (await blobsAt(session.root, session.anchor.tip, [at.path])).get(at.path) ?? "";
1417
+ return splitLines(bytes)
1418
+ .slice(at.start - 1, at.start - 1 + PREVIEW_LINES)
1419
+ .map((line) => Buffer.from(line.replace(/\r?\n$/, ""), "latin1").toString("utf8"));
1420
+ }
1297
1421
  /** The old name of report. */
1298
1422
  async function feedback(cwd, args) {
1299
1423
  console.error("follow-me: feedback is now report; printing the same");
@@ -1302,9 +1426,14 @@ async function feedback(cwd, args) {
1302
1426
  function isCommentEvent(event) {
1303
1427
  return event.type === "comment" || event.type === "comment-updated";
1304
1428
  }
1305
- /** Events the reviewer caused; the agent's own replies, explanations and version swaps are not news. */
1429
+ /**
1430
+ * Events the reviewer caused; the agent's own replies, explanations, version swaps and coverage
1431
+ * are not news, and neither is a dismissed offer, which asks nothing of the agent.
1432
+ */
1306
1433
  function isNews(event) {
1307
- if (event.type === "version")
1434
+ if (event.type === "version" || event.type === "coverage")
1435
+ return false;
1436
+ if (event.type === "offer-dismissed")
1308
1437
  return false;
1309
1438
  if (event.type === "comment" || event.type === "comment-updated") {
1310
1439
  return event.comment.author !== "agent";
@@ -1385,12 +1514,18 @@ async function watch(cwd, args) {
1385
1514
  /** Posts the agent's answer under a comment on the hosted page. */
1386
1515
  async function reply(cwd, args) {
1387
1516
  const [commentId, extra] = args.positionals;
1388
- const body = args.values.get("body");
1517
+ const diagram = diagramOf(args);
1518
+ // A diagram can be the whole answer, as when it answers an accepted offer.
1519
+ const body = args.values.get("body") ?? (diagram === undefined ? undefined : "");
1389
1520
  if (commentId === undefined || extra !== undefined || body === undefined) {
1390
- throw new UsageError("usage: follow-me reply <commentId> --body <text> [--diagram <file>]");
1521
+ throw new UsageError(REPLY_USAGE);
1522
+ }
1523
+ const mapFile = args.values.get("map");
1524
+ if (mapFile !== undefined && diagram === undefined) {
1525
+ throw new UsageError(`--map needs --diagram, the state diagram it ties to code\n${REPLY_USAGE}`);
1391
1526
  }
1392
- const diagram = diagramOf(args);
1393
1527
  const session = await openSession(cwd, sessionOpts(args));
1528
+ const map = mapFile === undefined ? undefined : await readMap(session, mapFile, diagram);
1394
1529
  const open = await openOnServer(session, null);
1395
1530
  if (open === null)
1396
1531
  return notOpen(session);
@@ -1398,11 +1533,59 @@ async function reply(cwd, args) {
1398
1533
  author: "agent",
1399
1534
  body,
1400
1535
  ...(diagram === undefined ? {} : { diagram }),
1536
+ ...(map === undefined ? {} : { map }),
1401
1537
  });
1402
1538
  say(`replied ${id} to ${commentId}`);
1403
1539
  next(`run follow-me again; ${STILL_WORKING}`);
1404
1540
  return 0;
1405
1541
  }
1542
+ const REPLY_USAGE = `usage: follow-me reply <commentId> --body <text> [--diagram <file> [--map <file>]]
1543
+ follow-me reply <commentId> --diagram <file> [--map <file>] [--body <text>]`;
1544
+ /** A map path as git names it: the JSON's UTF-8 text as the bytes git prints, read as latin1. */
1545
+ function repoPathOf(typed) {
1546
+ const path = Buffer.from(typed, "utf8").toString("latin1");
1547
+ if (!isRepoPath(path))
1548
+ throw new UsageError(`${typed} is not a repo path`);
1549
+ return path;
1550
+ }
1551
+ /**
1552
+ * --map's JSON, checked against the tip: every place is a file there and within its lines. Every
1553
+ * bad entry is named at once, so one fix round is enough.
1554
+ */
1555
+ async function readMap(session, file, diagram) {
1556
+ let raw;
1557
+ try {
1558
+ raw = JSON.parse(readFileSync(file, "utf8"));
1559
+ }
1560
+ catch (error) {
1561
+ throw new UsageError(`cannot read ${file} as JSON: ${error instanceof Error ? error.message : error}`);
1562
+ }
1563
+ const typed = parseStateMap(raw, diagram);
1564
+ if (typeof typed === "string")
1565
+ throw new UsageError(`${file}: ${typed}`);
1566
+ const at = (loc) => ({ ...loc, path: repoPathOf(loc.path) });
1567
+ const map = {
1568
+ states: Object.fromEntries(Object.entries(typed.states).map(([name, locs]) => [name, locs.map(at)])),
1569
+ ...(typed.transitions === undefined
1570
+ ? {}
1571
+ : { transitions: typed.transitions.map((t) => ({ ...t, at: t.at.map(at) })) }),
1572
+ };
1573
+ const { root, anchor } = session;
1574
+ const named = [
1575
+ ...Object.values(map.states).flat(),
1576
+ ...(map.transitions ?? []).flatMap((t) => t.at),
1577
+ ].map((loc) => loc.path);
1578
+ const tip = await pathsAt(root, anchor.tip);
1579
+ const present = [...new Set(named)].filter((p) => tip.has(p));
1580
+ const lines = new Map([...(await blobsAt(root, anchor.tip, present))].map(([p, bytes]) => [
1581
+ p,
1582
+ splitLines(bytes).length,
1583
+ ]));
1584
+ const problems = mapOffTip(map, lines, (p) => Buffer.from(p, "latin1").toString("utf8"));
1585
+ if (problems.length > 0)
1586
+ throw new UsageError(`${file}:\n ${problems.join("\n ")}`);
1587
+ return map;
1588
+ }
1406
1589
  /** The Mermaid source in --diagram's file, or undefined without the flag. */
1407
1590
  function diagramOf(args) {
1408
1591
  const file = args.values.get("diagram");
@@ -1871,13 +2054,15 @@ export async function main(argv, cwd) {
1871
2054
  return await watch(cwd, parseArgs(rest, spec));
1872
2055
  }
1873
2056
  case "reply":
1874
- return await reply(cwd, parseArgs(rest, { values: ["body", "diagram", "branch"] }));
2057
+ return await reply(cwd, parseArgs(rest, { values: ["body", "diagram", "map", "branch"] }));
1875
2058
  case "explain": {
1876
2059
  const spec = { values: ["commit", "unit", "lines", "body", "diagram", "branch"] };
1877
2060
  return await explain(cwd, parseArgs(rest, spec));
1878
2061
  }
1879
2062
  case "show":
1880
2063
  return await showRef(cwd, parseArgs(rest, { values: ["branch"] }));
2064
+ case "coverage":
2065
+ return await coverage(cwd, parseArgs(rest, { values: ["test", "at", "branch"], flags: ["reset"] }));
1881
2066
  case "report":
1882
2067
  return await report(cwd, parseArgs(rest, { values: ["branch"], flags: ["json", "markdown"] }));
1883
2068
  case "clean": {
@@ -1918,6 +2103,8 @@ export async function main(argv, cwd) {
1918
2103
  catch (error) {
1919
2104
  const known = [
1920
2105
  UsageError,
2106
+ CoverageError,
2107
+ LcovError,
1921
2108
  BranchMovedError,
1922
2109
  BranchRefusedError,
1923
2110
  SessionFileError,
@@ -1,5 +1,6 @@
1
- import { isRepoPath, isUnitId } from "./diff.js";
2
- import { diagramProblem, isArray, isCommentId, isConcernId, isFields, isPlanVersion, isSeverity, parsePlacedConcern, } from "./plan.js";
1
+ import { parseLoc } from "./coverage.js";
2
+ import { isRepoPath, isSha, isUnitId } from "./diff.js";
3
+ import { diagramProblem, isArray, isCommentId, isConcernId, isFields, isOfferId, isPlanVersion, isSeverity, parsePlacedConcern, } from "./plan.js";
3
4
  /**
4
5
  * The wire formats of review state, parsed once at the boundary: comments, replies, concern
5
6
  * decisions, the review file, its autosaved draft and the events a hosted session logs. Every
@@ -48,10 +49,110 @@ function parseAnchor(v) {
48
49
  }
49
50
  return { kind: "hunk", unit: v.unit, commit, changes, offsets };
50
51
  }
52
+ function parseLocs(v, at, needed) {
53
+ const what = `${at}: expected a list of { path, start, end }${needed ? " with at least one place" : ""}`;
54
+ if (!isArray(v) || (needed && v.length === 0))
55
+ return what;
56
+ const out = [];
57
+ for (const [i, raw] of v.entries()) {
58
+ const loc = parseLoc(raw);
59
+ if (loc === null)
60
+ return `${at}[${i}]: expected { path, start, end } with 1 <= start <= end`;
61
+ out.push(loc);
62
+ }
63
+ return out;
64
+ }
65
+ const TRANSITION_ENDS = ["from", "to"];
66
+ function escapeRegExp(text) {
67
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
68
+ }
69
+ /**
70
+ * Boundary parse of a reply's state map. Every state has at least one place, every transition
71
+ * names states of the map, and with a stateDiagram every state name appears in it. The reason
72
+ * names the bad entry, as map.states.<name>[i] or map.transitions[i].<field>.
73
+ */
74
+ export function parseStateMap(v, diagram) {
75
+ if (!isFields(v))
76
+ return "map: expected { states, transitions }";
77
+ if (!isFields(v.states) || Object.keys(v.states).length === 0) {
78
+ return "map.states: expected an object of state name to places";
79
+ }
80
+ const states = [];
81
+ for (const [name, raw] of Object.entries(v.states)) {
82
+ if (name.trim() === "")
83
+ return "map.states: a state has an empty name";
84
+ const locs = parseLocs(raw, `map.states.${name}`, true);
85
+ if (typeof locs === "string")
86
+ return locs;
87
+ states.push([name, locs]);
88
+ }
89
+ const named = new Set(states.map(([name]) => name));
90
+ const transitions = [];
91
+ const rawTransitions = v.transitions ?? [];
92
+ if (!isArray(rawTransitions))
93
+ return "map.transitions: expected a list";
94
+ for (const [i, raw] of rawTransitions.entries()) {
95
+ const at = `map.transitions[${i}]`;
96
+ if (!isFields(raw))
97
+ return `${at}: expected { from, to, event, at }`;
98
+ for (const end of TRANSITION_ENDS) {
99
+ const state = raw[end];
100
+ if (typeof state !== "string" || !named.has(state)) {
101
+ return `${at}.${end}: ${JSON.stringify(state)} is not a state in map.states`;
102
+ }
103
+ }
104
+ const { from, to, event } = raw;
105
+ if (typeof from !== "string" || typeof to !== "string")
106
+ return `${at}: expected from and to`;
107
+ if (event !== undefined && typeof event !== "string")
108
+ return `${at}.event: expected a string`;
109
+ const locs = parseLocs(raw.at, `${at}.at`, false);
110
+ if (typeof locs === "string")
111
+ return locs;
112
+ transitions.push({ from, to, ...(event === undefined ? {} : { event }), at: locs });
113
+ }
114
+ const first = (diagram ?? "")
115
+ .split("\n")
116
+ .find((line) => line.trim() !== "")
117
+ ?.trim() ?? "";
118
+ if (first.startsWith("stateDiagram")) {
119
+ for (const name of named) {
120
+ if (!new RegExp(`(^|[^\\w])${escapeRegExp(name)}([^\\w]|$)`, "m").test(diagram ?? "")) {
121
+ return `map.states.${name}: not a state of the stateDiagram`;
122
+ }
123
+ }
124
+ }
125
+ return {
126
+ states: Object.fromEntries(states),
127
+ ...(transitions.length === 0 ? {} : { transitions }),
128
+ };
129
+ }
130
+ /**
131
+ * Where a map points past the tip: a path the tip has no file at, or lines beyond its end. lines
132
+ * holds the line count of every tip file the map names that exists; show prints a path.
133
+ */
134
+ export function mapOffTip(map, lines, show = (path) => path) {
135
+ const check = (loc, at) => {
136
+ const count = lines.get(loc.path);
137
+ if (count === undefined)
138
+ return [`${at}: ${show(loc.path)} is not a file at the tip`];
139
+ if (loc.end > count) {
140
+ return [
141
+ `${at}: lines ${loc.start}-${loc.end} are past the end of ${show(loc.path)}, which has ${count} lines at the tip`,
142
+ ];
143
+ }
144
+ return [];
145
+ };
146
+ return [
147
+ ...Object.entries(map.states).flatMap(([name, locs]) => locs.flatMap((loc, i) => check(loc, `map.states.${name}[${i}]`))),
148
+ ...(map.transitions ?? []).flatMap((t, i) => t.at.flatMap((loc, j) => check(loc, `map.transitions[${i}].at[${j}]`))),
149
+ ];
150
+ }
51
151
  /** A stored reply. Replies a client posts go through parseNewReply, which mints id and time. */
52
152
  export function parseReply(v) {
53
- if (!isFields(v))
153
+ if (!isFields(v)) {
54
154
  return "not an object";
155
+ }
55
156
  if (!isReplyId(v.id))
56
157
  return "id is not a reply id";
57
158
  if (v.author !== "agent" && v.author !== "reviewer")
@@ -61,30 +162,50 @@ export function parseReply(v) {
61
162
  }
62
163
  if (v.diagram !== undefined && typeof v.diagram !== "string")
63
164
  return "diagram is not a string";
165
+ const map = v.map === undefined ? undefined : parseStateMap(v.map, v.diagram);
166
+ if (typeof map === "string")
167
+ return map;
64
168
  return {
65
169
  id: v.id,
66
170
  author: v.author,
67
171
  body: v.body,
68
172
  ...(v.diagram === undefined ? {} : { diagram: v.diagram }),
173
+ ...(map === undefined ? {} : { map }),
69
174
  createdAt: v.createdAt,
70
175
  };
71
176
  }
72
- /** The body of POST .../replies: who speaks, what, and an optional Mermaid diagram. */
177
+ /**
178
+ * The body of POST .../replies: who speaks, what, and an optional Mermaid diagram. The body may be
179
+ * empty only when the diagram is the answer.
180
+ */
73
181
  export function parseNewReply(v) {
74
182
  if (!isFields(v))
75
183
  return "the reply is not an object";
76
184
  if (v.author !== "agent" && v.author !== "reviewer")
77
185
  return 'author is not "agent" or "reviewer"';
78
- if (typeof v.body !== "string" || v.body.trim() === "")
79
- return "body is not a non-empty string";
80
- if (v.diagram === undefined)
186
+ if (typeof v.body !== "string")
187
+ return "body is not a string";
188
+ if (v.diagram === undefined) {
189
+ if (v.map !== undefined)
190
+ return "map needs the diagram it ties to code";
191
+ if (v.body.trim() === "")
192
+ return "body is empty; a reply needs words or a diagram";
81
193
  return { author: v.author, body: v.body };
194
+ }
82
195
  if (typeof v.diagram !== "string")
83
196
  return "diagram is not a string";
84
197
  const problem = diagramProblem(v.diagram);
85
198
  if (problem !== null)
86
199
  return `diagram: ${problem}`;
87
- return { author: v.author, body: v.body, diagram: v.diagram };
200
+ const map = v.map === undefined ? undefined : parseStateMap(v.map, v.diagram);
201
+ if (typeof map === "string")
202
+ return map;
203
+ return {
204
+ author: v.author,
205
+ body: v.body,
206
+ diagram: v.diagram,
207
+ ...(map === undefined ? {} : { map }),
208
+ };
88
209
  }
89
210
  function isString(v) {
90
211
  return typeof v === "string";
@@ -133,6 +254,14 @@ export function parseComment(v) {
133
254
  const severity = v.severity;
134
255
  if (severity !== undefined && !isSeverity(severity))
135
256
  return "severity is unknown";
257
+ const offer = v.offer;
258
+ if (offer !== undefined) {
259
+ if (!isOfferId(offer))
260
+ return "offer is not an offer id";
261
+ if (v.kind !== "question" || v.author !== undefined) {
262
+ return "offer is only for a reviewer's question";
263
+ }
264
+ }
136
265
  const replies = parseList(v.replies, parseReply, "replies");
137
266
  if (typeof replies === "string")
138
267
  return replies;
@@ -152,6 +281,7 @@ export function parseComment(v) {
152
281
  ...(replies.length === 0 ? {} : { replies }),
153
282
  ...(v.author === "agent" ? { author: "agent" } : {}),
154
283
  ...(v.diagram === undefined ? {} : { diagram: v.diagram }),
284
+ ...(offer === undefined ? {} : { offer }),
155
285
  };
156
286
  }
157
287
  function withDecision(concern, v) {
@@ -363,6 +493,9 @@ export function parseReview(v) {
363
493
  const concernsCarried = parseList(v.carriedConcerns, parseCarriedConcern, "carriedConcerns");
364
494
  if (typeof concernsCarried === "string")
365
495
  return concernsCarried;
496
+ const dismissed = parseIds(v.dismissedOffers, isOfferId, "dismissedOffers", "offer");
497
+ if (typeof dismissed === "string")
498
+ return dismissed;
366
499
  return {
367
500
  schema: v.schema,
368
501
  mode,
@@ -376,6 +509,7 @@ export function parseReview(v) {
376
509
  ...(earlier.length === 0 ? {} : { earlierReplies: earlier }),
377
510
  ...(carried === null ? {} : { carried }),
378
511
  ...(concernsCarried.length === 0 ? {} : { carriedConcerns: concernsCarried }),
512
+ ...(dismissed.length === 0 ? {} : { dismissedOffers: dismissed }),
379
513
  };
380
514
  }
381
515
  function lenient(items, parse) {
@@ -403,6 +537,7 @@ export function parseDraft(v) {
403
537
  earlierReplies: lenient(v.earlierReplies, parseEarlierReplies),
404
538
  carried: v.carried === undefined ? null : lenient(v.carried, parseCarriedRef),
405
539
  carriedConcerns: lenient(v.carriedConcerns, parseCarriedConcern),
540
+ dismissedOffers: lenientIds(v.dismissedOffers, isOfferId),
406
541
  };
407
542
  }
408
543
  export function parseEvent(v) {
@@ -444,6 +579,10 @@ export function parseEvent(v) {
444
579
  }
445
580
  return { seq, type: v.type, commentId: id, resolved: v.resolved };
446
581
  }
582
+ case "offer-dismissed":
583
+ return isOfferId(v.offer)
584
+ ? { seq, type: v.type, offer: v.offer }
585
+ : "offer is not an offer id";
447
586
  case "version": {
448
587
  if (!isPlanVersion(v.version) || typeof v.hash !== "string")
449
588
  return "malformed version event";
@@ -452,6 +591,11 @@ export function parseEvent(v) {
452
591
  return carried;
453
592
  return { seq, type: v.type, version: v.version, hash: v.hash, carried };
454
593
  }
594
+ case "coverage":
595
+ if (typeof v.tip !== "string" || !isSha(v.tip) || typeof v.takenAt !== "string") {
596
+ return "malformed coverage event";
597
+ }
598
+ return { seq, type: v.type, tip: v.tip, takenAt: v.takenAt };
455
599
  case "submitted":
456
600
  return { seq, type: v.type };
457
601
  case "ended":