@shiyuphan/follow-me 0.8.0 → 0.9.1

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 (98) hide show
  1. package/README.md +8 -3
  2. package/dist/src/cli.js +204 -53
  3. package/dist/src/comments.js +95 -7
  4. package/dist/src/coverage.js +173 -38
  5. package/dist/src/host.js +1 -0
  6. package/dist/src/plan.js +44 -22
  7. package/dist/src/prompt.js +58 -17
  8. package/dist/src/refs.js +239 -0
  9. package/dist/ui/dist/assets/{abnfDiagram-WEYOIA3C-Bp14ygmz.js → abnfDiagram-WEYOIA3C-BWgk01ve.js} +1 -1
  10. package/dist/ui/dist/assets/{arc-DUR6uzhI.js → arc-Cg_Qb5c4.js} +1 -1
  11. package/dist/ui/dist/assets/{architecture-WOLXFQ4H-CyfnP65O.js → architecture-WOLXFQ4H-EdfV1etv.js} +1 -1
  12. package/dist/ui/dist/assets/{architectureDiagram-ZHVYKT53-dJTicIk2.js → architectureDiagram-ZHVYKT53-Dz-gytQU.js} +1 -1
  13. package/dist/ui/dist/assets/{blockDiagram-RJAE7QA3-BYiqh5Ib.js → blockDiagram-RJAE7QA3-B7Q5KhYC.js} +1 -1
  14. package/dist/ui/dist/assets/{c4Diagram-XDPPPE54-DHHPQ7xE.js → c4Diagram-XDPPPE54-Cq9IvGR1.js} +1 -1
  15. package/dist/ui/dist/assets/channel-Da0YvuQF.js +1 -0
  16. package/dist/ui/dist/assets/{chunk-2E7WHVMD-BODm_awL.js → chunk-2E7WHVMD-BTJB-4C8.js} +1 -1
  17. package/dist/ui/dist/assets/{chunk-4EMNKR2T-CpZfjf2T.js → chunk-4EMNKR2T-BILthc2j.js} +1 -1
  18. package/dist/ui/dist/assets/{chunk-55XCU76K-CoP2pJTQ.js → chunk-55XCU76K-DH3fYrJH.js} +1 -1
  19. package/dist/ui/dist/assets/{chunk-7S6XSTFO-CaQcUvXY.js → chunk-7S6XSTFO-DDIiBvc2.js} +1 -1
  20. package/dist/ui/dist/assets/{chunk-AQDTSKZB-BZ9Czvbu.js → chunk-AQDTSKZB-b1EeJ3gb.js} +1 -1
  21. package/dist/ui/dist/assets/{chunk-DSNLSHJZ-CNjUo110.js → chunk-DSNLSHJZ-DZZ635s5.js} +1 -1
  22. package/dist/ui/dist/assets/{chunk-HAGFOTED-BTmBE_D3.js → chunk-HAGFOTED-DhhrHTIh.js} +1 -1
  23. package/dist/ui/dist/assets/{chunk-HNE4Q36P-D2cDuT0C.js → chunk-HNE4Q36P-B8wvhc2e.js} +1 -1
  24. package/dist/ui/dist/assets/{chunk-NQOKRVIF-AZcemFgG.js → chunk-NQOKRVIF-CLtGMwDx.js} +1 -1
  25. package/dist/ui/dist/assets/{chunk-UHW7PFBP-BszwjUqG.js → chunk-UHW7PFBP-B9fVLGl0.js} +1 -1
  26. package/dist/ui/dist/assets/{chunk-VQ5W62QI-vFSenYSe.js → chunk-VQ5W62QI-CgPvvMAv.js} +1 -1
  27. package/dist/ui/dist/assets/{chunk-WQQL4CDK-DZR6is1Q.js → chunk-WQQL4CDK-DdqKu6R9.js} +1 -1
  28. package/dist/ui/dist/assets/{chunk-XXDRQBXY-CACFh-si.js → chunk-XXDRQBXY-GlM07vbp.js} +1 -1
  29. package/dist/ui/dist/assets/{chunk-YZO67HFG-DRRTjGuW.js → chunk-YZO67HFG-YqT6M0W9.js} +1 -1
  30. package/dist/ui/dist/assets/{chunk-ZAUX2LTX-Bxuy-Roa.js → chunk-ZAUX2LTX-DqHK51Gc.js} +1 -1
  31. package/dist/ui/dist/assets/{chunk-ZCHMQSOR-rYXk__1o.js → chunk-ZCHMQSOR-DEeXkjWz.js} +1 -1
  32. package/dist/ui/dist/assets/{classDiagram-v2-4MYWR3UA-RiF44tTz.js → classDiagram-v2-4MYWR3UA-BcKsX4up.js} +1 -1
  33. package/dist/ui/dist/assets/{cose-bilkent-JH36ORCC-0oiQoThS.js → cose-bilkent-JH36ORCC-fu9djgYc.js} +1 -1
  34. package/dist/ui/dist/assets/{cynefin-EF2NZ3EQ-BAssFK5K.js → cynefin-EF2NZ3EQ-CFJASBsJ.js} +1 -1
  35. package/dist/ui/dist/assets/{cynefinDiagram-WF24UDRP-Bmd05bak.js → cynefinDiagram-WF24UDRP-BVJD_n0g.js} +1 -1
  36. package/dist/ui/dist/assets/{dagre-NK4GIZVX-QJkLjPxj.js → dagre-NK4GIZVX-Cx8pjz9O.js} +1 -1
  37. package/dist/ui/dist/assets/{diagram-CBK3MWTC-PSaSiYXz.js → diagram-CBK3MWTC-CVIkEZ1X.js} +1 -1
  38. package/dist/ui/dist/assets/{diagram-I5XIIV2D-BmU5X_hr.js → diagram-I5XIIV2D-2rxpF8BZ.js} +1 -1
  39. package/dist/ui/dist/assets/{diagram-ODIL2UCW-AAV39kUu.js → diagram-ODIL2UCW-NJfGHnLk.js} +1 -1
  40. package/dist/ui/dist/assets/{diagram-PFBEJUXE-DWlV-oxd.js → diagram-PFBEJUXE-DZHVJSeM.js} +1 -1
  41. package/dist/ui/dist/assets/{diagram-RJA3I4VV-D8wHRCNX.js → diagram-RJA3I4VV-CQAfOObI.js} +1 -1
  42. package/dist/ui/dist/assets/{diagram-TYUNFSNR-B_JQEsNY.js → diagram-TYUNFSNR-DGGaMOl6.js} +1 -1
  43. package/dist/ui/dist/assets/{dist-DxQIK_Ay.js → dist-CW-4NrUq.js} +1 -1
  44. package/dist/ui/dist/assets/{ebnfDiagram-V4P3TTPT-D83qV8mv.js → ebnfDiagram-V4P3TTPT-92pb2Fcg.js} +1 -1
  45. package/dist/ui/dist/assets/{elk-QONTBCTQ-BKFAKVSD.js → elk-QONTBCTQ-vu0Z4rYG.js} +1 -1
  46. package/dist/ui/dist/assets/{erDiagram-KYQIGQAA-CHYRd4aM.js → erDiagram-KYQIGQAA-BXnsRzCQ.js} +1 -1
  47. package/dist/ui/dist/assets/{eventmodeling-K75KTNOO-BDJDv3Zf.js → eventmodeling-K75KTNOO-bSr88MT1.js} +1 -1
  48. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-CUH45H9n.js +1 -0
  49. package/dist/ui/dist/assets/{ganttDiagram-R7GPNTJY-gTHjYkFj.js → ganttDiagram-R7GPNTJY-BQzVkGbj.js} +1 -1
  50. package/dist/ui/dist/assets/{gitGraph-VSP46ZUC-D4cWkxKB.js → gitGraph-VSP46ZUC-BegblPQi.js} +1 -1
  51. package/dist/ui/dist/assets/{gitGraphDiagram-PDYL7XHY-D3M9wYMr.js → gitGraphDiagram-PDYL7XHY-BaGlGpEX.js} +1 -1
  52. package/dist/ui/dist/assets/index-D74Skifa.css +1 -0
  53. package/dist/ui/dist/assets/index-D8ErIacA.js +136 -0
  54. package/dist/ui/dist/assets/{info-OHQRW6UA-B9y0ksVq.js → info-OHQRW6UA-D3HVGJDc.js} +1 -1
  55. package/dist/ui/dist/assets/{infoDiagram-MVDRKAX5-s4FMwb8r.js → infoDiagram-MVDRKAX5-Ckcp-D0C.js} +1 -1
  56. package/dist/ui/dist/assets/{ishikawaDiagram-HPEHV2GE-CnQdxybf.js → ishikawaDiagram-HPEHV2GE-CWhd0CTS.js} +1 -1
  57. package/dist/ui/dist/assets/{journeyDiagram-BOSHW7O7-I-9OpDWY.js → journeyDiagram-BOSHW7O7-CrH5cFXY.js} +1 -1
  58. package/dist/ui/dist/assets/{kanban-definition-FNSA4HC5-DqUbIdH4.js → kanban-definition-FNSA4HC5-CpORMTDZ.js} +1 -1
  59. package/dist/ui/dist/assets/{line-BELSui4d.js → line-B3El9R0Z.js} +1 -1
  60. package/dist/ui/dist/assets/{linear-77hmn2iQ.js → linear-DLfthkbB.js} +1 -1
  61. package/dist/ui/dist/assets/{mermaid-parser.core-Bxa71wW-.js → mermaid-parser.core-QtQjLaoK.js} +3 -3
  62. package/dist/ui/dist/assets/{mermaid.core-DBI9F4bh.js → mermaid.core-C2DGgMSc.js} +4 -4
  63. package/dist/ui/dist/assets/{mindmap-definition-6OY57UG3-BxXMOapP.js → mindmap-definition-6OY57UG3-CW1lSXnX.js} +1 -1
  64. package/dist/ui/dist/assets/{packet-JDAUHWVQ-DTT77cQh.js → packet-JDAUHWVQ-Dhu_m_17.js} +1 -1
  65. package/dist/ui/dist/assets/{pegDiagram-ECIUJNBX-HzrOQPT5.js → pegDiagram-ECIUJNBX-BGOhbzN4.js} +1 -1
  66. package/dist/ui/dist/assets/{pie-XZMESJXO-iHwL1Qw-.js → pie-XZMESJXO-Citt3cMX.js} +1 -1
  67. package/dist/ui/dist/assets/{pieDiagram-GNMPY74T-DZ1tLVjw.js → pieDiagram-GNMPY74T-DPVZikvz.js} +1 -1
  68. package/dist/ui/dist/assets/{prism-core-NtO0YF9C.js → prism-core-YgwsYh9d.js} +1 -1
  69. package/dist/ui/dist/assets/{quadrantDiagram-D57K4MC3-BjTi9H5L.js → quadrantDiagram-D57K4MC3-Bwye1B9e.js} +1 -1
  70. package/dist/ui/dist/assets/{radar-ABXABTNO-D2hYgXSB.js → radar-ABXABTNO-DRUb4G6V.js} +1 -1
  71. package/dist/ui/dist/assets/{railroad-I3PHUGI6-DpakXN74.js → railroad-I3PHUGI6-x3Ug2mxX.js} +1 -1
  72. package/dist/ui/dist/assets/{railroad-abnf-LNEFI6M7-B8ZFR3iy.js → railroad-abnf-LNEFI6M7-Dw14XhkZ.js} +1 -1
  73. package/dist/ui/dist/assets/{railroad-ebnf-DWYD2UWJ-S1XUyTsC.js → railroad-ebnf-DWYD2UWJ-AwoWTWgL.js} +1 -1
  74. package/dist/ui/dist/assets/{railroad-peg-JR7BVNR7-BbeHC_6K.js → railroad-peg-JR7BVNR7-U7MXfciE.js} +1 -1
  75. package/dist/ui/dist/assets/{railroadDiagram-Y72NRBL7-fd-2iyBo.js → railroadDiagram-Y72NRBL7-BxWlCZYs.js} +1 -1
  76. package/dist/ui/dist/assets/{requirementDiagram-EQYSW5RA-B9-HHKH-.js → requirementDiagram-EQYSW5RA-TPmnZguV.js} +1 -1
  77. package/dist/ui/dist/assets/{sankeyDiagram-SY24JHO5-CeQN1ZEG.js → sankeyDiagram-SY24JHO5-nPGftpl5.js} +1 -1
  78. package/dist/ui/dist/assets/{sequenceDiagram-BLEHNHQU-Vff7tx4t.js → sequenceDiagram-BLEHNHQU-BdctgaWa.js} +1 -1
  79. package/dist/ui/dist/assets/{src-D5-ZzLwt.js → src-zq235FjH.js} +1 -1
  80. package/dist/ui/dist/assets/{stateDiagram-v2-A6IESKWZ-Bzn_ma9c.js → stateDiagram-v2-A6IESKWZ-BK_E6kPg.js} +1 -1
  81. package/dist/ui/dist/assets/{swimlanes-B3ENRKFQ-CevqLchV.js → swimlanes-B3ENRKFQ-BY50Ne4l.js} +1 -1
  82. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-Ctw23I3i.js +8 -0
  83. package/dist/ui/dist/assets/{timeline-definition-JXYKGLGB-D9jSDGJI.js → timeline-definition-JXYKGLGB-Bg8AXJ2P.js} +1 -1
  84. package/dist/ui/dist/assets/{treeView-D4JQ5SDB-CoXKj11m.js → treeView-D4JQ5SDB-B4hz6M4l.js} +1 -1
  85. package/dist/ui/dist/assets/{treemap-SAJKECNS-cHsansGe.js → treemap-SAJKECNS-e_Vc97Ox.js} +1 -1
  86. package/dist/ui/dist/assets/{usecaseDiagram-WPOLBNNX-CW9i3bW6.js → usecaseDiagram-WPOLBNNX-Butu_LCX.js} +1 -1
  87. package/dist/ui/dist/assets/{vennDiagram-Q3Y3IDCW-DuU3qEKJ.js → vennDiagram-Q3Y3IDCW-BGIyxlbf.js} +1 -1
  88. package/dist/ui/dist/assets/{wardley-7MLQ67FV-dl9RgHUI.js → wardley-7MLQ67FV-Btv95mqq.js} +1 -1
  89. package/dist/ui/dist/assets/{wardleyDiagram-IVAIDKWA-BOXhrK-a.js → wardleyDiagram-IVAIDKWA-DwTKExJ4.js} +1 -1
  90. package/dist/ui/dist/assets/{xychartDiagram-4URNHDU4-CLQNGrh4.js → xychartDiagram-4URNHDU4-UmlA5uxc.js} +1 -1
  91. package/dist/ui/dist/index.html +2 -2
  92. package/package.json +1 -1
  93. package/skills/follow-me/SKILL.md +8 -8
  94. package/dist/ui/dist/assets/channel-CY2cQobm.js +0 -1
  95. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-Bke3CfUH.js +0 -1
  96. package/dist/ui/dist/assets/index-BUOFihod.js +0 -134
  97. package/dist/ui/dist/assets/index-DLNJT0VS.css +0 -1
  98. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-Bkv8hAmN.js +0 -8
