@shiyuphan/follow-me 0.7.1 → 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 (99) hide show
  1. package/README.md +3 -0
  2. package/dist/src/cli.js +128 -24
  3. package/dist/src/comments.js +146 -7
  4. package/dist/src/coverage.js +16 -2
  5. package/dist/src/host.js +23 -0
  6. package/dist/src/plan.js +128 -6
  7. package/dist/src/prompt.js +55 -2
  8. package/dist/src/server.js +65 -3
  9. package/dist/src/session.js +1 -0
  10. package/dist/ui/dist/assets/{abnfDiagram-WEYOIA3C-cHEJvcBn.js → abnfDiagram-WEYOIA3C-Bp14ygmz.js} +1 -1
  11. package/dist/ui/dist/assets/{arc-xolFyc0v.js → arc-DUR6uzhI.js} +1 -1
  12. package/dist/ui/dist/assets/{architecture-WOLXFQ4H-B3hvvNV2.js → architecture-WOLXFQ4H-CyfnP65O.js} +1 -1
  13. package/dist/ui/dist/assets/{architectureDiagram-ZHVYKT53-BNTMbPJW.js → architectureDiagram-ZHVYKT53-dJTicIk2.js} +1 -1
  14. package/dist/ui/dist/assets/{blockDiagram-RJAE7QA3-NYY6qeuY.js → blockDiagram-RJAE7QA3-BYiqh5Ib.js} +1 -1
  15. package/dist/ui/dist/assets/{c4Diagram-XDPPPE54-B_bNpXHQ.js → c4Diagram-XDPPPE54-DHHPQ7xE.js} +1 -1
  16. package/dist/ui/dist/assets/channel-CY2cQobm.js +1 -0
  17. package/dist/ui/dist/assets/{chunk-2E7WHVMD-BTet9STn.js → chunk-2E7WHVMD-BODm_awL.js} +1 -1
  18. package/dist/ui/dist/assets/{chunk-4EMNKR2T-qoQt4Fpx.js → chunk-4EMNKR2T-CpZfjf2T.js} +1 -1
  19. package/dist/ui/dist/assets/{chunk-55XCU76K-C3I6EB-H.js → chunk-55XCU76K-CoP2pJTQ.js} +1 -1
  20. package/dist/ui/dist/assets/{chunk-7S6XSTFO-CL-9s_8p.js → chunk-7S6XSTFO-CaQcUvXY.js} +1 -1
  21. package/dist/ui/dist/assets/{chunk-AQDTSKZB-DHoGnGyP.js → chunk-AQDTSKZB-BZ9Czvbu.js} +1 -1
  22. package/dist/ui/dist/assets/{chunk-DSNLSHJZ-Bk8WO5JF.js → chunk-DSNLSHJZ-CNjUo110.js} +1 -1
  23. package/dist/ui/dist/assets/{chunk-HAGFOTED-CwAC85lg.js → chunk-HAGFOTED-BTmBE_D3.js} +1 -1
  24. package/dist/ui/dist/assets/{chunk-HNE4Q36P-CIllywWr.js → chunk-HNE4Q36P-D2cDuT0C.js} +1 -1
  25. package/dist/ui/dist/assets/{chunk-NQOKRVIF-DDlpyYiH.js → chunk-NQOKRVIF-AZcemFgG.js} +1 -1
  26. package/dist/ui/dist/assets/{chunk-UHW7PFBP-Cxdndcon.js → chunk-UHW7PFBP-BszwjUqG.js} +1 -1
  27. package/dist/ui/dist/assets/{chunk-VQ5W62QI-CL9d9_iJ.js → chunk-VQ5W62QI-vFSenYSe.js} +1 -1
  28. package/dist/ui/dist/assets/{chunk-WQQL4CDK-C4kzN8QU.js → chunk-WQQL4CDK-DZR6is1Q.js} +1 -1
  29. package/dist/ui/dist/assets/{chunk-XXDRQBXY-Cv_2FSiY.js → chunk-XXDRQBXY-CACFh-si.js} +1 -1
  30. package/dist/ui/dist/assets/{chunk-YZO67HFG-oHiuYaCJ.js → chunk-YZO67HFG-DRRTjGuW.js} +1 -1
  31. package/dist/ui/dist/assets/{chunk-ZAUX2LTX-Cy_8d0XT.js → chunk-ZAUX2LTX-Bxuy-Roa.js} +1 -1
  32. package/dist/ui/dist/assets/{chunk-ZCHMQSOR-piIpIcQi.js → chunk-ZCHMQSOR-rYXk__1o.js} +1 -1
  33. package/dist/ui/dist/assets/{classDiagram-v2-4MYWR3UA-DNKRIujO.js → classDiagram-v2-4MYWR3UA-RiF44tTz.js} +1 -1
  34. package/dist/ui/dist/assets/{cose-bilkent-JH36ORCC-BWKTGCHY.js → cose-bilkent-JH36ORCC-0oiQoThS.js} +1 -1
  35. package/dist/ui/dist/assets/{cynefin-EF2NZ3EQ-yx7KKkAk.js → cynefin-EF2NZ3EQ-BAssFK5K.js} +1 -1
  36. package/dist/ui/dist/assets/{cynefinDiagram-WF24UDRP-BTnZ7QVL.js → cynefinDiagram-WF24UDRP-Bmd05bak.js} +1 -1
  37. package/dist/ui/dist/assets/{dagre-NK4GIZVX-CPh5Zn5I.js → dagre-NK4GIZVX-QJkLjPxj.js} +1 -1
  38. package/dist/ui/dist/assets/{diagram-CBK3MWTC-pI6NFdrD.js → diagram-CBK3MWTC-PSaSiYXz.js} +1 -1
  39. package/dist/ui/dist/assets/{diagram-I5XIIV2D-DUIfARoS.js → diagram-I5XIIV2D-BmU5X_hr.js} +1 -1
  40. package/dist/ui/dist/assets/{diagram-ODIL2UCW-lafSPvAw.js → diagram-ODIL2UCW-AAV39kUu.js} +1 -1
  41. package/dist/ui/dist/assets/{diagram-PFBEJUXE-u1lZas3a.js → diagram-PFBEJUXE-DWlV-oxd.js} +1 -1
  42. package/dist/ui/dist/assets/{diagram-RJA3I4VV-mc0GLMHo.js → diagram-RJA3I4VV-D8wHRCNX.js} +1 -1
  43. package/dist/ui/dist/assets/{diagram-TYUNFSNR-1NRbBrZW.js → diagram-TYUNFSNR-B_JQEsNY.js} +1 -1
  44. package/dist/ui/dist/assets/{dist-CHZ9mrl_.js → dist-DxQIK_Ay.js} +1 -1
  45. package/dist/ui/dist/assets/{ebnfDiagram-V4P3TTPT-DdStjTDc.js → ebnfDiagram-V4P3TTPT-D83qV8mv.js} +1 -1
  46. package/dist/ui/dist/assets/{elk-QONTBCTQ-BzRoFO45.js → elk-QONTBCTQ-BKFAKVSD.js} +1 -1
  47. package/dist/ui/dist/assets/{erDiagram-KYQIGQAA-DlMkgQxj.js → erDiagram-KYQIGQAA-CHYRd4aM.js} +1 -1
  48. package/dist/ui/dist/assets/{eventmodeling-K75KTNOO-BRYIOrod.js → eventmodeling-K75KTNOO-BDJDv3Zf.js} +1 -1
  49. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-Bke3CfUH.js +1 -0
  50. package/dist/ui/dist/assets/{ganttDiagram-R7GPNTJY-D8fQZ7Ah.js → ganttDiagram-R7GPNTJY-gTHjYkFj.js} +1 -1
  51. package/dist/ui/dist/assets/{gitGraph-VSP46ZUC-C7ZydyWX.js → gitGraph-VSP46ZUC-D4cWkxKB.js} +1 -1
  52. package/dist/ui/dist/assets/{gitGraphDiagram-PDYL7XHY-DCItam0v.js → gitGraphDiagram-PDYL7XHY-D3M9wYMr.js} +1 -1
  53. package/dist/ui/dist/assets/index-BUOFihod.js +134 -0
  54. package/dist/ui/dist/assets/index-DLNJT0VS.css +1 -0
  55. package/dist/ui/dist/assets/{info-OHQRW6UA-C3KneBKT.js → info-OHQRW6UA-B9y0ksVq.js} +1 -1
  56. package/dist/ui/dist/assets/{infoDiagram-MVDRKAX5-6wK09D32.js → infoDiagram-MVDRKAX5-s4FMwb8r.js} +1 -1
  57. package/dist/ui/dist/assets/{ishikawaDiagram-HPEHV2GE-DTP5C8VX.js → ishikawaDiagram-HPEHV2GE-CnQdxybf.js} +1 -1
  58. package/dist/ui/dist/assets/{journeyDiagram-BOSHW7O7-B--UIMtq.js → journeyDiagram-BOSHW7O7-I-9OpDWY.js} +1 -1
  59. package/dist/ui/dist/assets/{kanban-definition-FNSA4HC5-Ku4N9QGt.js → kanban-definition-FNSA4HC5-DqUbIdH4.js} +1 -1
  60. package/dist/ui/dist/assets/{line-C4-H5h1-.js → line-BELSui4d.js} +1 -1
  61. package/dist/ui/dist/assets/{linear-DhAID3hm.js → linear-77hmn2iQ.js} +1 -1
  62. package/dist/ui/dist/assets/{mermaid-parser.core-CMPNc-u6.js → mermaid-parser.core-Bxa71wW-.js} +3 -3
  63. package/dist/ui/dist/assets/{mermaid.core-Nd_xOg2y.js → mermaid.core-DBI9F4bh.js} +4 -4
  64. package/dist/ui/dist/assets/{mindmap-definition-6OY57UG3-BlVsym5h.js → mindmap-definition-6OY57UG3-BxXMOapP.js} +1 -1
  65. package/dist/ui/dist/assets/{packet-JDAUHWVQ-rsk7SM7S.js → packet-JDAUHWVQ-DTT77cQh.js} +1 -1
  66. package/dist/ui/dist/assets/{pegDiagram-ECIUJNBX-C0L20M8z.js → pegDiagram-ECIUJNBX-HzrOQPT5.js} +1 -1
  67. package/dist/ui/dist/assets/{pie-XZMESJXO-B23-iZSN.js → pie-XZMESJXO-iHwL1Qw-.js} +1 -1
  68. package/dist/ui/dist/assets/{pieDiagram-GNMPY74T-BqFhe4Oh.js → pieDiagram-GNMPY74T-DZ1tLVjw.js} +1 -1
  69. package/dist/ui/dist/assets/{prism-core-DYsiqJeR.js → prism-core-NtO0YF9C.js} +1 -1
  70. package/dist/ui/dist/assets/{quadrantDiagram-D57K4MC3-BKP75B-m.js → quadrantDiagram-D57K4MC3-BjTi9H5L.js} +1 -1
  71. package/dist/ui/dist/assets/{radar-ABXABTNO-BrEk2mQ7.js → radar-ABXABTNO-D2hYgXSB.js} +1 -1
  72. package/dist/ui/dist/assets/{railroad-I3PHUGI6-wWO4Eapc.js → railroad-I3PHUGI6-DpakXN74.js} +1 -1
  73. package/dist/ui/dist/assets/{railroad-abnf-LNEFI6M7-DfY-IPvE.js → railroad-abnf-LNEFI6M7-B8ZFR3iy.js} +1 -1
  74. package/dist/ui/dist/assets/{railroad-ebnf-DWYD2UWJ-19wedWGk.js → railroad-ebnf-DWYD2UWJ-S1XUyTsC.js} +1 -1
  75. package/dist/ui/dist/assets/{railroad-peg-JR7BVNR7-9vIslnoJ.js → railroad-peg-JR7BVNR7-BbeHC_6K.js} +1 -1
  76. package/dist/ui/dist/assets/{railroadDiagram-Y72NRBL7-DVnmXs8w.js → railroadDiagram-Y72NRBL7-fd-2iyBo.js} +1 -1
  77. package/dist/ui/dist/assets/{requirementDiagram-EQYSW5RA-BEoMdlzR.js → requirementDiagram-EQYSW5RA-B9-HHKH-.js} +1 -1
  78. package/dist/ui/dist/assets/{sankeyDiagram-SY24JHO5-D7Tn2Gbs.js → sankeyDiagram-SY24JHO5-CeQN1ZEG.js} +1 -1
  79. package/dist/ui/dist/assets/{sequenceDiagram-BLEHNHQU-CyjUDBaQ.js → sequenceDiagram-BLEHNHQU-Vff7tx4t.js} +1 -1
  80. package/dist/ui/dist/assets/{src-BiC9XNcI.js → src-D5-ZzLwt.js} +1 -1
  81. package/dist/ui/dist/assets/{stateDiagram-v2-A6IESKWZ-CMp1VCO9.js → stateDiagram-v2-A6IESKWZ-Bzn_ma9c.js} +1 -1
  82. package/dist/ui/dist/assets/{swimlanes-B3ENRKFQ-KEciLd6r.js → swimlanes-B3ENRKFQ-CevqLchV.js} +1 -1
  83. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-Bkv8hAmN.js +8 -0
  84. package/dist/ui/dist/assets/{timeline-definition-JXYKGLGB-C8YQtv7M.js → timeline-definition-JXYKGLGB-D9jSDGJI.js} +1 -1
  85. package/dist/ui/dist/assets/{treeView-D4JQ5SDB-DgAzPaOO.js → treeView-D4JQ5SDB-CoXKj11m.js} +1 -1
  86. package/dist/ui/dist/assets/{treemap-SAJKECNS-eCRqZMYc.js → treemap-SAJKECNS-cHsansGe.js} +1 -1
  87. package/dist/ui/dist/assets/{usecaseDiagram-WPOLBNNX-Dirryw9G.js → usecaseDiagram-WPOLBNNX-CW9i3bW6.js} +1 -1
  88. package/dist/ui/dist/assets/{vennDiagram-Q3Y3IDCW-BjxQbAUx.js → vennDiagram-Q3Y3IDCW-DuU3qEKJ.js} +1 -1
  89. package/dist/ui/dist/assets/{wardley-7MLQ67FV-CqHX5RUz.js → wardley-7MLQ67FV-dl9RgHUI.js} +1 -1
  90. package/dist/ui/dist/assets/{wardleyDiagram-IVAIDKWA-Ds1HoEUY.js → wardleyDiagram-IVAIDKWA-BOXhrK-a.js} +1 -1
  91. package/dist/ui/dist/assets/{xychartDiagram-4URNHDU4-kodzDb12.js → xychartDiagram-4URNHDU4-CLQNGrh4.js} +1 -1
  92. package/dist/ui/dist/index.html +2 -2
  93. package/package.json +1 -1
  94. package/skills/follow-me/SKILL.md +2 -0
  95. package/dist/ui/dist/assets/channel-DPDY0XJP.js +0 -1
  96. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-C1IBh_GZ.js +0 -1
  97. package/dist/ui/dist/assets/index-8BehwQ7E.js +0 -134
  98. package/dist/ui/dist/assets/index-mC2OW2Av.css +0 -1
  99. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-DzestpDE.js +0 -8
