@shiyuphan/follow-me 0.7.1 → 0.9.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 (100) hide show
  1. package/README.md +9 -1
  2. package/dist/src/cli.js +181 -42
  3. package/dist/src/comments.js +215 -9
  4. package/dist/src/coverage.js +163 -25
  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/refs.js +239 -0
  9. package/dist/src/server.js +65 -3
  10. package/dist/src/session.js +1 -0
  11. package/dist/ui/dist/assets/{abnfDiagram-WEYOIA3C-cHEJvcBn.js → abnfDiagram-WEYOIA3C-DztIEwNE.js} +1 -1
  12. package/dist/ui/dist/assets/{arc-xolFyc0v.js → arc-DUYh-EqZ.js} +1 -1
  13. package/dist/ui/dist/assets/{architecture-WOLXFQ4H-B3hvvNV2.js → architecture-WOLXFQ4H-BQYTB92_.js} +1 -1
  14. package/dist/ui/dist/assets/{architectureDiagram-ZHVYKT53-BNTMbPJW.js → architectureDiagram-ZHVYKT53-Dl7095A5.js} +1 -1
  15. package/dist/ui/dist/assets/{blockDiagram-RJAE7QA3-NYY6qeuY.js → blockDiagram-RJAE7QA3-QDZ5kE1J.js} +1 -1
  16. package/dist/ui/dist/assets/{c4Diagram-XDPPPE54-B_bNpXHQ.js → c4Diagram-XDPPPE54-CKJZ1xOw.js} +1 -1
  17. package/dist/ui/dist/assets/channel-DF08Hd8f.js +1 -0
  18. package/dist/ui/dist/assets/{chunk-2E7WHVMD-BTet9STn.js → chunk-2E7WHVMD-BXBjaqd1.js} +1 -1
  19. package/dist/ui/dist/assets/{chunk-4EMNKR2T-qoQt4Fpx.js → chunk-4EMNKR2T-DPzfVuke.js} +1 -1
  20. package/dist/ui/dist/assets/{chunk-55XCU76K-C3I6EB-H.js → chunk-55XCU76K-B33Sj3w8.js} +1 -1
  21. package/dist/ui/dist/assets/{chunk-7S6XSTFO-CL-9s_8p.js → chunk-7S6XSTFO-B_u7dS03.js} +1 -1
  22. package/dist/ui/dist/assets/{chunk-AQDTSKZB-DHoGnGyP.js → chunk-AQDTSKZB-B4EzH5gx.js} +1 -1
  23. package/dist/ui/dist/assets/{chunk-DSNLSHJZ-Bk8WO5JF.js → chunk-DSNLSHJZ-CfWeHABr.js} +1 -1
  24. package/dist/ui/dist/assets/{chunk-HAGFOTED-CwAC85lg.js → chunk-HAGFOTED-B8G8PjLZ.js} +1 -1
  25. package/dist/ui/dist/assets/{chunk-HNE4Q36P-CIllywWr.js → chunk-HNE4Q36P-BA-PqoTF.js} +1 -1
  26. package/dist/ui/dist/assets/{chunk-NQOKRVIF-DDlpyYiH.js → chunk-NQOKRVIF-JvhuFcnR.js} +1 -1
  27. package/dist/ui/dist/assets/{chunk-UHW7PFBP-Cxdndcon.js → chunk-UHW7PFBP-BrfL5CGY.js} +1 -1
  28. package/dist/ui/dist/assets/{chunk-VQ5W62QI-CL9d9_iJ.js → chunk-VQ5W62QI-dBJqgdDy.js} +1 -1
  29. package/dist/ui/dist/assets/{chunk-WQQL4CDK-C4kzN8QU.js → chunk-WQQL4CDK-dfqd3k_P.js} +1 -1
  30. package/dist/ui/dist/assets/{chunk-XXDRQBXY-Cv_2FSiY.js → chunk-XXDRQBXY-kViWdB9M.js} +1 -1
  31. package/dist/ui/dist/assets/{chunk-YZO67HFG-oHiuYaCJ.js → chunk-YZO67HFG-ukfs4wT3.js} +1 -1
  32. package/dist/ui/dist/assets/{chunk-ZAUX2LTX-Cy_8d0XT.js → chunk-ZAUX2LTX-BxZHki81.js} +1 -1
  33. package/dist/ui/dist/assets/{chunk-ZCHMQSOR-piIpIcQi.js → chunk-ZCHMQSOR-PjluhR_E.js} +1 -1
  34. package/dist/ui/dist/assets/{classDiagram-v2-4MYWR3UA-DNKRIujO.js → classDiagram-v2-4MYWR3UA-Ct89LJec.js} +1 -1
  35. package/dist/ui/dist/assets/{cose-bilkent-JH36ORCC-BWKTGCHY.js → cose-bilkent-JH36ORCC-BnQceGmB.js} +1 -1
  36. package/dist/ui/dist/assets/{cynefin-EF2NZ3EQ-yx7KKkAk.js → cynefin-EF2NZ3EQ-D6WkCVE8.js} +1 -1
  37. package/dist/ui/dist/assets/{cynefinDiagram-WF24UDRP-BTnZ7QVL.js → cynefinDiagram-WF24UDRP-dn6DUPAb.js} +1 -1
  38. package/dist/ui/dist/assets/{dagre-NK4GIZVX-CPh5Zn5I.js → dagre-NK4GIZVX-BtZSlLk7.js} +1 -1
  39. package/dist/ui/dist/assets/{diagram-CBK3MWTC-pI6NFdrD.js → diagram-CBK3MWTC-Ds4_tv6X.js} +1 -1
  40. package/dist/ui/dist/assets/{diagram-I5XIIV2D-DUIfARoS.js → diagram-I5XIIV2D-DcoVkg45.js} +1 -1
  41. package/dist/ui/dist/assets/{diagram-ODIL2UCW-lafSPvAw.js → diagram-ODIL2UCW-T47FAA3k.js} +1 -1
  42. package/dist/ui/dist/assets/{diagram-PFBEJUXE-u1lZas3a.js → diagram-PFBEJUXE-CRZcaVKP.js} +1 -1
  43. package/dist/ui/dist/assets/{diagram-RJA3I4VV-mc0GLMHo.js → diagram-RJA3I4VV-D6KXeUPg.js} +1 -1
  44. package/dist/ui/dist/assets/{diagram-TYUNFSNR-1NRbBrZW.js → diagram-TYUNFSNR-BHEIVyNN.js} +1 -1
  45. package/dist/ui/dist/assets/{dist-CHZ9mrl_.js → dist-DLlKIWBp.js} +1 -1
  46. package/dist/ui/dist/assets/{ebnfDiagram-V4P3TTPT-DdStjTDc.js → ebnfDiagram-V4P3TTPT-nUDvYRBV.js} +1 -1
  47. package/dist/ui/dist/assets/{elk-QONTBCTQ-BzRoFO45.js → elk-QONTBCTQ-D21BcRzc.js} +1 -1
  48. package/dist/ui/dist/assets/{erDiagram-KYQIGQAA-DlMkgQxj.js → erDiagram-KYQIGQAA-CTpHuftU.js} +1 -1
  49. package/dist/ui/dist/assets/{eventmodeling-K75KTNOO-BRYIOrod.js → eventmodeling-K75KTNOO-B6JaKuDY.js} +1 -1
  50. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-BTeOUR-p.js +1 -0
  51. package/dist/ui/dist/assets/{ganttDiagram-R7GPNTJY-D8fQZ7Ah.js → ganttDiagram-R7GPNTJY-D2-pMcnC.js} +1 -1
  52. package/dist/ui/dist/assets/{gitGraph-VSP46ZUC-C7ZydyWX.js → gitGraph-VSP46ZUC-B2yxwhEo.js} +1 -1
  53. package/dist/ui/dist/assets/{gitGraphDiagram-PDYL7XHY-DCItam0v.js → gitGraphDiagram-PDYL7XHY-DnrhBMH1.js} +1 -1
  54. package/dist/ui/dist/assets/index-D74Skifa.css +1 -0
  55. package/dist/ui/dist/assets/index-Dnte9tPd.js +136 -0
  56. package/dist/ui/dist/assets/{info-OHQRW6UA-C3KneBKT.js → info-OHQRW6UA-D8oh6gvs.js} +1 -1
  57. package/dist/ui/dist/assets/{infoDiagram-MVDRKAX5-6wK09D32.js → infoDiagram-MVDRKAX5-BLSZjjgB.js} +1 -1
  58. package/dist/ui/dist/assets/{ishikawaDiagram-HPEHV2GE-DTP5C8VX.js → ishikawaDiagram-HPEHV2GE-BXiznLSn.js} +1 -1
  59. package/dist/ui/dist/assets/{journeyDiagram-BOSHW7O7-B--UIMtq.js → journeyDiagram-BOSHW7O7-BSYmVcO_.js} +1 -1
  60. package/dist/ui/dist/assets/{kanban-definition-FNSA4HC5-Ku4N9QGt.js → kanban-definition-FNSA4HC5-COthkkzK.js} +1 -1
  61. package/dist/ui/dist/assets/{line-C4-H5h1-.js → line-6ND8OaOL.js} +1 -1
  62. package/dist/ui/dist/assets/{linear-DhAID3hm.js → linear-DbWEPFJX.js} +1 -1
  63. package/dist/ui/dist/assets/{mermaid-parser.core-CMPNc-u6.js → mermaid-parser.core-Btsk294z.js} +3 -3
  64. package/dist/ui/dist/assets/{mermaid.core-Nd_xOg2y.js → mermaid.core-B4llDSFO.js} +4 -4
  65. package/dist/ui/dist/assets/{mindmap-definition-6OY57UG3-BlVsym5h.js → mindmap-definition-6OY57UG3-hv2PfX-x.js} +1 -1
  66. package/dist/ui/dist/assets/{packet-JDAUHWVQ-rsk7SM7S.js → packet-JDAUHWVQ-Dr4vBgjt.js} +1 -1
  67. package/dist/ui/dist/assets/{pegDiagram-ECIUJNBX-C0L20M8z.js → pegDiagram-ECIUJNBX-DvlFov3N.js} +1 -1
  68. package/dist/ui/dist/assets/{pie-XZMESJXO-B23-iZSN.js → pie-XZMESJXO-C9QsqC4n.js} +1 -1
  69. package/dist/ui/dist/assets/{pieDiagram-GNMPY74T-BqFhe4Oh.js → pieDiagram-GNMPY74T-CrrHOj67.js} +1 -1
  70. package/dist/ui/dist/assets/{prism-core-DYsiqJeR.js → prism-core-Cc8H7LPG.js} +1 -1
  71. package/dist/ui/dist/assets/{quadrantDiagram-D57K4MC3-BKP75B-m.js → quadrantDiagram-D57K4MC3-DQ7J6-ti.js} +1 -1
  72. package/dist/ui/dist/assets/{radar-ABXABTNO-BrEk2mQ7.js → radar-ABXABTNO-BBBPaujh.js} +1 -1
  73. package/dist/ui/dist/assets/{railroad-I3PHUGI6-wWO4Eapc.js → railroad-I3PHUGI6-C-P6Igaz.js} +1 -1
  74. package/dist/ui/dist/assets/{railroad-abnf-LNEFI6M7-DfY-IPvE.js → railroad-abnf-LNEFI6M7-CAkorRLs.js} +1 -1
  75. package/dist/ui/dist/assets/{railroad-ebnf-DWYD2UWJ-19wedWGk.js → railroad-ebnf-DWYD2UWJ-D8Hz6gCf.js} +1 -1
  76. package/dist/ui/dist/assets/{railroad-peg-JR7BVNR7-9vIslnoJ.js → railroad-peg-JR7BVNR7-D-XNMkOs.js} +1 -1
  77. package/dist/ui/dist/assets/{railroadDiagram-Y72NRBL7-DVnmXs8w.js → railroadDiagram-Y72NRBL7-DE9LwxJQ.js} +1 -1
  78. package/dist/ui/dist/assets/{requirementDiagram-EQYSW5RA-BEoMdlzR.js → requirementDiagram-EQYSW5RA-FrKFY6NJ.js} +1 -1
  79. package/dist/ui/dist/assets/{sankeyDiagram-SY24JHO5-D7Tn2Gbs.js → sankeyDiagram-SY24JHO5-BDxI6RDX.js} +1 -1
  80. package/dist/ui/dist/assets/{sequenceDiagram-BLEHNHQU-CyjUDBaQ.js → sequenceDiagram-BLEHNHQU-pV1XcKX7.js} +1 -1
  81. package/dist/ui/dist/assets/{src-BiC9XNcI.js → src-snNJTXXq.js} +1 -1
  82. package/dist/ui/dist/assets/{stateDiagram-v2-A6IESKWZ-CMp1VCO9.js → stateDiagram-v2-A6IESKWZ-BSc_5aAf.js} +1 -1
  83. package/dist/ui/dist/assets/{swimlanes-B3ENRKFQ-KEciLd6r.js → swimlanes-B3ENRKFQ-DC6qUXLh.js} +1 -1
  84. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-F1HykV3o.js +8 -0
  85. package/dist/ui/dist/assets/{timeline-definition-JXYKGLGB-C8YQtv7M.js → timeline-definition-JXYKGLGB-C8L0Dxtu.js} +1 -1
  86. package/dist/ui/dist/assets/{treeView-D4JQ5SDB-DgAzPaOO.js → treeView-D4JQ5SDB-DDyZIG5T.js} +1 -1
  87. package/dist/ui/dist/assets/{treemap-SAJKECNS-eCRqZMYc.js → treemap-SAJKECNS-CUaLgge0.js} +1 -1
  88. package/dist/ui/dist/assets/{usecaseDiagram-WPOLBNNX-Dirryw9G.js → usecaseDiagram-WPOLBNNX-BHRBd5pV.js} +1 -1
  89. package/dist/ui/dist/assets/{vennDiagram-Q3Y3IDCW-BjxQbAUx.js → vennDiagram-Q3Y3IDCW-Du4iwwkh.js} +1 -1
  90. package/dist/ui/dist/assets/{wardley-7MLQ67FV-CqHX5RUz.js → wardley-7MLQ67FV-DUgENI0s.js} +1 -1
  91. package/dist/ui/dist/assets/{wardleyDiagram-IVAIDKWA-Ds1HoEUY.js → wardleyDiagram-IVAIDKWA-B86zOdke.js} +1 -1
  92. package/dist/ui/dist/assets/{xychartDiagram-4URNHDU4-kodzDb12.js → xychartDiagram-4URNHDU4-twq9irqO.js} +1 -1
  93. package/dist/ui/dist/index.html +2 -2
  94. package/package.json +1 -1
  95. package/skills/follow-me/SKILL.md +5 -3
  96. package/dist/ui/dist/assets/channel-DPDY0XJP.js +0 -1
  97. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-C1IBh_GZ.js +0 -1
  98. package/dist/ui/dist/assets/index-8BehwQ7E.js +0 -134
  99. package/dist/ui/dist/assets/index-mC2OW2Av.css +0 -1
  100. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-DzestpDE.js +0 -8