package/README.md CHANGED
@@ -75,11 +75,16 @@ 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
+ When your agent's answer names code, like `src/lib.js:4-5`, each place becomes a numbered chip. Hover the answer to tint those lines, click a chip to jump there and Alt+Left to come back. An answer that names several places is a walkthrough: press Enter on it, then `.` and `,` to step.
79
+
80
+ ![An agent reply naming three places as numbered chips, one in a later commit, with those lines tinted and numbered in the code and the walkthrough bar at step 1 of 3](assets/screenshots/refs.png)
81
+ *A walkthrough. Each place the agent names is tinted and numbered on the code, the current step strongest, and the bar at the bottom says where you are.*
82
+
78
83
  ![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
84
  *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
85
 
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.*
86
+ ![A checkout state machine reply: each state marked by whether a test reached it, tested transitions green with a count of their tests, one untested transition red and dashed, the sequences no one test drives, and a table naming the tests of each state and move](assets/screenshots/state-machine.png)
87
+ *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. A transition is green with the number of tests that take it, or red and dashed when none does. When the tests run one at a time, the table names them, and a list names the sequences each step of which has a test but no one test drives. Click a state or a transition to jump to its code.*
83
88
 
84
89
  ![A diagram opened in the lightbox at full size](assets/screenshots/diagram.png)
85
90
  *Diagrams sit full width under the commit or reply they explain, and open full size on click.*
@@ -87,7 +92,7 @@ The red blob in the party hat is your branch tip. Nothing follow-me does moves i
87
92
  ![The hand-off page: every kept item with its original-branch path and line, an include checkbox, and copy buttons](assets/screenshots/handoff.png)
88
93
  *Submit review. Each item carries the original branch's path and line, so your agent can post it to the merge request as is.*
89
94
 
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.
95
+ 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, `.` and `,` step through the agent's walkthrough, `?` lists the rest. An unfinished review autosaves and comes back when the page is opened again.
91
96
 
92
97
  ### Reviewing someone else's branch
93
98
 
package/dist/src/cli.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { readFileSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, 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
6
  import { mapOffTip, parseStateMap } from "./comments.js";
7
- import { changedLines, LcovError, PREVIEW_LINES, parseLcov, parseLocArg, recordRun, runOf, tipPlacer, totalOf, } from "./coverage.js";
7
+ import { changedBranchesTaken, changedLines, LcovError, PREVIEW_LINES, parseLcov, parseLocArg, recordRun, runOf, tipPlacer, totalOf, } from "./coverage.js";
8
8
  import { definitionIndex, isIdentifier } from "./definition.js";
9
9
  import { DiffParseError, formatUnitRef, isRepoPath, isUnitId, splitLines } from "./diff.js";
10
10
  import { BranchRefusedError, blobsAt, branchHead, GitError, isOwnWork, pathsAt, pathsChanged, signing, treeOf, writeBranch, } from "./git.js";
@@ -13,12 +13,17 @@ import { findSkillDir, InstallError, runInstall, runUninstall, syncSkills, } fro
13
13
  import { naiveDraft } from "./naive.js";
14
14
  import { isCommentId, isConcernId, isFields, OFFERS_PER_COMMIT, OFFERS_PER_PLAN, serializePlanFile, validatePlan, } from "./plan.js";
15
15
  import { renderEvents, renderPrompt, renderReport, renderShownComment, renderShownConcern, renderShownDefinitions, renderShownUnit, reportItems, } from "./prompt.js";
16
+ import { parseRefText, refsIn, repoPathOfTyped, resolveRefs } from "./refs.js";
16
17
  import { RegistrationError, sessionIdOf } from "./server.js";
17
18
  import { BranchMovedError, cleanSessions, coverageAt, extractSession, MissingDraftError, NotARepositoryError, openSession, SessionFileError, SessionLockedError, sessionComments, sinceLastPlan, } from "./session.js";
18
19
  import { checkSyntax } from "./syntax.js";
19
20
  class UsageError extends Error {
20
21
  name = "UsageError";
21
22
  }
23
+ /** `--help` or `-h` anywhere a command reads its options; main prints that command's usage. */
24
+ class HelpRequested extends Error {
25
+ name = "HelpRequested";
26
+ }
22
27
  /** A test run follow-me will not record, with the reason the agent can act on. */
23
28
  class CoverageError extends Error {
24
29
  name = "CoverageError";
@@ -38,15 +43,16 @@ Other commands:
38
43
  tip lines
39
44
  explain [--commit <n>] [--unit <id>] [--lines <a-b>] --body <text> [--diagram <file>]
40
45
  start a thread on the page without being asked
41
- show <h_…|c_…|k_…|path:line[@n|@base]|name>
46
+ show <h_…|c_…|k_…|path:a[-b][@n|@base]|name>
42
47
  print a unit, a comment's thread, a concern, the
43
- lines around path:line in commit n, or where a name
48
+ lines around path:a-b in commit n, or where a name
44
49
  is defined, instead of reading files
45
50
  report [--json] [--markdown] print the report of the latest review; --json lists
46
51
  the items to post to a merge request
47
52
  coverage <lcov> [--test <id> [--at <path:line>]] [--reset]
48
53
  record a test run taken at the tip for the page;
49
- --reset alone clears it before per-test runs
54
+ --at is where the test is declared; --reset alone
55
+ clears it before per-test runs
50
56
 
51
57
  help --all lists every command.`;
52
58
  const USAGE = `usage: follow-me <command> [options]
@@ -92,16 +98,17 @@ Interactive loop (while the review page is open):
92
98
  reply <commentId> --body <text> [--diagram <file> [--map <file>]]
93
99
  answer under a comment on the page; the diagram is
94
100
  Mermaid source, the map JSON tying its states to
95
- tip lines
101
+ tip lines. \`path:a-b[@n]\` in backticks in the body
102
+ points at code, and is refused if it does not exist
96
103
  explain [--commit <n>] [--unit <id>] [--lines <a-b>] --body <text> [--diagram <file>]
97
104
  start a thread on the page without being asked;
98
105
  --lines are change lines of the unit, commits count
99
106
  from 1; with no target it is about the whole plan.
100
107
  watch leaves these out like your replies
101
- show <h_…|c_…|k_…|path:line[@n|@base]|name> [--branch <name>]
108
+ show <h_…|c_…|k_…|path:a[-b][@n|@base]|name> [--branch <name>]
102
109
  print a unit with the commits that take it, a
103
110
  comment with its rows and thread, a concern with its
104
- lines, the lines around path:line at the tip, in
111
+ lines, the lines around path:a-b at the tip, in
105
112
  commit n of the plan, or in the base, or each place
106
113
  a name is defined at the tip with the commit it
107
114
  lands in
@@ -115,7 +122,8 @@ Interactive loop (while the review page is open):
115
122
  record the lcov of a test run at the branch tip;
116
123
  refuses when a covered file in the working tree
117
124
  differs from the tip. A plain run replaces the last;
118
- --test runs add one test each, --at is where it is.
125
+ --test runs add one test each, --at is the line
126
+ where that test is declared.
119
127
  --reset starts fresh; alone it clears the last run
120
128
  apply, while the page is open, swaps it to the new plan version in place, same URL.
121
129
 
@@ -155,6 +163,8 @@ function parseArgs(argv, spec) {
155
163
  const flags = new Set();
156
164
  for (let i = 0; i < argv.length; i++) {
157
165
  const arg = argv[i] ?? "";
166
+ if (arg === "--help" || arg === "-h")
167
+ throw new HelpRequested();
158
168
  if (!arg.startsWith("--")) {
159
169
  positionals.push(arg);
160
170
  continue;
@@ -174,6 +184,43 @@ function parseArgs(argv, spec) {
174
184
  }
175
185
  return { positionals, values, flags };
176
186
  }
187
+ /**
188
+ * The usage of one command, read from its entries in USAGE: the synopsis, then what it does.
189
+ * Null for a command USAGE does not list.
190
+ */
191
+ function usageOf(command) {
192
+ const blocks = [];
193
+ let block = null;
194
+ for (const line of USAGE.split("\n")) {
195
+ if (line.startsWith(` ${command} `) || line === ` ${command}`) {
196
+ block = [line];
197
+ blocks.push(block);
198
+ }
199
+ else if (block !== null && line.startsWith(" "))
200
+ block.push(line);
201
+ else
202
+ block = null;
203
+ }
204
+ if (blocks.length === 0)
205
+ return null;
206
+ return blocks
207
+ .map((lines) => {
208
+ const synopsis = [];
209
+ const what = [];
210
+ for (const line of lines) {
211
+ const [left = "", right = ""] = line.trim().split(/\s{3,}/, 2);
212
+ if (/^\s{20,}/.test(line))
213
+ what.push(left);
214
+ else {
215
+ synopsis.push(left);
216
+ if (right !== "")
217
+ what.push(right);
218
+ }
219
+ }
220
+ return [`usage: follow-me ${synopsis.join(" ")}`, ...what.map((w) => ` ${w}`)].join("\n");
221
+ })
222
+ .join("\n");
223
+ }
177
224
  function say(line = "") {
178
225
  process.stdout.write(`${line}\n`);
179
226
  }
@@ -263,45 +310,59 @@ function summarize(session, cwd) {
263
310
  }
264
311
  say(`session ${relative(cwd, session.dir) || "."}`);
265
312
  }
313
+ /** Commits are numbered from 1 in everything the agent reads; issues carry 0-based indices. */
314
+ function commitName(k) {
315
+ return `commit ${k + 1}`;
316
+ }
317
+ /** A JSON path into the draft with its commit named from 1: `$.commits[2].units[0]` reads `commit 3, units[0]`. */
318
+ function jsonPlace(at) {
319
+ const m = /^\$\.commits\[(\d+)\]\.?(.*)$/.exec(at);
320
+ if (m === null)
321
+ return at;
322
+ const [, k = "0", rest = ""] = m;
323
+ return rest === "" ? commitName(Number(k)) : `${commitName(Number(k))}, ${rest}`;
324
+ }
266
325
  function formatIssue(issue) {
267
326
  switch (issue.kind) {
268
327
  case "malformed":
269
- return `malformed ${issue.at}: ${issue.reason}`;
328
+ return `malformed ${jsonPlace(issue.at)}: ${issue.reason}`;
270
329
  case "stale":
271
330
  return `stale: the plan is for tip ${short(issue.planTip)} but the session tip is ${short(issue.sessionTip)}`;
272
331
  case "empty-message":
273
- return `$.commits[${issue.commit}]: empty message`;
332
+ return `${commitName(issue.commit)}: empty message`;
274
333
  case "empty-commit":
275
- return `$.commits[${issue.commit}]: no units`;
334
+ return `${commitName(issue.commit)}: no units`;
276
335
  case "unknown-unit":
277
- return `$.commits[${issue.commit}]: unknown unit ${issue.ref}`;
336
+ return `${commitName(issue.commit)}: unknown unit ${issue.ref}`;
278
337
  case "bad-selection":
279
- return `$.commits[${issue.commit}]: ${issue.unit}: ${issue.reason}`;
338
+ return `${commitName(issue.commit)}: ${issue.unit}: ${issue.reason}`;
280
339
  case "duplicate": {
281
340
  const [a, b] = issue.commits;
282
341
  const what = issue.changes.length > 0 ? lines(issue.unit, issue.changes) : issue.unit;
283
- return `${what} is claimed by commits ${a} and ${b}`;
342
+ return `${what} is claimed by commits ${a + 1} and ${b + 1}`;
284
343
  }
285
344
  case "unassigned": {
286
345
  const what = issue.changes.length > 0 ? lines(issue.unit, issue.changes) : issue.unit;
287
346
  return `${what} (${show(issue.path)}) is in no commit`;
288
347
  }
289
348
  case "path-conflict":
290
- return `$.commits[${issue.commit}]: ${show(issue.file)} is a file while ${show(issue.under)} exists`;
349
+ return `${commitName(issue.commit)}: ${show(issue.file)} is a file while ${show(issue.under)} exists`;
291
350
  case "unknown-response":
292
351
  return `response to unknown comment ${issue.comment}`;
293
352
  case "bad-answer":
294
353
  return `response to ${issue.comment}: ${issue.reason}`;
354
+ case "section-order":
355
+ return `${commitName(issue.commit)}: section ${issue.section} comes after ${commitName(issue.before)} in ${issue.beforeSection}; sections read story, steps, details, wiring, so move ${commitName(issue.commit)} before ${commitName(issue.before)}, or change its section`;
295
356
  case "bad-concern":
296
- return `$.commits[${issue.commit}]: concern: ${issue.reason}`;
357
+ return `${commitName(issue.commit)}: concern: ${issue.reason}`;
297
358
  case "too-many-concerns":
298
- return `${issue.unit} has more than one concern, in commits ${issue.commits.join(" and ")}; keep the one that matters`;
359
+ return `${commitName(issue.commit)}: ${issue.unit} has ${issue.count} concerns; a unit takes one per commit (each slice of a split hunk may carry its own), so keep the one that matters`;
299
360
  case "bad-offer":
300
- return `$.commits[${issue.commit}]: offer: ${issue.reason}`;
361
+ return `${commitName(issue.commit)}: offer: ${issue.reason}`;
301
362
  case "too-many-offers":
302
363
  return issue.commit === null
303
364
  ? `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`;
365
+ : `${commitName(issue.commit)} has ${issue.count} offers; at most ${OFFERS_PER_COMMIT} per commit`;
305
366
  case "final-mismatch": {
306
367
  const tip = issue.expected ? `${issue.expected.mode} ${issue.expected.blob}` : "absent";
307
368
  return `${show(issue.path)}: the last commit has ${issue.got}, the tip has ${tip}`;
@@ -314,10 +375,7 @@ function formatIssue(issue) {
314
375
  }
315
376
  function formatWarning(w) {
316
377
  if (w.kind === "long-title") {
317
- return `warning: $.commits[${w.commit}]: title is ${w.length} characters; keep it to 50`;
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`;
378
+ return `warning: ${commitName(w.commit)}: title is ${w.length} characters; keep it to 50`;
321
379
  }
322
380
  if (w.kind === "question-addressed") {
323
381
  return `warning: question ${w.comment} is marked addressed; explain it with "answered" and "at" unless answering it changed the plan`;
@@ -401,7 +459,7 @@ function syntaxOverride(args) {
401
459
  return { override };
402
460
  }
403
461
  function formatSyntax(report, mark = "") {
404
- const out = report.issues.map((i) => `${mark}$.commits[${i.commit}]: ${show(i.path)}: ${i.message}`);
462
+ const out = report.issues.map((i) => `${mark}${commitName(i.commit)}: ${show(i.path)}: ${i.message}`);
405
463
  for (const i of report.tip) {
406
464
  out.push(`${mark}${show(i.path)}: ${i.message} (the tip itself does not parse here, so earlier commits are not checked for it)`);
407
465
  }
@@ -557,7 +615,7 @@ function projectedCommits(session, version) {
557
615
  const plan = session.plans().find((p) => p.version === version);
558
616
  if (plan === undefined)
559
617
  return [];
560
- const verdict = validatePlan(session.diff, serializePlanFile(plan.file));
618
+ const verdict = validatePlan(session.diff, serializePlanFile(plan.file), { frozen: true });
561
619
  return verdict.ok ? verdict.plan.commits : [];
562
620
  }
563
621
  /** Writes the one-commit-per-file draft with no agent. */
@@ -928,7 +986,8 @@ function reviewedPlan(session) {
928
986
  const reviews = reviewsOf(session);
929
987
  for (const stored of reviews.toReversed()) {
930
988
  const plan = session.plans().find((p) => p.version === stored.version);
931
- if (plan !== undefined && validatePlan(session.diff, serializePlanFile(plan.file)).ok) {
989
+ if (plan !== undefined &&
990
+ validatePlan(session.diff, serializePlanFile(plan.file), { frozen: true }).ok) {
932
991
  return stored;
933
992
  }
934
993
  }
@@ -1074,11 +1133,42 @@ async function offerStep(port, summary) {
1074
1133
  return null;
1075
1134
  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
1135
  }
1136
+ /** The per-test step while a state map waits for it, said as a `next:` of the listening loop. */
1137
+ async function testsStep(session, open, cwd) {
1138
+ if (!(await mapWantsTests(session, open)))
1139
+ return null;
1140
+ return `${STILL_WORKING}; ${perTestStep(session, cwd)}; then run follow-me again`;
1141
+ }
1142
+ /**
1143
+ * Where the agent writes lcov files, relative to cwd: inside the session, which `.follow-me/`'s
1144
+ * own .gitignore hides and which goes when the session goes, so no run leaves a file in the repo.
1145
+ */
1146
+ function lcovDir(session, cwd) {
1147
+ const dir = join(session.dir, "lcov");
1148
+ mkdirSync(dir, { recursive: true });
1149
+ return relative(cwd, dir);
1150
+ }
1151
+ /** The aggregate run to take when a review opens. */
1152
+ function coverageStep(session, cwd) {
1153
+ return `if the project has tests, run them with coverage at the tip, lcov to ${lcovDir(session, cwd)}/all.info, and pass it to follow-me coverage`;
1154
+ }
1155
+ /** The per-test runs a state map needs for its badges and its "Not driven" list. */
1156
+ function perTestStep(session, cwd) {
1157
+ const dir = lcovDir(session, cwd);
1158
+ return `the state machine needs one coverage run per test: follow-me coverage --reset, then for each test run it alone with lcov to ${dir}/<n>.info and follow-me coverage ${dir}/<n>.info --test "<test name>" --at <path:line where it is declared>`;
1159
+ }
1160
+ /** Whether a reply of the hosted review carries a state map while no run has per-test detail. */
1161
+ async function mapWantsTests(session, open) {
1162
+ if ((session.coverage()?.tests.size ?? 0) > 0)
1163
+ return false;
1164
+ const said = await fetchComments(open.port, open.summary);
1165
+ return said.some(({ comments }) => comments.some((c) => (c.replies ?? []).some((r) => r.map !== undefined)));
1166
+ }
1077
1167
  /**
1078
1168
  * Listens to the hosted review for up to --wait seconds and prints what the reviewer did. It
1079
1169
  * returns at once with news, and with the report when the reviewer submitted or ended the review.
1080
1170
  */
1081
- async function listen(session, open, args) {
1171
+ async function listen(session, open, args, cwd) {
1082
1172
  const { port, summary } = open;
1083
1173
  const waitMs = waitOf(args) * 1000;
1084
1174
  const deadline = Date.now() + waitMs;
@@ -1134,11 +1224,13 @@ async function listen(session, open, args) {
1134
1224
  return 0;
1135
1225
  }
1136
1226
  next((await offerStep(port, summary)) ??
1227
+ (await testsStep(session, open, cwd)) ??
1137
1228
  `${STILL_WORKING}; answer with follow-me reply <id> --body "<markdown>" [--diagram file], then run follow-me again`);
1138
1229
  return 0;
1139
1230
  }
1140
1231
  if (left <= 0) {
1141
1232
  next((await offerStep(port, summary)) ??
1233
+ (await testsStep(session, open, cwd)) ??
1142
1234
  `${STILL_WORKING}; run follow-me again to keep listening`);
1143
1235
  return 0;
1144
1236
  }
@@ -1163,7 +1255,7 @@ async function entry(cwd, args) {
1163
1255
  return swapOpenPage(session, hosted, args);
1164
1256
  }
1165
1257
  if (hosted !== null)
1166
- return listen(session, hosted, args);
1258
+ return listen(session, hosted, args, cwd);
1167
1259
  const state = draftState(session);
1168
1260
  if (state.kind === "invalid") {
1169
1261
  next("fix the draft, then run follow-me again");
@@ -1208,11 +1300,10 @@ async function entry(cwd, args) {
1208
1300
  const result = await hostReview(session, mode, detached, async () => prep, reportSaved);
1209
1301
  if (result.kind !== "elsewhere")
1210
1302
  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
- }
1303
+ if (session.coverage() === null)
1304
+ next(coverageStep(session, cwd));
1214
1305
  say("follow-me is interactive: keep running it until it prints the report");
1215
- return listen(session, result, args);
1306
+ return listen(session, result, args, cwd);
1216
1307
  }
1217
1308
  /** Opens the session, re-extracting instead of failing when the branch moved or --base changed. */
1218
1309
  async function openFresh(cwd, args) {
@@ -1235,7 +1326,10 @@ function walkablePlan(session) {
1235
1326
  const lastReview = session.lastReview();
1236
1327
  const stored = session.plans().at(-1);
1237
1328
  if (stored !== undefined) {
1238
- const verdict = validatePlan(session.diff, serializePlanFile(stored.file), { lastReview });
1329
+ const verdict = validatePlan(session.diff, serializePlanFile(stored.file), {
1330
+ lastReview,
1331
+ frozen: true,
1332
+ });
1239
1333
  if (verdict.ok)
1240
1334
  return { version: stored.version, plan: verdict.plan };
1241
1335
  }
@@ -1274,7 +1368,7 @@ async function renderStored(session, stored, format = "markdown") {
1274
1368
  console.error(`follow-me: review v${stored.version} refers to a plan that is missing`);
1275
1369
  return null;
1276
1370
  }
1277
- const verdict = validatePlan(session.diff, serializePlanFile(plan.file));
1371
+ const verdict = validatePlan(session.diff, serializePlanFile(plan.file), { frozen: true });
1278
1372
  if (!verdict.ok) {
1279
1373
  console.error(`follow-me: plan v${plan.version} no longer fits ${session.anchor.branch}`);
1280
1374
  return null;
@@ -1338,7 +1432,7 @@ async function report(cwd, args, walkOnly = false) {
1338
1432
  process.stdout.write(text);
1339
1433
  return 0;
1340
1434
  }
1341
- const COVERAGE_USAGE = "usage: follow-me coverage <lcov> [--test <id> [--at <path:line>]] [--reset]\n follow-me coverage --reset";
1435
+ const COVERAGE_USAGE = "usage: follow-me coverage <lcov> [--test <id> [--at <path:line>]] [--reset]\n follow-me coverage --reset\n--at is the path:line where the test is declared";
1342
1436
  /**
1343
1437
  * Records the lcov of a test run the agent made at the tip in the session's coverage.json and
1344
1438
  * prints how much of the branch's changed code it ran. follow-me never runs the tests itself.
@@ -1402,7 +1496,19 @@ async function coverage(cwd, args) {
1402
1496
  if (open !== null)
1403
1497
  await postCoverage(open.port, open.summary);
1404
1498
  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`);
1499
+ const total = `coverage for ${plural(recorded.files.size, "file")} at ${short(tip)}; ${covered} of ${executable} changed lines covered`;
1500
+ if (test === undefined)
1501
+ say(total);
1502
+ else {
1503
+ // A per-test run adds to what is on record, so its own share is said apart from the total.
1504
+ const own = totalOf(changedLines(session.diff.units, run.files).values()).covered;
1505
+ const branches = changedBranchesTaken(session.diff.units, run.files);
1506
+ say(`recorded test ${JSON.stringify(test.id)}: ${plural(own, "changed line")}, ${branches === 1 ? "1 branch" : `${branches} branches`}`);
1507
+ say(`running total of ${plural(recorded.tests.size, "test")}: ${total}`);
1508
+ }
1509
+ if (test === undefined && open !== null && (await mapWantsTests(session, open))) {
1510
+ next(`${perTestStep(session, cwd)}; then run follow-me again`);
1511
+ }
1406
1512
  return 0;
1407
1513
  }
1408
1514
  /**
@@ -1529,14 +1635,20 @@ async function reply(cwd, args) {
1529
1635
  const open = await openOnServer(session, null);
1530
1636
  if (open === null)
1531
1637
  return notOpen(session);
1638
+ const refs = await bodyRefs(session, open.summary.version, body);
1532
1639
  const id = await postReply(open.port, open.summary, commentId, {
1533
1640
  author: "agent",
1534
1641
  body,
1535
1642
  ...(diagram === undefined ? {} : { diagram }),
1536
1643
  ...(map === undefined ? {} : { map }),
1644
+ ...(refs.length === 0 ? {} : { refs }),
1537
1645
  });
1538
1646
  say(`replied ${id} to ${commentId}`);
1539
- next(`run follow-me again; ${STILL_WORKING}`);
1647
+ if (map !== undefined && (session.coverage()?.tests.size ?? 0) === 0) {
1648
+ next(`${perTestStep(session, cwd)}; then run follow-me again; ${STILL_WORKING}`);
1649
+ }
1650
+ else
1651
+ next(`run follow-me again; ${STILL_WORKING}`);
1540
1652
  return 0;
1541
1653
  }
1542
1654
  const REPLY_USAGE = `usage: follow-me reply <commentId> --body <text> [--diagram <file> [--map <file>]]
@@ -1586,6 +1698,33 @@ async function readMap(session, file, diagram) {
1586
1698
  throw new UsageError(`${file}:\n ${problems.join("\n ")}`);
1587
1699
  return map;
1588
1700
  }
1701
+ /**
1702
+ * The refs --body writes, resolved against plan `version` and the tip. Every one that does not
1703
+ * resolve is named at once, as --map's are.
1704
+ */
1705
+ async function bodyRefs(session, version, body) {
1706
+ const specs = refsIn(body);
1707
+ if (specs.length === 0)
1708
+ return [];
1709
+ const { root, diff } = session;
1710
+ const otherChanged = new Set([...diff.units.values()].flatMap((u) => (diff.textFiles.has(u.path) ? [] : [u.path])));
1711
+ const asked = [...new Set(specs.map((s) => repoPathOfTyped(s.path)))].filter((p) => isRepoPath(p) && !diff.textFiles.has(p) && !otherChanged.has(p));
1712
+ const tip = asked.length === 0 ? new Set() : await pathsAt(root, diff.tip);
1713
+ const present = asked.filter((p) => tip.has(p));
1714
+ const unchanged = new Map([...(await blobsAt(root, diff.tip, present))].map(([p, bytes]) => [p, splitLines(bytes)]));
1715
+ const { refs, problems } = resolveRefs(specs, {
1716
+ tip: diff.tip,
1717
+ textFiles: diff.textFiles,
1718
+ otherChanged,
1719
+ unchanged,
1720
+ commits: projectedCommits(session, version),
1721
+ version,
1722
+ });
1723
+ if (problems.length > 0) {
1724
+ throw new UsageError(`--body points at code that does not resolve:\n ${problems.join("\n ")}`);
1725
+ }
1726
+ return refs;
1727
+ }
1589
1728
  /** The Mermaid source in --diagram's file, or undefined without the flag. */
1590
1729
  function diagramOf(args) {
1591
1730
  const file = args.values.get("diagram");
@@ -1693,17 +1832,19 @@ async function explain(cwd, args) {
1693
1832
  if (open === null)
1694
1833
  return notOpen(session);
1695
1834
  const target = explainTarget(session, open.summary.version, args);
1835
+ const refs = await bodyRefs(session, open.summary.version, body);
1696
1836
  const id = await postComment(open.port, open.summary, {
1697
1837
  ...target,
1698
1838
  body,
1699
1839
  author: "agent",
1700
1840
  ...(diagram === undefined ? {} : { diagram }),
1841
+ ...(refs.length === 0 ? {} : { refs }),
1701
1842
  });
1702
1843
  say(`explained ${id}`);
1703
1844
  next(`run follow-me again; ${STILL_WORKING}`);
1704
1845
  return 0;
1705
1846
  }
1706
- const SHOW_USAGE = "usage: follow-me show <h_…|c_…|k_…|path:line[@<commit>|@base]|name>";
1847
+ const SHOW_USAGE = "usage: follow-me show <h_…|c_…|k_…|path:a[-b][@<commit>|@base]|name>";
1707
1848
  /** Every comment the session has, with the plan version it was made on: live ones first. */
1708
1849
  async function knownComments(session) {
1709
1850
  const open = await openOnServer(session, null);
@@ -1717,14 +1858,16 @@ async function knownComments(session) {
1717
1858
  }
1718
1859
  const SHOW_MARGIN = 3;
1719
1860
  /**
1720
- * The lines around path:line as commit `at` of the latest plan writes the file (1-based), the
1721
- * base, or the tip when `at` is undefined. Read from the plan, so it works before any apply.
1861
+ * The lines around path:a-b as commit `at` of the latest plan writes the file (1-based), the
1862
+ * base, or the tip when `at` is null. Read from the plan, so it works before any apply.
1722
1863
  */
1723
1864
  function showLines(session, raw) {
1724
- const m = /^(.+):(\d+)(?:@(\d+|base))?$/.exec(raw);
1725
- const [, typed, lineRaw, at] = m ?? [];
1726
- if (typed === undefined || lineRaw === undefined)
1865
+ const spec = parseRefText(raw);
1866
+ if (spec === null)
1727
1867
  throw new UsageError(SHOW_USAGE);
1868
+ const { path: typed, start, end, at } = spec;
1869
+ if (end < start)
1870
+ throw new UsageError(`${raw} ends before it starts`);
1728
1871
  const path = Buffer.from(typed, "utf8").toString("latin1");
1729
1872
  const file = [...session.diff.textFiles.values()].find((f) => f.path === path);
1730
1873
  if (file === undefined) {
@@ -1732,7 +1875,7 @@ function showLines(session, raw) {
1732
1875
  }
1733
1876
  let text;
1734
1877
  let label;
1735
- if (at === undefined) {
1878
+ if (at === null) {
1736
1879
  text = file.tip?.bytes ?? null;
1737
1880
  label = "the tip";
1738
1881
  }
@@ -1743,7 +1886,7 @@ function showLines(session, raw) {
1743
1886
  else {
1744
1887
  const latest = session.plans().at(-1);
1745
1888
  const commits = latest === undefined ? [] : projectedCommits(session, latest.version);
1746
- const n = Number(at);
1889
+ const n = at;
1747
1890
  if (n < 1 || n > commits.length) {
1748
1891
  throw new UsageError(`@${at} is not one of the ${commits.length} commits of the plan`);
1749
1892
  }
@@ -1762,18 +1905,18 @@ function showLines(session, raw) {
1762
1905
  const lines = Buffer.from(text, "latin1").toString("utf8").split("\n");
1763
1906
  if (lines.at(-1) === "")
1764
1907
  lines.pop();
1765
- const line = Number(lineRaw);
1766
- if (line < 1 || line > lines.length) {
1908
+ if (start < 1 || end > lines.length) {
1767
1909
  return `${typed} has ${plural(lines.length, "line")} in ${label}\n`;
1768
1910
  }
1769
- const from = Math.max(1, line - SHOW_MARGIN);
1770
- const to = Math.min(lines.length, line + SHOW_MARGIN);
1911
+ const from = Math.max(1, start - SHOW_MARGIN);
1912
+ const to = Math.min(lines.length, end + SHOW_MARGIN);
1771
1913
  const width = String(to).length;
1772
1914
  const rows = lines.slice(from - 1, to).map((l, i) => {
1773
1915
  const n = from + i;
1774
- return `${n === line ? ">" : " "} ${String(n).padStart(width)} ${l}`;
1916
+ return `${n >= start && n <= end ? ">" : " "} ${String(n).padStart(width)} ${l}`;
1775
1917
  });
1776
- return `${typed}:${line} in ${label}\n${rows.join("\n")}\n`;
1918
+ const span = end > start ? `${start}-${end}` : `${start}`;
1919
+ return `${typed}:${span} in ${label}\n${rows.join("\n")}\n`;
1777
1920
  }
1778
1921
  /** Expands a ref the agent was given to the lines and thread it names, so no file read is needed. */
1779
1922
  async function showRef(cwd, args) {
@@ -1789,7 +1932,7 @@ async function showRef(cwd, args) {
1789
1932
  throw new UsageError(`${ref} is not a unit of the branch now; its hunk changed`);
1790
1933
  }
1791
1934
  const commits = latest === undefined ? [] : projectedCommits(session, latest.version);
1792
- process.stdout.write(renderShownUnit(unit, commits));
1935
+ process.stdout.write(renderShownUnit(unit, units, commits));
1793
1936
  return 0;
1794
1937
  }
1795
1938
  if (/^c_[a-z2-7]{6}$/.test(ref) && isCommentId(ref)) {
@@ -1880,6 +2023,9 @@ async function serverCommand(argv) {
1880
2023
  await started.host.closed;
1881
2024
  return 0;
1882
2025
  }
2026
+ case "--help":
2027
+ case "-h":
2028
+ throw new HelpRequested();
1883
2029
  default:
1884
2030
  throw new UsageError("usage: follow-me server status|stop");
1885
2031
  }
@@ -2101,6 +2247,11 @@ export async function main(argv, cwd) {
2101
2247
  }
2102
2248
  }
2103
2249
  catch (error) {
2250
+ if (error instanceof HelpRequested) {
2251
+ const asked = command === undefined || command === "help" ? null : usageOf(command);
2252
+ say(asked ?? SHORT_USAGE);
2253
+ return 0;
2254
+ }
2104
2255
  const known = [
2105
2256
  UsageError,
2106
2257
  CoverageError,