package/README.md CHANGED
@@ -78,6 +78,9 @@ The red blob in the party hat is your branch tip. Nothing follow-me does moves i
78
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
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
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
+
81
84
  ![A diagram opened in the lightbox at full size](assets/screenshots/diagram.png)
82
85
  *Diagrams sit full width under the commit or reply they explain, and open full size on click.*
83
86
 
package/dist/src/cli.js CHANGED
@@ -3,14 +3,15 @@ import { readFileSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join, relative, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { changedLines, LcovError, parseLcov, parseLocArg, recordRun, runOf, tipPlacer, totalOf, } from "./coverage.js";
6
+ import { mapOffTip, parseStateMap } from "./comments.js";
7
+ import { changedLines, LcovError, PREVIEW_LINES, parseLcov, parseLocArg, recordRun, runOf, tipPlacer, totalOf, } from "./coverage.js";
7
8
  import { definitionIndex, isIdentifier } from "./definition.js";
8
- import { DiffParseError, formatUnitRef, isUnitId } from "./diff.js";
9
- import { BranchRefusedError, branchHead, GitError, isOwnWork, pathsAt, pathsChanged, signing, treeOf, writeBranch, } from "./git.js";
10
- import { fetchCarried, fetchComments, fetchReport, findServer, followEvents, idleMs, listSessions, openBrowser, pollEvents, portFallbackMessage, postComment, postCoverage, 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";
11
12
  import { findSkillDir, InstallError, runInstall, runUninstall, syncSkills, } from "./install.js";
12
13
  import { naiveDraft } from "./naive.js";
13
- 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";
14
15
  import { renderEvents, renderPrompt, renderReport, renderShownComment, renderShownConcern, renderShownDefinitions, renderShownUnit, reportItems, } from "./prompt.js";
15
16
  import { RegistrationError, sessionIdOf } from "./server.js";
16
17
  import { BranchMovedError, cleanSessions, coverageAt, extractSession, MissingDraftError, NotARepositoryError, openSession, SessionFileError, SessionLockedError, sessionComments, sinceLastPlan, } from "./session.js";
@@ -31,9 +32,10 @@ prints what they flagged or asked. It is interactive: do what its "next:" line s
31
32
  again until it prints the report.
32
33
 
33
34
  Other commands:
34
- reply <commentId> --body <text> [--diagram <file>]
35
+ reply <commentId> --body <text> [--diagram <file> [--map <file>]]
35
36
  answer under a comment on the page; the diagram is
36
- Mermaid source
37
+ Mermaid source, the map JSON tying its states to
38
+ tip lines
37
39
  explain [--commit <n>] [--unit <id>] [--lines <a-b>] --body <text> [--diagram <file>]
38
40
  start a thread on the page without being asked
39
41
  show <h_…|c_…|k_…|path:line[@n|@base]|name>
@@ -42,8 +44,9 @@ Other commands:
42
44
  is defined, instead of reading files
43
45
  report [--json] [--markdown] print the report of the latest review; --json lists
44
46
  the items to post to a merge request
45
- coverage <lcov> [--test <id> [--at <path:line>]]
46
- record a test run taken at the tip for the page
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
47
50
 
48
51
  help --all lists every command.`;
49
52
  const USAGE = `usage: follow-me <command> [options]
@@ -86,9 +89,10 @@ Interactive loop (while the review page is open):
86
89
  reports the end of the review. Your own replies are
87
90
  left out. --follow streams every event until the
88
91
  review ends instead of stopping at the first
89
- reply <commentId> --body <text> [--diagram <file>]
92
+ reply <commentId> --body <text> [--diagram <file> [--map <file>]]
90
93
  answer under a comment on the page; the diagram is
91
- Mermaid source
94
+ Mermaid source, the map JSON tying its states to
95
+ tip lines
92
96
  explain [--commit <n>] [--unit <id>] [--lines <a-b>] --body <text> [--diagram <file>]
93
97
  start a thread on the page without being asked;
94
98
  --lines are change lines of the unit, commits count
@@ -107,11 +111,12 @@ Interactive loop (while the review page is open):
107
111
  line in both voices, then questions with answers;
108
112
  --json lists the items to post to a merge request
109
113
  (path, lines, severity, body, thread, excluded)
110
- coverage <lcov> [--test <id> [--at <path:line>]] [--branch <name>]
114
+ coverage <lcov> [--test <id> [--at <path:line>]] [--reset] [--branch <name>]
111
115
  record the lcov of a test run at the branch tip;
112
116
  refuses when a covered file in the working tree
113
117
  differs from the tip. A plain run replaces the last;
114
- --test runs add one test each, --at is where it is
118
+ --test runs add one test each, --at is where it is.
119
+ --reset starts fresh; alone it clears the last run
115
120
  apply, while the page is open, swaps it to the new plan version in place, same URL.
116
121
 
117
122
  Old names, still working:
@@ -291,6 +296,12 @@ function formatIssue(issue) {
291
296
  return `$.commits[${issue.commit}]: concern: ${issue.reason}`;
292
297
  case "too-many-concerns":
293
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`;
294
305
  case "final-mismatch": {
295
306
  const tip = issue.expected ? `${issue.expected.mode} ${issue.expected.blob}` : "absent";
296
307
  return `${show(issue.path)}: the last commit has ${issue.got}, the tip has ${tip}`;
@@ -305,6 +316,9 @@ function formatWarning(w) {
305
316
  if (w.kind === "long-title") {
306
317
  return `warning: $.commits[${w.commit}]: title is ${w.length} characters; keep it to 50`;
307
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
+ }
308
322
  if (w.kind === "question-addressed") {
309
323
  return `warning: question ${w.comment} is marked addressed; explain it with "answered" and "at" unless answering it changed the plan`;
310
324
  }
@@ -1053,6 +1067,13 @@ function waitOf(args) {
1053
1067
  /** One call fits inside a harness's tool timeout, so the agent's loop is one command per turn. */
1054
1068
  const ENTRY_WAIT_SECONDS = 90;
1055
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
+ }
1056
1077
  /**
1057
1078
  * Listens to the hosted review for up to --wait seconds and prints what the reviewer did. It
1058
1079
  * returns at once with news, and with the report when the reviewer submitted or ended the review.
@@ -1112,11 +1133,13 @@ async function listen(session, open, args) {
1112
1133
  next("done; the user can port it with follow-me report --json");
1113
1134
  return 0;
1114
1135
  }
1115
- 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`);
1116
1138
  return 0;
1117
1139
  }
1118
1140
  if (left <= 0) {
1119
- 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`);
1120
1143
  return 0;
1121
1144
  }
1122
1145
  }
@@ -1315,7 +1338,7 @@ async function report(cwd, args, walkOnly = false) {
1315
1338
  process.stdout.write(text);
1316
1339
  return 0;
1317
1340
  }
1318
- const COVERAGE_USAGE = "usage: follow-me coverage <lcov> [--test <id> [--at <path:line>]]";
1341
+ const COVERAGE_USAGE = "usage: follow-me coverage <lcov> [--test <id> [--at <path:line>]] [--reset]\n follow-me coverage --reset";
1319
1342
  /**
1320
1343
  * Records the lcov of a test run the agent made at the tip in the session's coverage.json and
1321
1344
  * prints how much of the branch's changed code it ran. follow-me never runs the tests itself.
@@ -1324,6 +1347,13 @@ async function coverage(cwd, args) {
1324
1347
  const [file, extra] = args.positionals;
1325
1348
  const id = args.values.get("test");
1326
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
+ }
1327
1357
  if (file === undefined || extra !== undefined)
1328
1358
  throw new UsageError(COVERAGE_USAGE);
1329
1359
  if (atArg !== undefined && id === undefined) {
@@ -1354,10 +1384,15 @@ async function coverage(cwd, args) {
1354
1384
  const at = atArg === undefined ? undefined : parseLocArg(atArg, place);
1355
1385
  if (typeof at === "string")
1356
1386
  throw new UsageError(at);
1357
- test = { id, ...(at === undefined ? {} : { at }) };
1387
+ const preview = at === undefined ? undefined : await testPreview(session, at);
1388
+ test = {
1389
+ id,
1390
+ ...(at === undefined ? {} : { at }),
1391
+ ...(preview === undefined ? {} : { preview }),
1392
+ };
1358
1393
  }
1359
1394
  const takenAt = new Date().toISOString();
1360
- const recorded = recordRun(session.coverage(), run, {
1395
+ const recorded = recordRun(reset ? null : session.coverage(), run, {
1361
1396
  tip,
1362
1397
  takenAt,
1363
1398
  ...(test === undefined ? {} : { test }),
@@ -1370,6 +1405,19 @@ async function coverage(cwd, args) {
1370
1405
  say(`coverage for ${plural(recorded.files.size, "file")} at ${short(tip)}; ${covered} of ${executable} changed lines covered`);
1371
1406
  return 0;
1372
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
+ }
1373
1421
  /** The old name of report. */
1374
1422
  async function feedback(cwd, args) {
1375
1423
  console.error("follow-me: feedback is now report; printing the same");
@@ -1380,11 +1428,13 @@ function isCommentEvent(event) {
1380
1428
  }
1381
1429
  /**
1382
1430
  * Events the reviewer caused; the agent's own replies, explanations, version swaps and coverage
1383
- * are not news.
1431
+ * are not news, and neither is a dismissed offer, which asks nothing of the agent.
1384
1432
  */
1385
1433
  function isNews(event) {
1386
1434
  if (event.type === "version" || event.type === "coverage")
1387
1435
  return false;
1436
+ if (event.type === "offer-dismissed")
1437
+ return false;
1388
1438
  if (event.type === "comment" || event.type === "comment-updated") {
1389
1439
  return event.comment.author !== "agent";
1390
1440
  }
@@ -1464,12 +1514,18 @@ async function watch(cwd, args) {
1464
1514
  /** Posts the agent's answer under a comment on the hosted page. */
1465
1515
  async function reply(cwd, args) {
1466
1516
  const [commentId, extra] = args.positionals;
1467
- 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 : "");
1468
1520
  if (commentId === undefined || extra !== undefined || body === undefined) {
1469
- 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}`);
1470
1526
  }
