@shiyuphan/follow-me 0.9.0 → 0.9.2

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 (96) hide show
  1. package/dist/src/cli.js +161 -35
  2. package/dist/src/comments.js +25 -4
  3. package/dist/src/coverage.js +26 -15
  4. package/dist/src/host.js +15 -5
  5. package/dist/src/install.js +11 -1
  6. package/dist/src/plain.js +20 -0
  7. package/dist/src/plan.js +44 -22
  8. package/dist/src/prompt.js +58 -17
  9. package/dist/src/session.js +8 -3
  10. package/dist/ui/dist/assets/{abnfDiagram-WEYOIA3C-DztIEwNE.js → abnfDiagram-WEYOIA3C-BWgk01ve.js} +1 -1
  11. package/dist/ui/dist/assets/{arc-DUYh-EqZ.js → arc-Cg_Qb5c4.js} +1 -1
  12. package/dist/ui/dist/assets/{architecture-WOLXFQ4H-BQYTB92_.js → architecture-WOLXFQ4H-EdfV1etv.js} +1 -1
  13. package/dist/ui/dist/assets/{architectureDiagram-ZHVYKT53-Dl7095A5.js → architectureDiagram-ZHVYKT53-Dz-gytQU.js} +1 -1
  14. package/dist/ui/dist/assets/{blockDiagram-RJAE7QA3-QDZ5kE1J.js → blockDiagram-RJAE7QA3-B7Q5KhYC.js} +1 -1
  15. package/dist/ui/dist/assets/{c4Diagram-XDPPPE54-CKJZ1xOw.js → c4Diagram-XDPPPE54-Cq9IvGR1.js} +1 -1
  16. package/dist/ui/dist/assets/channel-Da0YvuQF.js +1 -0
  17. package/dist/ui/dist/assets/{chunk-2E7WHVMD-BXBjaqd1.js → chunk-2E7WHVMD-BTJB-4C8.js} +1 -1
  18. package/dist/ui/dist/assets/{chunk-4EMNKR2T-DPzfVuke.js → chunk-4EMNKR2T-BILthc2j.js} +1 -1
  19. package/dist/ui/dist/assets/{chunk-55XCU76K-B33Sj3w8.js → chunk-55XCU76K-DH3fYrJH.js} +1 -1
  20. package/dist/ui/dist/assets/{chunk-7S6XSTFO-B_u7dS03.js → chunk-7S6XSTFO-DDIiBvc2.js} +1 -1
  21. package/dist/ui/dist/assets/{chunk-AQDTSKZB-B4EzH5gx.js → chunk-AQDTSKZB-b1EeJ3gb.js} +1 -1
  22. package/dist/ui/dist/assets/{chunk-DSNLSHJZ-CfWeHABr.js → chunk-DSNLSHJZ-DZZ635s5.js} +1 -1
  23. package/dist/ui/dist/assets/{chunk-HAGFOTED-B8G8PjLZ.js → chunk-HAGFOTED-DhhrHTIh.js} +1 -1
  24. package/dist/ui/dist/assets/{chunk-HNE4Q36P-BA-PqoTF.js → chunk-HNE4Q36P-B8wvhc2e.js} +1 -1
  25. package/dist/ui/dist/assets/{chunk-NQOKRVIF-JvhuFcnR.js → chunk-NQOKRVIF-CLtGMwDx.js} +1 -1
  26. package/dist/ui/dist/assets/{chunk-UHW7PFBP-BrfL5CGY.js → chunk-UHW7PFBP-B9fVLGl0.js} +1 -1
  27. package/dist/ui/dist/assets/{chunk-VQ5W62QI-dBJqgdDy.js → chunk-VQ5W62QI-CgPvvMAv.js} +1 -1
  28. package/dist/ui/dist/assets/{chunk-WQQL4CDK-dfqd3k_P.js → chunk-WQQL4CDK-DdqKu6R9.js} +1 -1
  29. package/dist/ui/dist/assets/{chunk-XXDRQBXY-kViWdB9M.js → chunk-XXDRQBXY-GlM07vbp.js} +1 -1
  30. package/dist/ui/dist/assets/{chunk-YZO67HFG-ukfs4wT3.js → chunk-YZO67HFG-YqT6M0W9.js} +1 -1
  31. package/dist/ui/dist/assets/{chunk-ZAUX2LTX-BxZHki81.js → chunk-ZAUX2LTX-DqHK51Gc.js} +1 -1
  32. package/dist/ui/dist/assets/{chunk-ZCHMQSOR-PjluhR_E.js → chunk-ZCHMQSOR-DEeXkjWz.js} +1 -1
  33. package/dist/ui/dist/assets/{classDiagram-v2-4MYWR3UA-Ct89LJec.js → classDiagram-v2-4MYWR3UA-BcKsX4up.js} +1 -1
  34. package/dist/ui/dist/assets/{cose-bilkent-JH36ORCC-BnQceGmB.js → cose-bilkent-JH36ORCC-fu9djgYc.js} +1 -1
  35. package/dist/ui/dist/assets/{cynefin-EF2NZ3EQ-D6WkCVE8.js → cynefin-EF2NZ3EQ-CFJASBsJ.js} +1 -1
  36. package/dist/ui/dist/assets/{cynefinDiagram-WF24UDRP-dn6DUPAb.js → cynefinDiagram-WF24UDRP-BVJD_n0g.js} +1 -1
  37. package/dist/ui/dist/assets/{dagre-NK4GIZVX-BtZSlLk7.js → dagre-NK4GIZVX-Cx8pjz9O.js} +1 -1
  38. package/dist/ui/dist/assets/{diagram-CBK3MWTC-Ds4_tv6X.js → diagram-CBK3MWTC-CVIkEZ1X.js} +1 -1
  39. package/dist/ui/dist/assets/{diagram-I5XIIV2D-DcoVkg45.js → diagram-I5XIIV2D-2rxpF8BZ.js} +1 -1
  40. package/dist/ui/dist/assets/{diagram-ODIL2UCW-T47FAA3k.js → diagram-ODIL2UCW-NJfGHnLk.js} +1 -1
  41. package/dist/ui/dist/assets/{diagram-PFBEJUXE-CRZcaVKP.js → diagram-PFBEJUXE-DZHVJSeM.js} +1 -1
  42. package/dist/ui/dist/assets/{diagram-RJA3I4VV-D6KXeUPg.js → diagram-RJA3I4VV-CQAfOObI.js} +1 -1
  43. package/dist/ui/dist/assets/{diagram-TYUNFSNR-BHEIVyNN.js → diagram-TYUNFSNR-DGGaMOl6.js} +1 -1
  44. package/dist/ui/dist/assets/{dist-DLlKIWBp.js → dist-CW-4NrUq.js} +1 -1
  45. package/dist/ui/dist/assets/{ebnfDiagram-V4P3TTPT-nUDvYRBV.js → ebnfDiagram-V4P3TTPT-92pb2Fcg.js} +1 -1
  46. package/dist/ui/dist/assets/{elk-QONTBCTQ-D21BcRzc.js → elk-QONTBCTQ-vu0Z4rYG.js} +1 -1
  47. package/dist/ui/dist/assets/{erDiagram-KYQIGQAA-CTpHuftU.js → erDiagram-KYQIGQAA-BXnsRzCQ.js} +1 -1
  48. package/dist/ui/dist/assets/{eventmodeling-K75KTNOO-B6JaKuDY.js → eventmodeling-K75KTNOO-bSr88MT1.js} +1 -1
  49. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-CUH45H9n.js +1 -0
  50. package/dist/ui/dist/assets/{ganttDiagram-R7GPNTJY-D2-pMcnC.js → ganttDiagram-R7GPNTJY-BQzVkGbj.js} +1 -1
  51. package/dist/ui/dist/assets/{gitGraph-VSP46ZUC-B2yxwhEo.js → gitGraph-VSP46ZUC-BegblPQi.js} +1 -1
  52. package/dist/ui/dist/assets/{gitGraphDiagram-PDYL7XHY-DnrhBMH1.js → gitGraphDiagram-PDYL7XHY-BaGlGpEX.js} +1 -1
  53. package/dist/ui/dist/assets/{index-Dnte9tPd.js → index-D8ErIacA.js} +4 -4
  54. package/dist/ui/dist/assets/{info-OHQRW6UA-D8oh6gvs.js → info-OHQRW6UA-D3HVGJDc.js} +1 -1
  55. package/dist/ui/dist/assets/{infoDiagram-MVDRKAX5-BLSZjjgB.js → infoDiagram-MVDRKAX5-Ckcp-D0C.js} +1 -1
  56. package/dist/ui/dist/assets/{ishikawaDiagram-HPEHV2GE-BXiznLSn.js → ishikawaDiagram-HPEHV2GE-CWhd0CTS.js} +1 -1
  57. package/dist/ui/dist/assets/{journeyDiagram-BOSHW7O7-BSYmVcO_.js → journeyDiagram-BOSHW7O7-CrH5cFXY.js} +1 -1
  58. package/dist/ui/dist/assets/{kanban-definition-FNSA4HC5-COthkkzK.js → kanban-definition-FNSA4HC5-CpORMTDZ.js} +1 -1
  59. package/dist/ui/dist/assets/{line-6ND8OaOL.js → line-B3El9R0Z.js} +1 -1
  60. package/dist/ui/dist/assets/{linear-DbWEPFJX.js → linear-DLfthkbB.js} +1 -1
  61. package/dist/ui/dist/assets/{mermaid-parser.core-Btsk294z.js → mermaid-parser.core-QtQjLaoK.js} +3 -3
  62. package/dist/ui/dist/assets/{mermaid.core-B4llDSFO.js → mermaid.core-C2DGgMSc.js} +4 -4
  63. package/dist/ui/dist/assets/{mindmap-definition-6OY57UG3-hv2PfX-x.js → mindmap-definition-6OY57UG3-CW1lSXnX.js} +1 -1
  64. package/dist/ui/dist/assets/{packet-JDAUHWVQ-Dr4vBgjt.js → packet-JDAUHWVQ-Dhu_m_17.js} +1 -1
  65. package/dist/ui/dist/assets/{pegDiagram-ECIUJNBX-DvlFov3N.js → pegDiagram-ECIUJNBX-BGOhbzN4.js} +1 -1
  66. package/dist/ui/dist/assets/{pie-XZMESJXO-C9QsqC4n.js → pie-XZMESJXO-Citt3cMX.js} +1 -1
  67. package/dist/ui/dist/assets/{pieDiagram-GNMPY74T-CrrHOj67.js → pieDiagram-GNMPY74T-DPVZikvz.js} +1 -1
  68. package/dist/ui/dist/assets/{prism-core-Cc8H7LPG.js → prism-core-YgwsYh9d.js} +1 -1
  69. package/dist/ui/dist/assets/{quadrantDiagram-D57K4MC3-DQ7J6-ti.js → quadrantDiagram-D57K4MC3-Bwye1B9e.js} +1 -1
  70. package/dist/ui/dist/assets/{radar-ABXABTNO-BBBPaujh.js → radar-ABXABTNO-DRUb4G6V.js} +1 -1
  71. package/dist/ui/dist/assets/{railroad-I3PHUGI6-C-P6Igaz.js → railroad-I3PHUGI6-x3Ug2mxX.js} +1 -1
  72. package/dist/ui/dist/assets/{railroad-abnf-LNEFI6M7-CAkorRLs.js → railroad-abnf-LNEFI6M7-Dw14XhkZ.js} +1 -1
  73. package/dist/ui/dist/assets/{railroad-ebnf-DWYD2UWJ-D8Hz6gCf.js → railroad-ebnf-DWYD2UWJ-AwoWTWgL.js} +1 -1
  74. package/dist/ui/dist/assets/{railroad-peg-JR7BVNR7-D-XNMkOs.js → railroad-peg-JR7BVNR7-U7MXfciE.js} +1 -1
  75. package/dist/ui/dist/assets/{railroadDiagram-Y72NRBL7-DE9LwxJQ.js → railroadDiagram-Y72NRBL7-BxWlCZYs.js} +1 -1
  76. package/dist/ui/dist/assets/{requirementDiagram-EQYSW5RA-FrKFY6NJ.js → requirementDiagram-EQYSW5RA-TPmnZguV.js} +1 -1
  77. package/dist/ui/dist/assets/{sankeyDiagram-SY24JHO5-BDxI6RDX.js → sankeyDiagram-SY24JHO5-nPGftpl5.js} +1 -1
  78. package/dist/ui/dist/assets/{sequenceDiagram-BLEHNHQU-pV1XcKX7.js → sequenceDiagram-BLEHNHQU-BdctgaWa.js} +1 -1
  79. package/dist/ui/dist/assets/{src-snNJTXXq.js → src-zq235FjH.js} +1 -1
  80. package/dist/ui/dist/assets/{stateDiagram-v2-A6IESKWZ-BSc_5aAf.js → stateDiagram-v2-A6IESKWZ-BK_E6kPg.js} +1 -1
  81. package/dist/ui/dist/assets/{swimlanes-B3ENRKFQ-DC6qUXLh.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-C8L0Dxtu.js → timeline-definition-JXYKGLGB-Bg8AXJ2P.js} +1 -1
  84. package/dist/ui/dist/assets/{treeView-D4JQ5SDB-DDyZIG5T.js → treeView-D4JQ5SDB-B4hz6M4l.js} +1 -1
  85. package/dist/ui/dist/assets/{treemap-SAJKECNS-CUaLgge0.js → treemap-SAJKECNS-e_Vc97Ox.js} +1 -1
  86. package/dist/ui/dist/assets/{usecaseDiagram-WPOLBNNX-BHRBd5pV.js → usecaseDiagram-WPOLBNNX-Butu_LCX.js} +1 -1
  87. package/dist/ui/dist/assets/{vennDiagram-Q3Y3IDCW-Du4iwwkh.js → vennDiagram-Q3Y3IDCW-BGIyxlbf.js} +1 -1
  88. package/dist/ui/dist/assets/{wardley-7MLQ67FV-DUgENI0s.js → wardley-7MLQ67FV-Btv95mqq.js} +1 -1
  89. package/dist/ui/dist/assets/{wardleyDiagram-IVAIDKWA-B86zOdke.js → wardleyDiagram-IVAIDKWA-DwTKExJ4.js} +1 -1
  90. package/dist/ui/dist/assets/{xychartDiagram-4URNHDU4-twq9irqO.js → xychartDiagram-4URNHDU4-UmlA5uxc.js} +1 -1
  91. package/dist/ui/dist/index.html +1 -1
  92. package/package.json +1 -1
  93. package/skills/follow-me/SKILL.md +7 -7
  94. package/dist/ui/dist/assets/channel-DF08Hd8f.js +0 -1
  95. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-BTeOUR-p.js +0 -1
  96. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-F1HykV3o.js +0 -8