package/dist/src/plan.js CHANGED
@@ -10,6 +10,12 @@ export function isCommentId(s) {
10
10
  export function isConcernId(s) {
11
11
  return typeof s === "string" && /^k_[0-9a-f]{8}$/.test(s);
12
12
  }
13
+ export function isOfferId(s) {
14
+ return typeof s === "string" && /^o_[0-9a-f]{8}$/.test(s);
15
+ }
16
+ /** At most this many offers in one commit and in the whole plan; most plans need none. */
17
+ export const OFFERS_PER_COMMIT = 1;
18
+ export const OFFERS_PER_PLAN = 3;
13
19
  export function isPlanVersion(n) {
14
20
  return typeof n === "number" && Number.isInteger(n) && n >= 1;
15
21
  }
@@ -29,6 +35,10 @@ export function isSeverity(s) {
29
35
  function isConcernKind(s) {
30
36
  return CONCERN_KINDS.some((x) => x === s);
31
37
  }
38
+ const OFFER_KINDS = ["state-machine", "sequence", "diagram"];
39
+ function isOfferKind(s) {
40
+ return OFFER_KINDS.some((x) => x === s);
41
+ }
32
42
  /** Narrows to an object whose named fields are still unknown, which holds for any object. */
33
43
  export function isFields(v) {
34
44
  return typeof v === "object" && v !== null && !Array.isArray(v);
@@ -123,7 +133,47 @@ function parseConcerns(v, onUnit) {
123
133
  }
124
134
  return out;
125
135
  }
126
- /** The optional narration, diagram and concerns of a commit or unit entry, or what is wrong. */
136
+ /** An offer is { kind, label }; the label names the thing to draw. */
137
+ function parseOffer(v) {
138
+ if (!isFields(v))
139
+ return "expected an object";
140
+ if (!isOfferKind(v.kind))
141
+ return `kind: expected one of ${OFFER_KINDS.join(", ")}`;
142
+ if (typeof v.label !== "string" || v.label.trim() === "") {
143
+ return 'label: expected what to draw, like "Draw UploadButton as a state machine"';
144
+ }
145
+ return { kind: v.kind, label: v.label };
146
+ }
147
+ /** A stored offer as validation placed it: with its id and its unit. */
148
+ export function parsePlacedOffer(v) {
149
+ if (!isFields(v))
150
+ return "expected an object";
151
+ if (!isOfferId(v.id))
152
+ return "id: expected an offer id";
153
+ if (v.unit !== undefined && !(typeof v.unit === "string" && isUnitId(v.unit))) {
154
+ return "unit: expected a unit id";
155
+ }
156
+ const offer = parseOffer(v);
157
+ if (typeof offer === "string")
158
+ return offer;
159
+ return { ...offer, id: v.id, ...(v.unit === undefined ? {} : { unit: v.unit }) };
160
+ }
161
+ /** Absent, null and [] all mean no offers. */
162
+ function parseOffers(v) {
163
+ if (v === undefined || v === null)
164
+ return [];
165
+ if (!isArray(v))
166
+ return "expected an array";
167
+ const out = [];
168
+ for (const [i, raw] of v.entries()) {
169
+ const offer = parseOffer(raw);
170
+ if (typeof offer === "string")
171
+ return `[${i}]: ${offer}`;
172
+ out.push(offer);
173
+ }
174
+ return out;
175
+ }
176
+ /** The optional narration, diagram, concerns and offers of a commit or unit entry, or what is wrong. */
127
177
  function parseExtras(v, onUnit) {
128
178
  const narration = parseText(v.narration);
129
179
  if (narration === INVALID)
@@ -137,7 +187,13 @@ function parseExtras(v, onUnit) {
137
187
  const concerns = parseConcerns(v.concerns, onUnit);
138
188
  if (typeof concerns === "string")
139
189
  return { field: "concerns", error: concerns };
140
- return withExtras(narration, diagram, concerns);
190
+ const offers = parseOffers(v.offers);
191
+ if (typeof offers === "string")
192
+ return { field: "offers", error: offers };
193
+ return {
194
+ ...withExtras(narration, diagram, concerns),
195
+ ...(offers.length === 0 ? {} : { offers }),
196
+ };
141
197
  }
142
198
  function withText(narration, diagram) {
143
199
  return {
@@ -160,15 +216,21 @@ function serializeExtras(x) {
160
216
  return {
161
217
  ...withText(x.narration ?? null, x.diagram ?? null),
162
218
  ...(x.concerns === undefined ? {} : { concerns: x.concerns.map(serializeConcern) }),
219
+ ...(x.offers === undefined
220
+ ? {}
221
+ : { offers: x.offers.map((o) => ({ kind: o.kind, label: o.label })) }),
163
222
  };
164
223
  }
165
- /** A unit entry is "h_x:0-3" or { "unit": "h_x:0-3", "narration", "diagram", "concerns" }. */
224
+ /**
225
+ * A unit entry is "h_x:0-3" or { "unit": "h_x:0-3", "narration", "diagram", "concerns", "offers" }.
226
+ */
166
227
  function parseUnitEntry(u) {
167
228
  if (typeof u === "string")
168
229
  return parseUnitRef(u);
169
- if (!isFields(u) || typeof u.unit !== "string") {
230
+ if (!isFields(u) ||
231
+ typeof u.unit !== "string") {
170
232
  return {
171
- error: 'expected a unit ref string or { "unit", "narration", "diagram", "concerns" }',
233
+ error: 'expected a unit ref string or { "unit", "narration", "diagram", "concerns", "offers" }',
172
234
  };
173
235
  }
174
236
  const ref = parseUnitRef(u.unit);
@@ -298,7 +360,10 @@ export function serializePlanFile(file) {
298
360
  message: c.message,
299
361
  ...(c.section === undefined ? {} : { section: c.section }),
300
362
  ...serializeExtras(c),
301
- units: c.units.map((ref) => ref.narration === undefined && ref.diagram === undefined && ref.concerns === undefined
363
+ units: c.units.map((ref) => ref.narration === undefined &&
364
+ ref.diagram === undefined &&
365
+ ref.concerns === undefined &&
366
+ ref.offers === undefined
302
367
  ? formatUnitRef(ref)
303
368
  : { unit: formatUnitRef(ref), ...serializeExtras(ref) }),
304
369
  })),
@@ -547,8 +612,51 @@ function placeConcerns(diff, file, claims, issues) {
547
612
  }
548
613
  return placedPerCommit;
549
614
  }
615
+ /** "o_" + 8 hex over the unit and the label: stable while the offer stays at its place. */
616
+ function deriveOfferId(unit, o) {
617
+ const hex = createHash("sha256")
618
+ .update(`offer\0${unit ?? ""}\0${o.label}`)
619
+ .digest("hex")
620
+ .slice(0, 8);
621
+ const id = `o_${hex}`;
622
+ if (!isOfferId(id))
623
+ throw new Error(`sha256 produced ${id}`);
624
+ return id;
625
+ }
626
+ /** Gives every offer its id: the commit's own first, then those of its unit refs in order. */
627
+ function placeOffers(file, issues) {
628
+ const ids = new Set();
629
+ return file.commits.map((commit, k) => {
630
+ const placed = [];
631
+ const add = (o, unit) => {
632
+ const id = deriveOfferId(unit, o);
633
+ if (ids.has(id)) {
634
+ issues.push({ kind: "bad-offer", commit: k, reason: `${id} repeats another offer` });
635
+ return;
636
+ }
637
+ ids.add(id);
638
+ placed.push({ ...o, id, ...(unit === null ? {} : { unit }) });
639
+ };
640
+ for (const o of commit.offers ?? [])
641
+ add(o, null);
642
+ for (const ref of commit.units)
643
+ for (const o of ref.offers ?? [])
644
+ add(o, ref.unit);
645
+ return placed;
646
+ });
647
+ }
648
+ /** At most OFFERS_PER_COMMIT in a commit, its units' included, and OFFERS_PER_PLAN in all. */
649
+ function offerLimits(file) {
650
+ const counts = file.commits.map((c) => (c.offers ?? []).length + c.units.reduce((n, r) => n + (r.offers ?? []).length, 0));
651
+ const issues = counts.flatMap((count, commit) => count > OFFERS_PER_COMMIT ? [{ kind: "too-many-offers", commit, count }] : []);
652
+ const total = counts.reduce((a, b) => a + b, 0);
653
+ if (total > OFFERS_PER_PLAN)
654
+ issues.push({ kind: "too-many-offers", commit: null, count: total });
655
+ return issues;
656
+ }
550
657
  function project(diff, file, claims, issues) {
551
658
  const concerns = placeConcerns(diff, file, claims, issues);
659
+ const offers = placeOffers(file, issues);
552
660
  const { stateAt, tipOf } = projector(diff, claims.owners);
553
661
  const present = new Map();
554
662
  for (const f of diff.textFiles.values())
@@ -560,6 +668,7 @@ function project(diff, file, claims, issues) {
560
668
  const commits = file.commits.map((commit, k) => {
561
669
  const refs = claims.resolved[k] ?? [];
562
670
  const mine = concerns[k] ?? [];
671
+ const offered = offers[k] ?? [];
563
672
  const touched = [...new Set(refs.flatMap((r) => diff.units.get(r.unit)?.path ?? []))].sort();
564
673
  const changes = touched.map((path) => {
565
674
  const state = stateAt(path, k);
@@ -591,6 +700,7 @@ function project(diff, file, claims, issues) {
591
700
  ...(commit.section === undefined ? {} : { section: commit.section }),
592
701
  ...withText(commit.narration ?? null, commit.diagram ?? null),
593
702
  ...(mine.length === 0 ? {} : { concerns: mine }),
703
+ ...(offered.length === 0 ? {} : { offers: offered }),
594
704
  refs,
595
705
  changes,
596
706
  };
@@ -655,6 +765,7 @@ export function validatePlan(diff, draft, opts = {}) {
655
765
  if (c.units.length === 0)
656
766
  issues.push({ kind: "empty-commit", commit: k });
657
767
  });
768
+ issues.push(...offerLimits(file));
658
769
  const assignment = [];
659
770
  const claims = claim(diff, file, assignment);
660
771
  issues.push(...assignment);
@@ -677,6 +788,17 @@ export function validatePlan(diff, draft, opts = {}) {
677
788
  if (length > LONG_TITLE)
678
789
  warnings.push({ kind: "long-title", commit, length });
679
790
  });
791
+ // Sections read top-down: story, steps, details, wiring. A later section never comes back.
792
+ let reached = null;
793
+ file.commits.forEach((c, commit) => {
794
+ if (c.section === undefined)
795
+ return;
796
+ if (reached !== null && SECTIONS.indexOf(c.section) < SECTIONS.indexOf(reached)) {
797
+ warnings.push({ kind: "section-order", commit, section: c.section, after: reached });
798
+ }
799
+ else
800
+ reached = c.section;
801
+ });
680
802
  const asked = new Map(review?.comments.map((c) => [c.id, c.kind === "question"]));
681
803
  for (const r of file.responses) {
682
804
  const isQuestion = asked.get(r.comment);
@@ -1018,6 +1018,28 @@ function indented(body) {
1018
1018
  function said(head, body) {
1019
1019
  return body.includes("\n") ? [`${head}:`, indented(body)] : [`${head}: ${body}`];
1020
1020
  }
1021
+ /**
1022
+ * The one line that tells the agent a reviewer accepted an offer: where it sits, as the tip lines
1023
+ * of its hunk or its commit, its label, and the command that answers it.
1024
+ */
1025
+ export function acceptedOfferLine(c, offer, ctx) {
1026
+ const found = (ctx?.commits ?? []).flatMap((commit, k) => (commit.offers ?? []).flatMap((o) => (o.id === offer ? [{ o, k }] : [])))[0];
1027
+ let place = describeAnchor(c.anchor, ctx);
1028
+ if (found !== undefined && found.o.unit === undefined)
1029
+ place = `commit ${found.k + 1}`;
1030
+ else if (found?.o.unit !== undefined) {
1031
+ const unit = ctx?.units.get(found.o.unit);
1032
+ let where = unit?.path ?? found.o.unit;
1033
+ if (unit?.kind === "hunk" && ctx !== null) {
1034
+ const from = tipStartOf(unit, ctx.units);
1035
+ const kept = unit.body.filter((row) => row.op !== "-").length;
1036
+ where = `${unit.path}:${from}-${from + Math.max(kept, 1) - 1}`;
1037
+ }
1038
+ place = `${where} (commit ${found.k + 1})`;
1039
+ }
1040
+ const label = (found?.o.label ?? c.body).trim().replace(/\.+$/, "");
1041
+ return `Reviewer accepted offer ${offer} on ${place}: ${label}. Reply with follow-me reply ${c.id} --diagram <file> [--map <file>].`;
1042
+ }
1021
1043
  /**
1022
1044
  * A new comment carries its short quote, since the agent has not seen it; everything else is one
1023
1045
  * line naming its id, because the agent already has the comment it refers to.
@@ -1026,6 +1048,8 @@ function renderEvent(event, ctx) {
1026
1048
  switch (event.type) {
1027
1049
  case "comment": {
1028
1050
  const { comment: c } = event;
1051
+ if (c.offer !== undefined)
1052
+ return [acceptedOfferLine(c, c.offer, ctx)];
1029
1053
  const what = c.kind === "question" ? "question" : (c.severity ?? "comment");
1030
1054
  const lines = [`- ${c.id} ${what} on ${describeAnchor(c.anchor, ctx)}`];
1031
1055
  if (c.follows)
@@ -1047,6 +1071,8 @@ function renderEvent(event, ctx) {
1047
1071
  }
1048
1072
  case "resolved":
1049
1073
  return [`- ${event.commentId} ${event.resolved ? "resolved" : "reopened"}`];
1074
+ case "offer-dismissed":
1075
+ return [`- the reviewer dismissed offer ${event.offer}`];
1050
1076
  case "version":
1051
1077
  return [`- plan v${event.version} is open`];
1052
1078
  case "coverage":
@@ -1084,11 +1110,38 @@ export function renderShownComment(c, ctx, version) {
1084
1110
  lines.push(...quoted.map((r) => ` ${r}`));
1085
1111
  lines.push(...said(c.author === "agent" ? "agent" : "reviewer", c.body));
1086
1112
  for (const reply of c.replies ?? []) {
1087
- const diagram = reply.diagram === undefined ? "" : " (with a diagram)";
1088
- lines.push(...said(`${reply.author} replied${diagram}`, reply.body));
1113
+ const extras = [
1114
+ reply.diagram === undefined ? null : "a diagram",
1115
+ reply.map === undefined ? null : "a state map",
1116
+ ];
1117
+ const parts = extras.filter((x) => x !== null);
1118
+ const head = `${reply.author} replied${parts.length === 0 ? "" : ` (with ${parts.join(" and ")})`}`;
1119
+ lines.push(...(reply.body.trim() === "" ? [head] : said(head, reply.body)));
1120
+ if (reply.map !== undefined)
1121
+ lines.push(...renderMap(reply.map).map((l) => ` ${l}`));
1089
1122
  }
1090
1123
  return `${lines.join("\n")}\n`;
1091
1124
  }
1125
+ /** "src/a.ts:12-20" or "src/a.ts:12", a following place in the same file as ":30-41". */
1126
+ function formatLocs(locs) {
1127
+ return locs
1128
+ .map((loc, i) => {
1129
+ const lines = loc.start === loc.end ? `${loc.start}` : `${loc.start}-${loc.end}`;
1130
+ return i > 0 && locs[i - 1]?.path === loc.path ? `:${lines}` : `${utf8(loc.path)}:${lines}`;
1131
+ })
1132
+ .join(", ");
1133
+ }
1134
+ /** A state map, one line per state and per transition. */
1135
+ function renderMap(map) {
1136
+ return [
1137
+ ...Object.entries(map.states).map(([name, locs]) => `state ${name}: ${formatLocs(locs)}`),
1138
+ ...(map.transitions ?? []).map((t) => {
1139
+ const event = t.event === undefined ? "" : ` on ${t.event}`;
1140
+ const at = t.at.length === 0 ? "" : `: ${formatLocs(t.at)}`;
1141
+ return `${t.from} -> ${t.to}${event}${at}`;
1142
+ }),
1143
+ ];
1144
+ }
1092
1145
  /** A concern of the plan with the lines it is pinned to, as its commit writes them. */
1093
1146
  export function renderShownConcern(concern, commit, ctx) {
1094
1147
  const where = whereConcern(ctx, { concern, commit, severity: concern.severity, note: null });
@@ -0,0 +1,239 @@
1
+ /** Lines quoted per ref, as comments quote their rows. */
2
+ export const QUOTE_LINES = 8;
3
+ const GRAMMAR = /^(.+):(\d+)(?:-(\d+))?(?:@(\d+|base))?$/;
4
+ /** `path:12`, `path:12-18`, `path:12@3`, `path:12-18@base`, or null when it is none of those. */
5
+ export function parseRefText(text) {
6
+ const m = GRAMMAR.exec(text);
7
+ const [, path, startRaw, endRaw, atRaw] = m ?? [];
8
+ if (path === undefined || startRaw === undefined)
9
+ return null;
10
+ const start = Number(startRaw);
11
+ const end = endRaw === undefined ? start : Number(endRaw);
12
+ const at = atRaw === undefined ? null : atRaw === "base" ? "base" : Number(atRaw);
13
+ return { text, path, start, end, at };
14
+ }
15
+ /** One key per place, so `a.ts:5` and `a.ts:5-5` share a number. */
16
+ export function refKey(spec) {
17
+ return `${spec.path}:${spec.start}-${spec.end}@${spec.at ?? "tip"}`;
18
+ }
19
+ /**
20
+ * A code span names a file when its path has a letter and a slash or a dot, so `10:30`,
21
+ * `localhost:3000` and `127.0.0.1:80` stay code.
22
+ */
23
+ function looksLikeFile(path) {
24
+ return /[A-Za-z]/.test(path) && /[/.]/.test(path) && !/\s/.test(path);
25
+ }
26
+ /**
27
+ * The text of every inline code span outside fenced blocks, in order, read as CommonMark does: a
28
+ * run of backticks closes only on a run of the same length, and one space padding both ends is
29
+ * dropped.
30
+ */
31
+ export function codeSpans(markdown) {
32
+ const spans = [];
33
+ let fence = null;
34
+ const prose = [];
35
+ for (const line of markdown.split("\n")) {
36
+ const opener = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
37
+ if (fence === null && opener !== undefined) {
38
+ fence = opener;
39
+ prose.push("");
40
+ continue;
41
+ }
42
+ if (fence !== null) {
43
+ const closer = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line)?.[1];
44
+ if (closer !== undefined && closer[0] === fence[0] && closer.length >= fence.length)
45
+ fence = null;
46
+ prose.push("");
47
+ continue;
48
+ }
49
+ prose.push(line);
50
+ }
51
+ // Spans never cross a blank line, which ends the paragraph.
52
+ for (const paragraph of prose.join("\n").split(/\n\s*\n/)) {
53
+ let i = 0;
54
+ while (i < paragraph.length) {
55
+ const c = paragraph[i];
56
+ if (c === "\\") {
57
+ i += 2;
58
+ continue;
59
+ }
60
+ if (c !== "`") {
61
+ i += 1;
62
+ continue;
63
+ }
64
+ let run = 0;
65
+ while (paragraph[i + run] === "`")
66
+ run += 1;
67
+ const open = i + run;
68
+ let close = -1;
69
+ for (let j = open; j < paragraph.length;) {
70
+ if (paragraph[j] !== "`") {
71
+ j += 1;
72
+ continue;
73
+ }
74
+ let k = 0;
75
+ while (paragraph[j + k] === "`")
76
+ k += 1;
77
+ if (k === run) {
78
+ close = j;
79
+ break;
80
+ }
81
+ j += k;
82
+ }
83
+ if (close === -1) {
84
+ i = open;
85
+ continue;
86
+ }
87
+ let text = paragraph.slice(open, close).replace(/\n/g, " ");
88
+ if (text.length > 2 && text.startsWith(" ") && text.endsWith(" ") && text.trim() !== "") {
89
+ text = text.slice(1, -1);
90
+ }
91
+ spans.push(text);
92
+ i = close + run;
93
+ }
94
+ }
95
+ return spans;
96
+ }
97
+ /** The refs a reply's markdown writes, each place once, in order of first appearance. */
98
+ export function refsIn(markdown) {
99
+ const seen = new Set();
100
+ const specs = [];
101
+ for (const span of codeSpans(markdown)) {
102
+ const spec = parseRefText(span);
103
+ if (spec === null || !looksLikeFile(spec.path))
104
+ continue;
105
+ const key = refKey(spec);
106
+ if (seen.has(key))
107
+ continue;
108
+ seen.add(key);
109
+ specs.push(spec);
110
+ }
111
+ return specs;
112
+ }
113
+ /**
114
+ * Every line of a changed text file as it reads with the given change lines applied: none for
115
+ * the base, all for the tip, the plan's first n commits for commit n. Null when the file does
116
+ * not exist then.
117
+ */
118
+ export function linesWith(file, applied) {
119
+ const changes = file.hunks.flatMap((h) => h.body.flatMap((l) => (l.op === " " ? [] : [applied(h, l)])));
120
+ const any = changes.some((c) => c);
121
+ const all = changes.every((c) => c);
122
+ if (file.base === null ? !any : file.tip === null && all)
123
+ return null;
124
+ const base = file.base?.lines ?? [];
125
+ const out = [];
126
+ let b = 0;
127
+ let t = 0;
128
+ const keep = (text) => {
129
+ b += 1;
130
+ t += 1;
131
+ out.push({ text, tip: t, base: b });
132
+ };
133
+ for (const hunk of file.hunks) {
134
+ while (b < hunk.oldStart)
135
+ keep(base[b] ?? "");
136
+ for (const line of hunk.body) {
137
+ if (line.op === " ")
138
+ keep(line.text);
139
+ else if (line.op === "-") {
140
+ b += 1;
141
+ if (!applied(hunk, line))
142
+ out.push({ text: line.text, tip: null, base: b });
143
+ }
144
+ else {
145
+ t += 1;
146
+ if (applied(hunk, line))
147
+ out.push({ text: line.text, tip: t, base: null });
148
+ }
149
+ }
150
+ }
151
+ while (b < base.length)
152
+ keep(base[b] ?? "");
153
+ return out;
154
+ }
155
+ /** Whether the plan's first n commits take this change line. */
156
+ export function takenBy(commits) {
157
+ return (hunk, line) => commits.some((c) => c.refs.some((r) => r.unit === hunk.id &&
158
+ (r.select.kind === "rest" || r.select.changes.includes(line.change))));
159
+ }
160
+ /** The repo path a typed path names: its UTF-8 as the latin1 bytes git prints. */
161
+ export function repoPathOfTyped(typed) {
162
+ return Buffer.from(typed, "utf8").toString("latin1");
163
+ }
164
+ function utf8(latin1) {
165
+ return Buffer.from(latin1, "latin1").toString("utf8");
166
+ }
167
+ function spanOf(ns) {
168
+ if (ns.length === 0)
169
+ return null;
170
+ return { start: Math.min(...ns), end: Math.max(...ns) };
171
+ }
172
+ function versionLabel(at) {
173
+ return at === null ? "at the tip" : at === "base" ? "in the base" : `in commit ${at}`;
174
+ }
175
+ /** A written ref resolved to its lines and quote, numbered n, or the reason it does not resolve. */
176
+ export function resolveRef(spec, n, source) {
177
+ const { text, start, end, at } = spec;
178
+ if (start < 1)
179
+ return `${text}: lines count from 1`;
180
+ if (end < start)
181
+ return `${text} ends before it starts`;
182
+ if (typeof at === "number" && (at < 1 || at > source.commits.length)) {
183
+ const count = source.commits.length;
184
+ return `${text}: plan v${source.version} has ${count} commit${count === 1 ? "" : "s"}`;
185
+ }
186
+ const path = repoPathOfTyped(spec.path);
187
+ const where = versionLabel(at);
188
+ let lines;
189
+ let repoPath;
190
+ const file = [...source.textFiles.values()].find((f) => f.path === path);
191
+ const plain = [...source.unchanged].find(([p]) => p === path);
192
+ if (file !== undefined) {
193
+ const applied = at === null ? () => true : at === "base" ? () => false : takenBy(source.commits.slice(0, at));
194
+ const found = linesWith(file, applied);
195
+ if (found === null)
196
+ return `${text}: the file does not exist ${where}`;
197
+ lines = found;
198
+ repoPath = file.path;
199
+ }
200
+ else if (source.otherChanged.has(path)) {
201
+ return `${text}: not a text file`;
202
+ }
203
+ else if (plain !== undefined) {
204
+ lines = plain[1].map((t, i) => ({ text: t, tip: i + 1, base: i + 1 }));
205
+ repoPath = plain[0];
206
+ }
207
+ else
208
+ return `${text}: no such file at the tip`;
209
+ if (end > lines.length) {
210
+ return `${text} is past the end of the file ${where} (${lines.length} lines)`;
211
+ }
212
+ const picked = lines.slice(start - 1, end);
213
+ const kept = picked.flatMap((l) => (l.tip === null ? [] : [l.tip]));
214
+ const removed = picked.flatMap((l) => (l.tip === null && l.base !== null ? [l.base] : []));
215
+ return {
216
+ n,
217
+ path: repoPath,
218
+ start,
219
+ end,
220
+ at,
221
+ quote: picked.slice(0, QUOTE_LINES).map((l) => utf8(l.text).replace(/\r?\n$/, "")),
222
+ tip: source.tip,
223
+ lines: spanOf(kept),
224
+ old: spanOf(removed),
225
+ };
226
+ }
227
+ /** Every ref of a body resolved, or every reason one does not, so one fix round is enough. */
228
+ export function resolveRefs(specs, source) {
229
+ const refs = [];
230
+ const problems = [];
231
+ specs.forEach((spec, i) => {
232
+ const ref = resolveRef(spec, i + 1, source);
233
+ if (typeof ref === "string")
234
+ problems.push(ref);
235
+ else
236
+ refs.push(ref);
237
+ });
238
+ return { refs, problems };
239
+ }