1471
- const diagram = diagramOf(args);
1472
1527
  const session = await openSession(cwd, sessionOpts(args));
1528
+ const map = mapFile === undefined ? undefined : await readMap(session, mapFile, diagram);
1473
1529
  const open = await openOnServer(session, null);
1474
1530
  if (open === null)
1475
1531
  return notOpen(session);
@@ -1477,11 +1533,59 @@ async function reply(cwd, args) {
1477
1533
  author: "agent",
1478
1534
  body,
1479
1535
  ...(diagram === undefined ? {} : { diagram }),
1536
+ ...(map === undefined ? {} : { map }),
1480
1537
  });
1481
1538
  say(`replied ${id} to ${commentId}`);
1482
1539
  next(`run follow-me again; ${STILL_WORKING}`);
1483
1540
  return 0;
1484
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
+ }
1485
1589
  /** The Mermaid source in --diagram's file, or undefined without the flag. */
1486
1590
  function diagramOf(args) {
1487
1591
  const file = args.values.get("diagram");
@@ -1950,7 +2054,7 @@ export async function main(argv, cwd) {
1950
2054
  return await watch(cwd, parseArgs(rest, spec));
1951
2055
  }
1952
2056
  case "reply":
1953
- return await reply(cwd, parseArgs(rest, { values: ["body", "diagram", "branch"] }));
2057
+ return await reply(cwd, parseArgs(rest, { values: ["body", "diagram", "map", "branch"] }));
1954
2058
  case "explain": {
1955
2059
  const spec = { values: ["commit", "unit", "lines", "body", "diagram", "branch"] };
1956
2060
  return await explain(cwd, parseArgs(rest, spec));
@@ -1958,7 +2062,7 @@ export async function main(argv, cwd) {
1958
2062
  case "show":
1959
2063
  return await showRef(cwd, parseArgs(rest, { values: ["branch"] }));
1960
2064
  case "coverage":
1961
- return await coverage(cwd, parseArgs(rest, { values: ["test", "at", "branch"] }));
2065
+ return await coverage(cwd, parseArgs(rest, { values: ["test", "at", "branch"], flags: ["reset"] }));
1962
2066
  case "report":
1963
2067
  return await report(cwd, parseArgs(rest, { values: ["branch"], flags: ["json", "markdown"] }));
1964
2068
  case "clean": {
@@ -1,5 +1,6 @@
1
+ import { parseLoc } from "./coverage.js";
1
2
  import { isRepoPath, isSha, isUnitId } from "./diff.js";
2
- import { diagramProblem, isArray, isCommentId, isConcernId, isFields, isPlanVersion, isSeverity, parsePlacedConcern, } from "./plan.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";
@@ -188,6 +188,8 @@ function mergeMax(a, b) {
188
188
  }
189
189
  return out;
190
190
  }
191
+ /** How many tip lines a test outside the branch's changes carries for the page's peek. */
192
+ export const PREVIEW_LINES = 8;
191
193
  /**
192
194
  * The coverage after this run. A plain run replaces everything, its TN blocks becoming its tests.
193
195
  * A named test's run adds to what is already there for the same tip, and starts over otherwise.
@@ -202,6 +204,7 @@ export function recordRun(prior, run, at) {
202
204
  const tests = new Map(base?.tests ?? []);
203
205
  const entry = {
204
206
  ...(test.at === undefined ? {} : { at: test.at }),
207
+ ...(test.preview === undefined ? {} : { preview: test.preview }),
205
208
  lines: hitLines(run.files),
206
209
  };
207
210
  tests.set(test.id, entry);
@@ -281,6 +284,7 @@ export function serializeCoverage(c) {
281
284
  id,
282
285
  {
283
286
  ...(t.at === undefined ? {} : { at: t.at }),
287
+ ...(t.preview === undefined ? {} : { preview: [...t.preview] }),
284
288
  lines: Object.fromEntries([...t.lines].map(([p, ls]) => [p, [...ls]])),
285
289
  },
286
290
  ]));
@@ -296,7 +300,8 @@ function isLine(v) {
296
300
  function entries(v) {
297
301
  return isFields(v) ? Object.entries(v) : null;
298
302
  }
299
- function parseLoc(v) {
303
+ /** A { path, start, end } with 1 <= start <= end, or null. */
304
+ export function parseLoc(v) {
300
305
  if (!isFields(v))
301
306
  return null;
302
307
  const { path, start, end } = v;
@@ -350,6 +355,11 @@ function parseTest(v) {
350
355
  const at = v.at === undefined ? undefined : parseLoc(v.at);
351
356
  if (at === null)
352
357
  return "at is not a { path, start, end }";
358
+ const preview = v.preview;
359
+ if (preview !== undefined &&
360
+ !(Array.isArray(preview) && preview.every((l) => typeof l === "string"))) {
361
+ return "preview is not a list of lines";
362
+ }
353
363
  const lineEntries = entries(v.lines);
354
364
  if (lineEntries === null)
355
365
  return "lines is not an object";
@@ -360,7 +370,11 @@ function parseTest(v) {
360
370
  }
361
371
  lines.set(path, list);
362
372
  }
363
- return { ...(at === undefined ? {} : { at }), lines };
373
+ return {
374
+ ...(at === undefined ? {} : { at }),
375
+ ...(preview === undefined ? {} : { preview: preview.filter((l) => typeof l === "string") }),
376
+ lines,
377
+ };
364
378
  }
365
379
  /** The one place coverage.json becomes a Coverage. */
366
380
  export function parseCoverage(v) {
package/dist/src/host.js CHANGED
@@ -320,6 +320,29 @@ export async function fetchComments(port, summary) {
320
320
  }
321
321
  return out;
322
322
  }
323
+ /**
324
+ * Offers accepted during the hosted review, on this version or a carried one, whose comment has no
325
+ * reply from the agent yet, oldest first.
326
+ */
327
+ export async function fetchWaitingOffers(port, summary) {
328
+ const state = await call(port, "GET", `${summary.url}api/state`);
329
+ if (!isFields(state))
330
+ throw new RegistrationError("the server sent no state");
331
+ const comments = Array.isArray(state["comments"]) ? state["comments"] : [];
332
+ const carried = Array.isArray(state["carried"]) ? state["carried"] : [];
333
+ const said = [
334
+ ...comments.map((raw) => parseComment(raw)),
335
+ ...carried.map((raw) => {
336
+ const c = parseCarried(raw);
337
+ return typeof c === "string" ? c : c.comment;
338
+ }),
339
+ ];
340
+ return said
341
+ .flatMap((c) => (typeof c === "string" ? [] : [c]))
342
+ .filter((c) => !(c.replies ?? []).some((r) => r.author === "agent"))
343
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt))
344
+ .flatMap((c) => (c.offer === undefined ? [] : [{ offer: c.offer, comment: c.id }]));
345
+ }
323
346
  /** One long poll of the session's event log: what happened after `since`, or nothing at timeout. */
324
347
  export async function pollEvents(port, summary, since, timeoutMs) {
325
348
  const path = `${summary.url}api/events?since=${since}&timeout=${timeoutMs}`;