package/dist/src/cli.js CHANGED
@@ -1,16 +1,17 @@
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";
11
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";
12
12
  import { findSkillDir, InstallError, runInstall, runUninstall, syncSkills, } from "./install.js";
13
13
  import { naiveDraft } from "./naive.js";
14
+ import { plainNumber, plainText } from "./plain.js";
14
15
  import { isCommentId, isConcernId, isFields, OFFERS_PER_COMMIT, OFFERS_PER_PLAN, serializePlanFile, validatePlan, } from "./plan.js";
15
16
  import { renderEvents, renderPrompt, renderReport, renderShownComment, renderShownConcern, renderShownDefinitions, renderShownUnit, reportItems, } from "./prompt.js";
16
17
  import { parseRefText, refsIn, repoPathOfTyped, resolveRefs } from "./refs.js";
@@ -20,6 +21,10 @@ import { checkSyntax } from "./syntax.js";
20
21
  class UsageError extends Error {
21
22
  name = "UsageError";
22
23
  }
24
+ /** `--help` or `-h` anywhere a command reads its options; main prints that command's usage. */
25
+ class HelpRequested extends Error {
26
+ name = "HelpRequested";
27
+ }
23
28
  /** A test run follow-me will not record, with the reason the agent can act on. */
24
29
  class CoverageError extends Error {
25
30
  name = "CoverageError";
@@ -47,7 +52,8 @@ Other commands:
47
52
  the items to post to a merge request
48
53
  coverage <lcov> [--test <id> [--at <path:line>]] [--reset]
49
54
  record a test run taken at the tip for the page;
50
- --reset alone clears it before per-test runs
55
+ --at is where the test is declared; --reset alone
56
+ clears it before per-test runs
51
57
 
52
58
  help --all lists every command.`;
53
59
  const USAGE = `usage: follow-me <command> [options]
@@ -117,7 +123,8 @@ Interactive loop (while the review page is open):
117
123
  record the lcov of a test run at the branch tip;
118
124
  refuses when a covered file in the working tree
119
125
  differs from the tip. A plain run replaces the last;
120
- --test runs add one test each, --at is where it is.
126
+ --test runs add one test each, --at is the line
127
+ where that test is declared.
121
128
  --reset starts fresh; alone it clears the last run
122
129
  apply, while the page is open, swaps it to the new plan version in place, same URL.
123
130
 
@@ -157,6 +164,8 @@ function parseArgs(argv, spec) {
157
164
  const flags = new Set();
158
165
  for (let i = 0; i < argv.length; i++) {
159
166
  const arg = argv[i] ?? "";
167
+ if (arg === "--help" || arg === "-h")
168
+ throw new HelpRequested();
160
169
  if (!arg.startsWith("--")) {
161
170
  positionals.push(arg);
162
171
  continue;
@@ -176,6 +185,43 @@ function parseArgs(argv, spec) {
176
185
  }
177
186
  return { positionals, values, flags };
178
187
  }
188
+ /**
189
+ * The usage of one command, read from its entries in USAGE: the synopsis, then what it does.
190
+ * Null for a command USAGE does not list.
191
+ */
192
+ function usageOf(command) {
193
+ const blocks = [];
194
+ let block = null;
195
+ for (const line of USAGE.split("\n")) {
196
+ if (line.startsWith(` ${command} `) || line === ` ${command}`) {
197
+ block = [line];
198
+ blocks.push(block);
199
+ }
200
+ else if (block !== null && line.startsWith(" "))
201
+ block.push(line);
202
+ else
203
+ block = null;
204
+ }
205
+ if (blocks.length === 0)
206
+ return null;
207
+ return blocks
208
+ .map((lines) => {
209
+ const synopsis = [];
210
+ const what = [];
211
+ for (const line of lines) {
212
+ const [left = "", right = ""] = line.trim().split(/\s{3,}/, 2);
213
+ if (/^\s{20,}/.test(line))
214
+ what.push(left);
215
+ else {
216
+ synopsis.push(left);
217
+ if (right !== "")
218
+ what.push(right);
219
+ }
220
+ }
221
+ return [`usage: follow-me ${synopsis.join(" ")}`, ...what.map((w) => ` ${w}`)].join("\n");
222
+ })
223
+ .join("\n");
224
+ }
179
225
  function say(line = "") {
180
226
  process.stdout.write(`${line}\n`);
181
227
  }
@@ -265,45 +311,59 @@ function summarize(session, cwd) {
265
311
  }
266
312
  say(`session ${relative(cwd, session.dir) || "."}`);
267
313
  }
314
+ /** Commits are numbered from 1 in everything the agent reads; issues carry 0-based indices. */
315
+ function commitName(k) {
316
+ return `commit ${k + 1}`;
317
+ }
318
+ /** A JSON path into the draft with its commit named from 1: `$.commits[2].units[0]` reads `commit 3, units[0]`. */
319
+ function jsonPlace(at) {
320
+ const m = /^\$\.commits\[(\d+)\]\.?(.*)$/.exec(at);
321
+ if (m === null)
322
+ return at;
323
+ const [, k = "0", rest = ""] = m;
324
+ return rest === "" ? commitName(Number(k)) : `${commitName(Number(k))}, ${rest}`;
325
+ }
268
326
  function formatIssue(issue) {
269
327
  switch (issue.kind) {
270
328
  case "malformed":
271
- return `malformed ${issue.at}: ${issue.reason}`;
329
+ return `malformed ${jsonPlace(issue.at)}: ${issue.reason}`;
272
330
  case "stale":
273
331
  return `stale: the plan is for tip ${short(issue.planTip)} but the session tip is ${short(issue.sessionTip)}`;
274
332
  case "empty-message":
275
- return `$.commits[${issue.commit}]: empty message`;
333
+ return `${commitName(issue.commit)}: empty message`;
276
334
  case "empty-commit":
277
- return `$.commits[${issue.commit}]: no units`;
335
+ return `${commitName(issue.commit)}: no units`;
278
336
  case "unknown-unit":
279
- return `$.commits[${issue.commit}]: unknown unit ${issue.ref}`;
337
+ return `${commitName(issue.commit)}: unknown unit ${issue.ref}`;
280
338
  case "bad-selection":
281
- return `$.commits[${issue.commit}]: ${issue.unit}: ${issue.reason}`;
339
+ return `${commitName(issue.commit)}: ${issue.unit}: ${issue.reason}`;
282
340
  case "duplicate": {
283
341
  const [a, b] = issue.commits;
284
342
  const what = issue.changes.length > 0 ? lines(issue.unit, issue.changes) : issue.unit;
285
- return `${what} is claimed by commits ${a} and ${b}`;
343
+ return `${what} is claimed by commits ${a + 1} and ${b + 1}`;
286
344
  }
287
345
  case "unassigned": {
288
346
  const what = issue.changes.length > 0 ? lines(issue.unit, issue.changes) : issue.unit;
289
347
  return `${what} (${show(issue.path)}) is in no commit`;
290
348
  }
291
349
  case "path-conflict":
292
- return `$.commits[${issue.commit}]: ${show(issue.file)} is a file while ${show(issue.under)} exists`;
350
+ return `${commitName(issue.commit)}: ${show(issue.file)} is a file while ${show(issue.under)} exists`;
293
351
  case "unknown-response":
294
352
  return `response to unknown comment ${issue.comment}`;
295
353
  case "bad-answer":
296
354
  return `response to ${issue.comment}: ${issue.reason}`;
355
+ case "section-order":
356
+ 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`;
297
357
  case "bad-concern":
298
- return `$.commits[${issue.commit}]: concern: ${issue.reason}`;
358
+ return `${commitName(issue.commit)}: concern: ${issue.reason}`;
299
359
  case "too-many-concerns":
300
- return `${issue.unit} has more than one concern, in commits ${issue.commits.join(" and ")}; keep the one that matters`;
360
+ 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`;
301
361
  case "bad-offer":
302
- return `$.commits[${issue.commit}]: offer: ${issue.reason}`;
362
+ return `${commitName(issue.commit)}: offer: ${issue.reason}`;
303
363
  case "too-many-offers":
304
364
  return issue.commit === null
305
365
  ? `the plan has ${issue.count} offers; at most ${OFFERS_PER_PLAN}, and most plans need none`
306
- : `$.commits[${issue.commit}] has ${issue.count} offers; at most ${OFFERS_PER_COMMIT} per commit`;
366
+ : `${commitName(issue.commit)} has ${issue.count} offers; at most ${OFFERS_PER_COMMIT} per commit`;
307
367
  case "final-mismatch": {
308
368
  const tip = issue.expected ? `${issue.expected.mode} ${issue.expected.blob}` : "absent";
309
369
  return `${show(issue.path)}: the last commit has ${issue.got}, the tip has ${tip}`;
@@ -316,10 +376,7 @@ function formatIssue(issue) {
316
376
  }
317
377
  function formatWarning(w) {
318
378
  if (w.kind === "long-title") {
319
- return `warning: $.commits[${w.commit}]: title is ${w.length} characters; keep it to 50`;
320
- }
321
- if (w.kind === "section-order") {
322
- return `warning: $.commits[${w.commit}]: section ${w.section} comes after ${w.after}; sections read story, steps, details, wiring`;
379
+ return `warning: ${commitName(w.commit)}: title is ${w.length} characters; keep it to 50`;
323
380
  }
324
381
  if (w.kind === "question-addressed") {
325
382
  return `warning: question ${w.comment} is marked addressed; explain it with "answered" and "at" unless answering it changed the plan`;
@@ -403,7 +460,7 @@ function syntaxOverride(args) {
403
460
  return { override };
404
461
  }
405
462
  function formatSyntax(report, mark = "") {
406
- const out = report.issues.map((i) => `${mark}$.commits[${i.commit}]: ${show(i.path)}: ${i.message}`);
463
+ const out = report.issues.map((i) => `${mark}${commitName(i.commit)}: ${show(i.path)}: ${i.message}`);
407
464
  for (const i of report.tip) {
408
465
  out.push(`${mark}${show(i.path)}: ${i.message} (the tip itself does not parse here, so earlier commits are not checked for it)`);
409
466
  }
@@ -559,7 +616,7 @@ function projectedCommits(session, version) {
559
616
  const plan = session.plans().find((p) => p.version === version);
560
617
  if (plan === undefined)
561
618
  return [];
562
- const verdict = validatePlan(session.diff, serializePlanFile(plan.file));
619
+ const verdict = validatePlan(session.diff, serializePlanFile(plan.file), { frozen: true });
563
620
  return verdict.ok ? verdict.plan.commits : [];
564
621
  }
565
622
  /** Writes the one-commit-per-file draft with no agent. */
@@ -930,7 +987,8 @@ function reviewedPlan(session) {
930
987
  const reviews = reviewsOf(session);
931
988
  for (const stored of reviews.toReversed()) {
932
989
  const plan = session.plans().find((p) => p.version === stored.version);
933
- if (plan !== undefined && validatePlan(session.diff, serializePlanFile(plan.file)).ok) {
990
+ if (plan !== undefined &&
991
+ validatePlan(session.diff, serializePlanFile(plan.file), { frozen: true }).ok) {
934
992
  return stored;
935
993
  }
936
994
  }
@@ -1046,9 +1104,18 @@ function readCursor(session, summary) {
1046
1104
  const counts = log === logOf(summary) && typeof seq === "number" && Number.isInteger(seq) && seq >= 0;
1047
1105
  return { session: logOf(summary), seq: counts ? seq : 0, seen };
1048
1106
  }
1107
+ const ID_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789_";
1108
+ const LOG_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-@.:+";
1109
+ const CURSOR_ID = /^[a-z]_[a-z0-9]+$/;
1110
+ const CURSOR_LOG = /^[\w-]+@[\w.:+-]+$/;
1111
+ /** The cursor holds data the server sent, so only ids and a log name of the expected shape are written. */
1049
1112
  function writeCursor(session, cursor) {
1050
1113
  const { session: log, seq, seen } = cursor;
1051
- writeFileSync(cursorPath(session), `${JSON.stringify({ session: log, seq, seen: [...seen] })}\n`);
1114
+ if (!CURSOR_LOG.test(log) || !Number.isInteger(seq))
1115
+ throw new Error(`refusing to save the cursor for ${log}`);
1116
+ const ids = [...seen].filter((id) => CURSOR_ID.test(id)).map((id) => plainText(id, ID_CHARS));
1117
+ const saved = { session: plainText(log, LOG_CHARS), seq: plainNumber(seq), seen: ids };
1118
+ writeFileSync(cursorPath(session), `${JSON.stringify(saved)}\n`);
1052
1119
  }
1053
1120
  /** The comment or reply id an event first carries, or null for events without one. */
1054
1121
  function saidId(event) {
@@ -1076,11 +1143,42 @@ async function offerStep(port, summary) {
1076
1143
  return null;
1077
1144
  return `${STILL_WORKING}; offer ${first.offer} waits for your diagram: follow-me reply ${first.comment} --diagram <file> [--map <file>], then run follow-me again`;
1078
1145
  }
1146
+ /** The per-test step while a state map waits for it, said as a `next:` of the listening loop. */
1147
+ async function testsStep(session, open, cwd) {
1148
+ if (!(await mapWantsTests(session, open)))
1149
+ return null;
1150
+ return `${STILL_WORKING}; ${perTestStep(session, cwd)}; then run follow-me again`;
1151
+ }
1152
+ /**
1153
+ * Where the agent writes lcov files, relative to cwd: inside the session, which `.follow-me/`'s
1154
+ * own .gitignore hides and which goes when the session goes, so no run leaves a file in the repo.
1155
+ */
1156
+ function lcovDir(session, cwd) {
1157
+ const dir = join(session.dir, "lcov");
1158
+ mkdirSync(dir, { recursive: true });
1159
+ return relative(cwd, dir);
1160
+ }
1161
+ /** The aggregate run to take when a review opens. */
1162
+ function coverageStep(session, cwd) {
1163
+ 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`;
1164
+ }
1165
+ /** The per-test runs a state map needs for its badges and its "Not driven" list. */
1166
+ function perTestStep(session, cwd) {
1167
+ const dir = lcovDir(session, cwd);
1168
+ 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>`;
1169
+ }
1170
+ /** Whether a reply of the hosted review carries a state map while no run has per-test detail. */
1171
+ async function mapWantsTests(session, open) {
1172
+ if ((session.coverage()?.tests.size ?? 0) > 0)
1173
+ return false;
1174
+ const said = await fetchComments(open.port, open.summary);
1175
+ return said.some(({ comments }) => comments.some((c) => (c.replies ?? []).some((r) => r.map !== undefined)));
1176
+ }
1079
1177
  /**
1080
1178
  * Listens to the hosted review for up to --wait seconds and prints what the reviewer did. It
1081
1179
  * returns at once with news, and with the report when the reviewer submitted or ended the review.
1082
1180
  */
1083
- async function listen(session, open, args) {
1181
+ async function listen(session, open, args, cwd) {
1084
1182
  const { port, summary } = open;
1085
1183
  const waitMs = waitOf(args) * 1000;
1086
1184
  const deadline = Date.now() + waitMs;
@@ -1136,11 +1234,13 @@ async function listen(session, open, args) {
1136
1234
  return 0;
1137
1235
  }
1138
1236
  next((await offerStep(port, summary)) ??
1237
+ (await testsStep(session, open, cwd)) ??
1139
1238
  `${STILL_WORKING}; answer with follow-me reply <id> --body "<markdown>" [--diagram file], then run follow-me again`);
1140
1239
  return 0;
1141
1240
  }
1142
1241
  if (left <= 0) {
1143
1242
  next((await offerStep(port, summary)) ??
1243
+ (await testsStep(session, open, cwd)) ??
1144
1244
  `${STILL_WORKING}; run follow-me again to keep listening`);
1145
1245
  return 0;
1146
1246
  }
@@ -1165,7 +1265,7 @@ async function entry(cwd, args) {
1165
1265
  return swapOpenPage(session, hosted, args);
1166
1266
  }
1167
1267
  if (hosted !== null)
1168
- return listen(session, hosted, args);
1268
+ return listen(session, hosted, args, cwd);
1169
1269
  const state = draftState(session);
1170
1270
  if (state.kind === "invalid") {
1171
1271
  next("fix the draft, then run follow-me again");
@@ -1210,11 +1310,10 @@ async function entry(cwd, args) {
1210
1310
  const result = await hostReview(session, mode, detached, async () => prep, reportSaved);
1211
1311
  if (result.kind !== "elsewhere")
1212
1312
  return exitCode(result);
1213
- if (session.coverage() === null) {
1214
- next("if the project has tests, run them with coverage at the tip and pass the lcov to follow-me coverage");
1215
- }
1313
+ if (session.coverage() === null)
1314
+ next(coverageStep(session, cwd));
1216
1315
  say("follow-me is interactive: keep running it until it prints the report");
1217
- return listen(session, result, args);
1316
+ return listen(session, result, args, cwd);
1218
1317
  }
1219
1318
  /** Opens the session, re-extracting instead of failing when the branch moved or --base changed. */
1220
1319
  async function openFresh(cwd, args) {
@@ -1237,7 +1336,10 @@ function walkablePlan(session) {
1237
1336
  const lastReview = session.lastReview();
1238
1337
  const stored = session.plans().at(-1);
1239
1338
  if (stored !== undefined) {
1240
- const verdict = validatePlan(session.diff, serializePlanFile(stored.file), { lastReview });
1339
+ const verdict = validatePlan(session.diff, serializePlanFile(stored.file), {
1340
+ lastReview,
1341
+ frozen: true,
1342
+ });
1241
1343
  if (verdict.ok)
1242
1344
  return { version: stored.version, plan: verdict.plan };
1243
1345
  }
@@ -1276,7 +1378,7 @@ async function renderStored(session, stored, format = "markdown") {
1276
1378
  console.error(`follow-me: review v${stored.version} refers to a plan that is missing`);
1277
1379
  return null;
1278
1380
  }
1279
- const verdict = validatePlan(session.diff, serializePlanFile(plan.file));
1381
+ const verdict = validatePlan(session.diff, serializePlanFile(plan.file), { frozen: true });
1280
1382
  if (!verdict.ok) {
1281
1383
  console.error(`follow-me: plan v${plan.version} no longer fits ${session.anchor.branch}`);
1282
1384
  return null;
@@ -1340,7 +1442,7 @@ async function report(cwd, args, walkOnly = false) {
1340
1442
  process.stdout.write(text);
1341
1443
  return 0;
1342
1444
  }
1343
- const COVERAGE_USAGE = "usage: follow-me coverage <lcov> [--test <id> [--at <path:line>]] [--reset]\n follow-me coverage --reset";
1445
+ 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";
1344
1446
  /**
1345
1447
  * Records the lcov of a test run the agent made at the tip in the session's coverage.json and
1346
1448
  * prints how much of the branch's changed code it ran. follow-me never runs the tests itself.
@@ -1404,7 +1506,19 @@ async function coverage(cwd, args) {
1404
1506
  if (open !== null)
1405
1507
  await postCoverage(open.port, open.summary);
1406
1508
  const { covered, executable } = totalOf(changedLines(session.diff.units, recorded.files).values());
1407
- say(`coverage for ${plural(recorded.files.size, "file")} at ${short(tip)}; ${covered} of ${executable} changed lines covered`);
1509
+ const total = `coverage for ${plural(recorded.files.size, "file")} at ${short(tip)}; ${covered} of ${executable} changed lines covered`;
1510
+ if (test === undefined)
1511
+ say(total);
1512
+ else {
1513
+ // A per-test run adds to what is on record, so its own share is said apart from the total.
1514
+ const own = totalOf(changedLines(session.diff.units, run.files).values()).covered;
1515
+ const branches = changedBranchesTaken(session.diff.units, run.files);
1516
+ say(`recorded test ${JSON.stringify(test.id)}: ${plural(own, "changed line")}, ${branches === 1 ? "1 branch" : `${branches} branches`}`);
1517
+ say(`running total of ${plural(recorded.tests.size, "test")}: ${total}`);
1518
+ }
1519
+ if (test === undefined && open !== null && (await mapWantsTests(session, open))) {
1520
+ next(`${perTestStep(session, cwd)}; then run follow-me again`);
1521
+ }
1408
1522
  return 0;
1409
1523
  }
1410
1524
  /**
@@ -1540,7 +1654,11 @@ async function reply(cwd, args) {
1540
1654
  ...(refs.length === 0 ? {} : { refs }),
1541
1655
  });
1542
1656
  say(`replied ${id} to ${commentId}`);
1543
- next(`run follow-me again; ${STILL_WORKING}`);
1657
+ if (map !== undefined && (session.coverage()?.tests.size ?? 0) === 0) {
1658
+ next(`${perTestStep(session, cwd)}; then run follow-me again; ${STILL_WORKING}`);
1659
+ }
1660
+ else
1661
+ next(`run follow-me again; ${STILL_WORKING}`);
1544
1662
  return 0;
1545
1663
  }
1546
1664
  const REPLY_USAGE = `usage: follow-me reply <commentId> --body <text> [--diagram <file> [--map <file>]]
@@ -1824,7 +1942,7 @@ async function showRef(cwd, args) {
1824
1942
  throw new UsageError(`${ref} is not a unit of the branch now; its hunk changed`);
1825
1943
  }
1826
1944
  const commits = latest === undefined ? [] : projectedCommits(session, latest.version);
1827
- process.stdout.write(renderShownUnit(unit, commits));
1945
+ process.stdout.write(renderShownUnit(unit, units, commits));
1828
1946
  return 0;
1829
1947
  }
1830
1948
  if (/^c_[a-z2-7]{6}$/.test(ref) && isCommentId(ref)) {
@@ -1915,6 +2033,9 @@ async function serverCommand(argv) {
1915
2033
  await started.host.closed;
1916
2034
  return 0;
1917
2035
  }
2036
+ case "--help":
2037
+ case "-h":
2038
+ throw new HelpRequested();
1918
2039
  default:
1919
2040
  throw new UsageError("usage: follow-me server status|stop");
1920
2041
  }
@@ -2136,6 +2257,11 @@ export async function main(argv, cwd) {
2136
2257
  }
2137
2258
  }
2138
2259
  catch (error) {
2260
+ if (error instanceof HelpRequested) {
2261
+ const asked = command === undefined || command === "help" ? null : usageOf(command);
2262
+ say(asked ?? SHORT_USAGE);
2263
+ return 0;
2264
+ }
2139
2265
  const known = [
2140
2266
  UsageError,
2141
2267
  CoverageError,
@@ -1,6 +1,7 @@
1
1
  import { parseLoc } from "./coverage.js";
2
2
  import { isRepoPath, isSha, isUnitId } from "./diff.js";
3
3
  import { diagramProblem, isArray, isCommentId, isConcernId, isFields, isOfferId, isPlanVersion, isSeverity, parsePlacedConcern, } from "./plan.js";
4
+ import { parseRefText } from "./refs.js";
4
5
  /**
5
6
  * The wire formats of review state, parsed once at the boundary: comments, replies, concern
6
7
  * decisions, the review file, its autosaved draft and the events a hosted session logs. Every
@@ -49,26 +50,42 @@ function parseAnchor(v) {
49
50
  }
50
51
  return { kind: "hunk", unit: v.unit, commit, changes, offsets };
51
52
  }
53
+ /** A place written as a ref string (`path:a-b`, tip lines) or as { path, start, end }. */
54
+ function parsePlace(raw) {
55
+ if (typeof raw !== "string")
56
+ return parseLoc(raw);
57
+ const spec = parseRefText(raw);
58
+ if (spec === null)
59
+ return null;
60
+ if (spec.at !== null)
61
+ return `${JSON.stringify(raw)}: a map is in tip lines; drop the @`;
62
+ return parseLoc({ path: spec.path, start: spec.start, end: spec.end });
63
+ }
52
64
  function parseLocs(v, at, needed) {
53
- const what = `${at}: expected a list of { path, start, end }${needed ? " with at least one place" : ""}`;
65
+ const what = `${at}: expected a list of "path:a-b" or { path, start, end }${needed ? " with at least one place" : ""}`;
54
66
  if (!isArray(v) || (needed && v.length === 0))
55
67
  return what;
56
68
  const out = [];
57
69
  for (const [i, raw] of v.entries()) {
58
- const loc = parseLoc(raw);
70
+ const loc = parsePlace(raw);
59
71
  if (loc === null)
60
- return `${at}[${i}]: expected { path, start, end } with 1 <= start <= end`;
72
+ return `${at}[${i}]: expected "path:a-b" or { path, start, end } with 1 <= start <= end`;
73
+ if (typeof loc === "string")
74
+ return `${at}[${i}]: ${loc}`;
61
75
  out.push(loc);
62
76
  }
63
77
  return out;
64
78
  }
65
79
  const TRANSITION_ENDS = ["from", "to"];
80
+ /** A stateDiagram's start and end marker. */
81
+ const START_END = "[*]";
66
82
  function escapeRegExp(text) {
67
83
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
68
84
  }
69
85
  /**
70
86
  * 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
87
+ * names states of the map, and with a stateDiagram every state name appears in it. A transition
88
+ * to or from `[*]` is dropped: the diagram marks the start and end. The reason
72
89
  * names the bad entry, as map.states.<name>[i] or map.transitions[i].<field>.
73
90
  */
74
91
  export function parseStateMap(v, diagram) {
@@ -95,6 +112,10 @@ export function parseStateMap(v, diagram) {
95
112
  const at = `map.transitions[${i}]`;
96
113
  if (!isFields(raw))
97
114
  return `${at}: expected { from, to, event, at }`;
115
+ // The diagram's [*] already marks where the machine starts and ends; a move to or from it
116
+ // has no code of its own, so it is dropped rather than refused.
117
+ if (raw.from === START_END || raw.to === START_END)
118
+ continue;
98
119
  for (const end of TRANSITION_ENDS) {
99
120
  const state = raw[end];
100
121
  if (typeof state !== "string" || !named.has(state)) {
@@ -272,12 +272,8 @@ export function recordRun(prior, run, at) {
272
272
  function testEntry(ran) {
273
273
  return { lines: ran.lines, ...(ran.branches.size === 0 ? {} : { branches: ran.branches }) };
274
274
  }
275
- /**
276
- * Per file, the branch's added rows that lcov calls executable, and how many ran. Every added
277
- * row is a tip line, which is where the run was taken. Files without such rows are left out.
278
- */
279
- export function changedLines(units, files) {
280
- const out = new Map();
275
+ /** Calls visit with each added row's tip line, per hunk, for files the run covers. */
276
+ function eachAddedLine(units, files, visit) {
281
277
  for (const unit of units.values()) {
282
278
  if (unit.kind !== "hunk")
283
279
  continue;
@@ -285,22 +281,37 @@ export function changedLines(units, files) {
285
281
  if (file === undefined)
286
282
  continue;
287
283
  let line = tipStartOf(unit, units);
288
- let { covered, executable } = out.get(unit.path) ?? { covered: 0, executable: 0 };
289
284
  for (const row of unit.body) {
290
- const hits = row.op === "+" ? file.lines.get(line) : undefined;
291
- if (hits !== undefined) {
292
- executable += 1;
293
- if (hits > 0)
294
- covered += 1;
295
- }
285
+ if (row.op === "+")
286
+ visit(unit.path, file, line);
296
287
  if (row.op !== "-")
297
288
  line += 1;
298
289
  }
299
- if (executable > 0)
300
- out.set(unit.path, { covered, executable });
301
290
  }
291
+ }
292
+ /**
293
+ * Per file, the branch's added rows that lcov calls executable, and how many ran. Every added
294
+ * row is a tip line, which is where the run was taken. Files without such rows are left out.
295
+ */
296
+ export function changedLines(units, files) {
297
+ const out = new Map();
298
+ eachAddedLine(units, files, (path, file, line) => {
299
+ const hits = file.lines.get(line);
300
+ if (hits === undefined)
301
+ return;
302
+ const { covered, executable } = out.get(path) ?? { covered: 0, executable: 0 };
303
+ out.set(path, { covered: covered + (hits > 0 ? 1 : 0), executable: executable + 1 });
304
+ });
302
305
  return out;
303
306
  }
307
+ /** How many branches the run took on the branch's added rows, summed over every decision. */
308
+ export function changedBranchesTaken(units, files) {
309
+ let taken = 0;
310
+ eachAddedLine(units, files, (_, file, line) => {
311
+ taken += file.branches.get(line)?.[0] ?? 0;
312
+ });
313
+ return taken;
314
+ }
304
315
  export function totalOf(tallies) {
305
316
  let covered = 0;
306
317
  let executable = 0;
package/dist/src/host.js CHANGED
@@ -5,10 +5,12 @@ import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { parseCarried, parseComment, parseEvent, parseEventBatch, } from "./comments.js";
7
7
  import { carryComments } from "./evolve.js";
8
+ import { plainNumber, plainText } from "./plain.js";
8
9
  import { isPlanVersion, serializePlanFile, validatePlan } from "./plan.js";
9
10
  import { HEALTH_APP, isSessionId, loadDraft, PortInUseError, RegistrationError, startServer, } from "./server.js";
10
11
  import { BranchMovedError, diffOfPlan, isAlive, openSessionAt, SessionFileError, SessionLockedError, sessionComments, } from "./session.js";
11
12
  const RECORD = "server.json";
13
+ const PATH_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/._~-?=&";
12
14
  const DEFAULT_IDLE_MS = 30_000;
13
15
  const START_TIMEOUT_MS = 10_000;
14
16
  const STOP_TIMEOUT_MS = 5_000;
@@ -28,6 +30,13 @@ export function idleMs(env = process.env) {
28
30
  function isPort(value) {
29
31
  return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65535;
30
32
  }
33
+ /** Every request this client makes goes to the loopback server, on a port and a path that are plain. */
34
+ function loopbackUrl(port, path) {
35
+ if (!isPort(port) || !/^\/[\w./~-]*(\?[\w=&.-]*)?$/.test(path)) {
36
+ throw new RegistrationError("refusing a request to an unexpected address");
37
+ }
38
+ return `http://127.0.0.1:${plainNumber(port)}${plainText(path, PATH_CHARS)}`;
39
+ }
31
40
  function isFields(value) {
32
41
  return typeof value === "object" && value !== null && !Array.isArray(value);
33
42
  }
@@ -52,7 +61,7 @@ export function readServerRecord(dir) {
52
61
  /** The pid of the follow-me server answering on this port, or null for anything else. */
53
62
  export async function probeHealth(port) {
54
63
  try {
55
- const res = await fetch(`http://127.0.0.1:${port}/api/health`, {
64
+ const res = await fetch(loopbackUrl(port, "/api/health"), {
56
65
  signal: AbortSignal.timeout(1_000),
57
66
  });
58
67
  const body = await res.json();
@@ -122,6 +131,7 @@ async function loadHosted(registration, held) {
122
131
  }
123
132
  const verdict = validatePlan(session.diff, serializePlanFile(stored.file), {
124
133
  lastReview: session.lastReview(),
134
+ frozen: true,
125
135
  });
126
136
  if (!verdict.ok) {
127
137
  throw new RegistrationError(`plan v${version} no longer fits ${branch}; check it again`);
@@ -251,7 +261,7 @@ export async function spawnDetached(dir, port) {
251
261
  throw new Error(`the review server did not start; see ${logPath}`);
252
262
  }
253
263
  async function call(port, method, path, body) {
254
- const res = await fetch(`http://127.0.0.1:${port}${path}`, {
264
+ const res = await fetch(loopbackUrl(port, path), {
255
265
  method,
256
266
  headers: { "content-type": "application/json" },
257
267
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
@@ -356,7 +366,7 @@ export async function pollEvents(port, summary, since, timeoutMs) {
356
366
  * the server closes the stream, which it does after the `ended` event.
357
367
  */
358
368
  export async function followEvents(port, summary, since, onEvent) {
359
- const res = await fetch(`${sessionUrl(port, summary)}api/events/stream?since=${since}`);
369
+ const res = await fetch(loopbackUrl(port, `${summary.url}api/events/stream?since=${Number(since)}`));
360
370
  if (!res.ok || res.body === null) {
361
371
  throw new RegistrationError(`GET api/events/stream failed with ${res.status}`);
362
372
  }
@@ -402,7 +412,7 @@ export async function postCoverage(port, summary) {
402
412
  }
403
413
  /** The consolidated report of the hosted session as it stands. */
404
414
  export async function fetchReport(port, summary, format = "markdown") {
405
- const res = await fetch(`${sessionUrl(port, summary)}api/report?format=${format}`);
415
+ const res = await fetch(loopbackUrl(port, `${summary.url}api/report?format=${format}`));
406
416
  if (!res.ok)
407
417
  throw new RegistrationError(`GET api/report failed with ${res.status}`);
408
418
  return res.text();
@@ -422,7 +432,7 @@ export function sessionUrl(port, summary) {
422
432
  /** Whether the server on this port still serves the session's state; a gone server serves none. */
423
433
  export async function stillHosted(port, summary) {
424
434
  try {
425
- const res = await fetch(`${sessionUrl(port, summary)}api/state`, {
435
+ const res = await fetch(loopbackUrl(port, `${summary.url}api/state`), {
426
436
  signal: AbortSignal.timeout(2_000),
427
437
  });
428
438
  await res.body?.cancel();
@@ -60,9 +60,19 @@ export function planSkillFiles(options, home, repoRoot) {
60
60
  }
61
61
  return [...byPath.values()];
62
62
  }
63
+ function readIfPresent(path) {
64
+ try {
65
+ return readFileSync(path);
66
+ }
67
+ catch (error) {
68
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
69
+ return null;
70
+ throw error;
71
+ }
72
+ }
63
73
  function writeSkill(skillDir, file, dryRun) {
64
74
  const content = readFileSync(join(skillDir, file.source));
65
- const current = existsSync(file.path) ? readFileSync(file.path) : null;
75
+ const current = readIfPresent(file.path);
66
76
  if (current?.equals(content))
67
77
  return `unchanged ${file.path}`;
68
78
  if (dryRun)
@@ -0,0 +1,20 @@
1
+ const DIGITS = "0123456789";
2
+ /**
3
+ * Rebuilds `text` one character at a time from `alphabet`, and throws on a character outside it. What comes
4
+ * back is made only of characters that were checked, so a port read from a file or an id sent by the server
5
+ * cannot carry anything else into a request address or a file.
6
+ */
7
+ export function plainText(text, alphabet) {
8
+ let out = "";
9
+ for (const char of text) {
10
+ const at = alphabet.indexOf(char);
11
+ if (at < 0)
12
+ throw new Error(`unexpected character ${JSON.stringify(char)}`);
13
+ out += alphabet.charAt(at);
14
+ }
15
+ return out;
16
+ }
17
+ /** A whole number, rebuilt from its digits; throws on anything else. */
18
+ export function plainNumber(value) {
19
+ return Number(plainText(String(value), DIGITS));
20
+ }