@shiyuphan/follow-me 0.1.1 → 0.3.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 (150) hide show
  1. package/README.md +119 -69
  2. package/dist/src/cli.js +1069 -140
  3. package/dist/src/comments.js +361 -0
  4. package/dist/src/git.js +32 -3
  5. package/dist/src/host.js +106 -9
  6. package/dist/src/install.js +22 -1
  7. package/dist/src/naive.js +26 -0
  8. package/dist/src/plan.js +282 -26
  9. package/dist/src/prompt.js +711 -98
  10. package/dist/src/server.js +573 -140
  11. package/dist/src/session.js +106 -104
  12. package/dist/src/syntax.js +199 -0
  13. package/dist/src/types.js +3 -1
  14. package/dist/ui/dist/assets/abnfDiagram-WEYOIA3C-DcmzRkVZ.js +1 -0
  15. package/dist/ui/dist/assets/arc-T5QB0T1C.js +1 -0
  16. package/dist/ui/dist/assets/architecture-WOLXFQ4H-DUIkQTeE.js +1 -0
  17. package/dist/ui/dist/assets/architectureDiagram-ZHVYKT53-BUYi4-bA.js +36 -0
  18. package/dist/ui/dist/assets/array-BifhSqXX.js +1 -0
  19. package/dist/ui/dist/assets/blockDiagram-RJAE7QA3-dyJxtT9e.js +140 -0
  20. package/dist/ui/dist/assets/c4Diagram-XDPPPE54-CrAgiaZR.js +38 -0
  21. package/dist/ui/dist/assets/channel-wW7ebm0i.js +1 -0
  22. package/dist/ui/dist/assets/chunk-2E7WHVMD-C6fAf4Vj.js +88 -0
  23. package/dist/ui/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  24. package/dist/ui/dist/assets/chunk-4EMNKR2T-BLZjwZ4Q.js +1 -0
  25. package/dist/ui/dist/assets/chunk-55XCU76K-DGLK2Q-L.js +1 -0
  26. package/dist/ui/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  27. package/dist/ui/dist/assets/chunk-7S6XSTFO-BablszTX.js +2 -0
  28. package/dist/ui/dist/assets/chunk-AQDTSKZB-Cuk3B_vb.js +72 -0
  29. package/dist/ui/dist/assets/chunk-DSNLSHJZ-DiC78RqE.js +127 -0
  30. package/dist/ui/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
  31. package/dist/ui/dist/assets/chunk-HAGFOTED-X_RbvixX.js +62 -0
  32. package/dist/ui/dist/assets/chunk-HNE4Q36P-C14_x5ck.js +1 -0
  33. package/dist/ui/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
  34. package/dist/ui/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  35. package/dist/ui/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
  36. package/dist/ui/dist/assets/chunk-NQOKRVIF-Dxd0dtYR.js +213 -0
  37. package/dist/ui/dist/assets/chunk-UHW7PFBP-BIxePNUS.js +1 -0
  38. package/dist/ui/dist/assets/chunk-VQ5W62QI-CKSKLbj4.js +1 -0
  39. package/dist/ui/dist/assets/chunk-WQQL4CDK-CWNmZGlR.js +10 -0
  40. package/dist/ui/dist/assets/chunk-XXDRQBXY-D-I5QHgG.js +1 -0
  41. package/dist/ui/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  42. package/dist/ui/dist/assets/chunk-YZO67HFG-DFP68CFd.js +1 -0
  43. package/dist/ui/dist/assets/chunk-ZAUX2LTX-DY90d4QG.js +1 -0
  44. package/dist/ui/dist/assets/chunk-ZCHMQSOR-Dlpr-5UC.js +2 -0
  45. package/dist/ui/dist/assets/classDiagram-v2-4MYWR3UA-CH9uhoLJ.js +217 -0
  46. package/dist/ui/dist/assets/cose-bilkent-JH36ORCC-DtYHrp8D.js +1 -0
  47. package/dist/ui/dist/assets/cynefin-EF2NZ3EQ-BtslbtrY.js +1 -0
  48. package/dist/ui/dist/assets/cynefinDiagram-WF24UDRP-DUU1Jcc7.js +62 -0
  49. package/dist/ui/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  50. package/dist/ui/dist/assets/dagre-NK4GIZVX-CDlP-dRW.js +4 -0
  51. package/dist/ui/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  52. package/dist/ui/dist/assets/diagram-CBK3MWTC-DiV2fn-r.js +24 -0
  53. package/dist/ui/dist/assets/diagram-I5XIIV2D-Bg5RehGi.js +24 -0
  54. package/dist/ui/dist/assets/diagram-ODIL2UCW-CFmazz3f.js +30 -0
  55. package/dist/ui/dist/assets/diagram-PFBEJUXE-uw3ZBqDp.js +3 -0
  56. package/dist/ui/dist/assets/diagram-RJA3I4VV-uEOYYRYa.js +41 -0
  57. package/dist/ui/dist/assets/diagram-TYUNFSNR-BN58LF_3.js +200 -0
  58. package/dist/ui/dist/assets/dist-tWS_lXDS.js +1 -0
  59. package/dist/ui/dist/assets/ebnfDiagram-V4P3TTPT-B68Tz-w8.js +1 -0
  60. package/dist/ui/dist/assets/elk-QONTBCTQ-DZZohNea.js +27 -0
  61. package/dist/ui/dist/assets/erDiagram-KYQIGQAA-BH_m74pv.js +99 -0
  62. package/dist/ui/dist/assets/eventmodeling-K75KTNOO-CTp5FHos.js +1 -0
  63. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-CsCvexry.js +1 -0
  64. package/dist/ui/dist/assets/ganttDiagram-R7GPNTJY-BDau7_0f.js +292 -0
  65. package/dist/ui/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  66. package/dist/ui/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  67. package/dist/ui/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  68. package/dist/ui/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  69. package/dist/ui/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  70. package/dist/ui/dist/assets/gitGraph-VSP46ZUC-Bv84IoCm.js +1 -0
  71. package/dist/ui/dist/assets/gitGraphDiagram-PDYL7XHY-DzXcMlm8.js +106 -0
  72. package/dist/ui/dist/assets/graphlib-DS17s2tU.js +1 -0
  73. package/dist/ui/dist/assets/index-BTJoqVVN.js +133 -0
  74. package/dist/ui/dist/assets/index-D60U3smc.css +1 -0
  75. package/dist/ui/dist/assets/info-OHQRW6UA-222J0O_j.js +1 -0
  76. package/dist/ui/dist/assets/infoDiagram-MVDRKAX5-DE2sa3Vz.js +2 -0
  77. package/dist/ui/dist/assets/init-C-OQMol4.js +1 -0
  78. package/dist/ui/dist/assets/ishikawaDiagram-HPEHV2GE-BJDk0Zcj.js +70 -0
  79. package/dist/ui/dist/assets/journeyDiagram-BOSHW7O7-JSUXX5Hb.js +139 -0
  80. package/dist/ui/dist/assets/kanban-definition-FNSA4HC5-Bg79b49M.js +89 -0
  81. package/dist/ui/dist/assets/katex-B_JcIwqj.js +257 -0
  82. package/dist/ui/dist/assets/line-Bk5qQSIa.js +1 -0
  83. package/dist/ui/dist/assets/linear-PFnVMru5.js +1 -0
  84. package/dist/ui/dist/assets/mermaid-parser.core-Dcxlzc-S.js +7 -0
  85. package/dist/ui/dist/assets/mermaid.core-TeOHQJ00.js +44 -0
  86. package/dist/ui/dist/assets/mindmap-definition-6OY57UG3-IaNk8NnW.js +98 -0
  87. package/dist/ui/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  88. package/dist/ui/dist/assets/packet-JDAUHWVQ-DMff5cVK.js +1 -0
  89. package/dist/ui/dist/assets/path-fybaL0A-.js +1 -0
  90. package/dist/ui/dist/assets/pegDiagram-ECIUJNBX-66Xu4YRc.js +1 -0
  91. package/dist/ui/dist/assets/pie-XZMESJXO-GEtzLCIM.js +1 -0
  92. package/dist/ui/dist/assets/pieDiagram-GNMPY74T-7jMBi-kU.js +39 -0
  93. package/dist/ui/dist/assets/prism-bash-D6zCJ74D.js +1 -0
  94. package/dist/ui/dist/assets/prism-c-04YixN25.js +1 -0
  95. package/dist/ui/dist/assets/prism-clike-DapgWyxx.js +1 -0
  96. package/dist/ui/dist/assets/prism-core-FDHlWQ43.js +1 -0
  97. package/dist/ui/dist/assets/prism-cpp-C-lJbC-6.js +1 -0
  98. package/dist/ui/dist/assets/prism-css-CPgOzxXp.js +1 -0
  99. package/dist/ui/dist/assets/prism-go-C_8qAH5n.js +1 -0
  100. package/dist/ui/dist/assets/prism-java-6wtusvIl.js +1 -0
  101. package/dist/ui/dist/assets/prism-javascript-BnO-swvr.js +1 -0
  102. package/dist/ui/dist/assets/prism-json-Dp_-W-Hv.js +1 -0
  103. package/dist/ui/dist/assets/prism-jsx-D-D0NWtC.js +1 -0
  104. package/dist/ui/dist/assets/prism-kotlin-BUlJgC-o.js +1 -0
  105. package/dist/ui/dist/assets/prism-markdown-Bc9-36OC.js +1 -0
  106. package/dist/ui/dist/assets/prism-markup-Cp04rIa1.js +1 -0
  107. package/dist/ui/dist/assets/prism-markup-templating-KkSMBj6Y.js +1 -0
  108. package/dist/ui/dist/assets/prism-php-Co4fM40K.js +1 -0
  109. package/dist/ui/dist/assets/prism-python-CfiSVtM6.js +1 -0
  110. package/dist/ui/dist/assets/prism-ruby-DFCASkuD.js +1 -0
  111. package/dist/ui/dist/assets/prism-rust-Bu9N8X3N.js +1 -0
  112. package/dist/ui/dist/assets/prism-sql-CYhVDMEE.js +1 -0
  113. package/dist/ui/dist/assets/prism-swift-B0kWc-ne.js +1 -0
  114. package/dist/ui/dist/assets/prism-toml-DKb54GAT.js +1 -0
  115. package/dist/ui/dist/assets/prism-tsx-p6J2Kc_4.js +1 -0
  116. package/dist/ui/dist/assets/prism-typescript-BMqONzxf.js +1 -0
  117. package/dist/ui/dist/assets/prism-yaml-C0JHu8gr.js +1 -0
  118. package/dist/ui/dist/assets/quadrantDiagram-D57K4MC3-8hIWayC2.js +7 -0
  119. package/dist/ui/dist/assets/radar-ABXABTNO-BTdwUOOF.js +1 -0
  120. package/dist/ui/dist/assets/railroad-I3PHUGI6-DcMbDqBL.js +1 -0
  121. package/dist/ui/dist/assets/railroad-abnf-LNEFI6M7-gTzUTWeI.js +1 -0
  122. package/dist/ui/dist/assets/railroad-ebnf-DWYD2UWJ-CNtdUPtk.js +1 -0
  123. package/dist/ui/dist/assets/railroad-peg-JR7BVNR7-DOiExAtE.js +1 -0
  124. package/dist/ui/dist/assets/railroadDiagram-Y72NRBL7-2kE_ygvM.js +1 -0
  125. package/dist/ui/dist/assets/requirementDiagram-EQYSW5RA-CoIAHRq2.js +84 -0
  126. package/dist/ui/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  127. package/dist/ui/dist/assets/sankeyDiagram-SY24JHO5-7p8D3wim.js +40 -0
  128. package/dist/ui/dist/assets/sequenceDiagram-BLEHNHQU-C6QFnMvn.js +169 -0
  129. package/dist/ui/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  130. package/dist/ui/dist/assets/src-FgijoBVs.js +1 -0
  131. package/dist/ui/dist/assets/stateDiagram-v2-A6IESKWZ-n9eAb_bT.js +291 -0
  132. package/dist/ui/dist/assets/swimlanes-B3ENRKFQ-U18mtUzX.js +1 -0
  133. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-DX07QVy9.js +8 -0
  134. package/dist/ui/dist/assets/timeline-definition-JXYKGLGB-CD2Z8QGP.js +120 -0
  135. package/dist/ui/dist/assets/treeView-D4JQ5SDB-sqUjH5gB.js +1 -0
  136. package/dist/ui/dist/assets/treemap-SAJKECNS-DvZ38FP7.js +1 -0
  137. package/dist/ui/dist/assets/usecaseDiagram-WPOLBNNX-CqOGidaZ.js +328 -0
  138. package/dist/ui/dist/assets/vennDiagram-Q3Y3IDCW-D1SXSBQ5.js +34 -0
  139. package/dist/ui/dist/assets/wardley-7MLQ67FV-Bpwio-Dx.js +1 -0
  140. package/dist/ui/dist/assets/wardleyDiagram-IVAIDKWA-JSUXHMnk.js +78 -0
  141. package/dist/ui/dist/assets/xychartDiagram-4URNHDU4-B4IFqQTN.js +7 -0
  142. package/dist/ui/dist/index.html +15 -0
  143. package/dist/ui/dist/style.css +121 -0
  144. package/package.json +31 -8
  145. package/skills/follow-me/SKILL.md +15 -61
  146. package/dist/src/anthropic.js +0 -101
  147. package/dist/ui/app.js +0 -797
  148. package/dist/ui/index.html +0 -40
  149. package/dist/ui/playback.js +0 -175
  150. package/dist/ui/style.css +0 -466
@@ -0,0 +1,361 @@
1
+ import { isRepoPath, isUnitId } from "./diff.js";
2
+ import { diagramProblem, isArray, isCommentId, isConcernId, isFields, isPlanVersion, isSeverity, } from "./plan.js";
3
+ /**
4
+ * The wire formats of review state, parsed once at the boundary: comments, replies, concern
5
+ * decisions, the review file, its autosaved draft and the events a hosted session logs. Every
6
+ * parser returns the domain value or the reason it is not one, so a caller decides whether a bad
7
+ * item rejects a request or is skipped when reading a file.
8
+ */
9
+ const REPLY_ID = /^r_[a-z2-7]{6}$/;
10
+ export function isReplyId(value) {
11
+ return typeof value === "string" && REPLY_ID.test(value);
12
+ }
13
+ function isIndex(v) {
14
+ return typeof v === "number" && Number.isInteger(v) && v >= 0;
15
+ }
16
+ function parseAnchor(v) {
17
+ if (!isFields(v)) {
18
+ return "anchor is not an object";
19
+ }
20
+ if (v.kind === "plan")
21
+ return { kind: "plan" };
22
+ if (!isIndex(v.commit))
23
+ return "anchor.commit is not a commit index";
24
+ const commit = v.commit;
25
+ if (v.kind === "message" || v.kind === "position")
26
+ return { kind: v.kind, commit };
27
+ if (v.kind !== "hunk")
28
+ return "anchor.kind is unknown";
29
+ if (typeof v.unit !== "string" || !isUnitId(v.unit))
30
+ return "anchor.unit is not a unit id";
31
+ if (v.changes === null) {
32
+ if (v.offsets !== undefined)
33
+ return "anchor.offsets needs anchor.changes to be a list";
34
+ return { kind: "hunk", unit: v.unit, commit, changes: null };
35
+ }
36
+ if (!isArray(v.changes) || !v.changes.every(isIndex)) {
37
+ return "anchor.changes is not null or a list of change indices";
38
+ }
39
+ const changes = v.changes.filter(isIndex);
40
+ if (v.offsets === undefined)
41
+ return { kind: "hunk", unit: v.unit, commit, changes };
42
+ if (!isArray(v.offsets) || v.offsets.length === 0 || !v.offsets.every(isIndex)) {
43
+ return "anchor.offsets is not a non-empty list of row indices";
44
+ }
45
+ const offsets = v.offsets.filter(isIndex);
46
+ if (!offsets.every((o, i) => i === 0 || o > (offsets[i - 1] ?? 0))) {
47
+ return "anchor.offsets is not strictly ascending";
48
+ }
49
+ return { kind: "hunk", unit: v.unit, commit, changes, offsets };
50
+ }
51
+ /** A stored reply. Replies a client posts go through parseNewReply, which mints id and time. */
52
+ export function parseReply(v) {
53
+ if (!isFields(v))
54
+ return "not an object";
55
+ if (!isReplyId(v.id))
56
+ return "id is not a reply id";
57
+ if (v.author !== "agent" && v.author !== "reviewer")
58
+ return 'author is not "agent" or "reviewer"';
59
+ if (typeof v.body !== "string" || typeof v.createdAt !== "string") {
60
+ return "body and createdAt must be strings";
61
+ }
62
+ if (v.diagram !== undefined && typeof v.diagram !== "string")
63
+ return "diagram is not a string";
64
+ return {
65
+ id: v.id,
66
+ author: v.author,
67
+ body: v.body,
68
+ ...(v.diagram === undefined ? {} : { diagram: v.diagram }),
69
+ createdAt: v.createdAt,
70
+ };
71
+ }
72
+ /** The body of POST .../replies: who speaks, what, and an optional Mermaid diagram. */
73
+ export function parseNewReply(v) {
74
+ if (!isFields(v))
75
+ return "the reply is not an object";
76
+ if (v.author !== "agent" && v.author !== "reviewer")
77
+ return 'author is not "agent" or "reviewer"';
78
+ if (typeof v.body !== "string" || v.body.trim() === "")
79
+ return "body is not a non-empty string";
80
+ if (v.diagram === undefined)
81
+ return { author: v.author, body: v.body };
82
+ if (typeof v.diagram !== "string")
83
+ return "diagram is not a string";
84
+ const problem = diagramProblem(v.diagram);
85
+ if (problem !== null)
86
+ return `diagram: ${problem}`;
87
+ return { author: v.author, body: v.body, diagram: v.diagram };
88
+ }
89
+ function isString(v) {
90
+ return typeof v === "string";
91
+ }
92
+ /** Boundary parse of a comment, from a file or a request. Returns the reason when it is not one. */
93
+ export function parseComment(v) {
94
+ if (!isFields(v))
95
+ return "not an object";
96
+ if (typeof v.id !== "string" || !isCommentId(v.id))
97
+ return "id is not a comment id";
98
+ const anchor = parseAnchor(v.anchor);
99
+ if (typeof anchor === "string")
100
+ return anchor;
101
+ const ctx = v.context;
102
+ if (!isFields(ctx) || typeof ctx.commitMessage !== "string") {
103
+ return "context.commitMessage is not a string";
104
+ }
105
+ const path = ctx.path;
106
+ if (path !== null && (typeof path !== "string" || !isRepoPath(path))) {
107
+ return "context.path is not null or a path";
108
+ }
109
+ const lines = ctx.lines;
110
+ if (lines !== undefined && !(isArray(lines) && lines.every(isString))) {
111
+ return "context.lines is not a list of strings";
112
+ }
113
+ if (typeof v.body !== "string" || typeof v.createdAt !== "string") {
114
+ return "body and createdAt must be strings";
115
+ }
116
+ if (v.kind !== undefined && v.kind !== "question")
117
+ return 'kind is not "question"';
118
+ if (v.author !== undefined && v.author !== "agent")
119
+ return 'author is not "agent"';
120
+ if (v.diagram !== undefined) {
121
+ if (typeof v.diagram !== "string")
122
+ return "diagram is not a string";
123
+ if (v.author !== "agent")
124
+ return "diagram is only for an agent's comment";
125
+ const problem = diagramProblem(v.diagram);
126
+ if (problem !== null)
127
+ return `diagram: ${problem}`;
128
+ }
129
+ const follows = v.follows;
130
+ if (follows !== undefined && !(typeof follows === "string" && isCommentId(follows))) {
131
+ return "follows is not a comment id";
132
+ }
133
+ const severity = v.severity;
134
+ if (severity !== undefined && !isSeverity(severity))
135
+ return "severity is unknown";
136
+ if (v.pending !== undefined && v.pending !== true)
137
+ return "pending is not true";
138
+ if (v.pending === true && (v.kind === "question" || v.author === "agent")) {
139
+ return "only a reviewer's flag can be pending";
140
+ }
141
+ const replies = parseList(v.replies, parseReply, "replies");
142
+ if (typeof replies === "string")
143
+ return replies;
144
+ return {
145
+ id: v.id,
146
+ anchor,
147
+ context: {
148
+ commitMessage: ctx.commitMessage,
149
+ path,
150
+ ...(lines === undefined ? {} : { lines: lines.filter(isString) }),
151
+ },
152
+ body: v.body,
153
+ createdAt: v.createdAt,
154
+ ...(v.kind === "question" ? { kind: "question" } : {}),
155
+ ...(follows === undefined ? {} : { follows }),
156
+ ...(severity === undefined ? {} : { severity }),
157
+ ...(v.pending === true ? { pending: true } : {}),
158
+ ...(replies.length === 0 ? {} : { replies }),
159
+ ...(v.author === "agent" ? { author: "agent" } : {}),
160
+ ...(v.diagram === undefined ? {} : { diagram: v.diagram }),
161
+ };
162
+ }
163
+ function withDecision(concern, v) {
164
+ if (v.decision !== "kept" && v.decision !== "dismissed") {
165
+ return 'decision is not "kept" or "dismissed"';
166
+ }
167
+ if (v.severity !== undefined && !isSeverity(v.severity))
168
+ return "severity is unknown";
169
+ if (v.note !== undefined && typeof v.note !== "string")
170
+ return "note is not a string";
171
+ if (v.decidedAt !== undefined && typeof v.decidedAt !== "string") {
172
+ return "decidedAt is not a string";
173
+ }
174
+ return {
175
+ concern,
176
+ decision: v.decision,
177
+ ...(v.severity === undefined ? {} : { severity: v.severity }),
178
+ ...(v.note === undefined || v.note.trim() === "" ? {} : { note: v.note }),
179
+ ...(v.decidedAt === undefined ? {} : { decidedAt: v.decidedAt }),
180
+ };
181
+ }
182
+ /** A stored decision. */
183
+ export function parseDecision(v) {
184
+ if (!isFields(v))
185
+ return "not an object";
186
+ if (!isConcernId(v.concern))
187
+ return "concern is not a concern id";
188
+ return withDecision(v.concern, v);
189
+ }
190
+ /** The body of POST .../concerns/<id>; the id comes from the URL and the time is the server's. */
191
+ export function parseNewDecision(concern, v) {
192
+ if (!isFields(v))
193
+ return "the decision is not an object";
194
+ return withDecision(concern, { ...v, decidedAt: undefined });
195
+ }
196
+ /** Absent reads as none. The first item that does not parse names its index and reason. */
197
+ function parseList(raw, parse, what) {
198
+ if (raw === undefined)
199
+ return [];
200
+ if (!isArray(raw))
201
+ return `${what} is not an array`;
202
+ const out = [];
203
+ for (const [i, item] of raw.entries()) {
204
+ const parsed = parse(item);
205
+ if (typeof parsed === "string")
206
+ return `${what}[${i}]: ${parsed}`;
207
+ out.push(parsed);
208
+ }
209
+ return out;
210
+ }
211
+ function parseConcernId(item) {
212
+ return isConcernId(item) ? item : "not a concern id";
213
+ }
214
+ /** Absent reads as nothing excluded. */
215
+ export function parseExcluded(v) {
216
+ if (v === undefined)
217
+ return { comments: [], concerns: [] };
218
+ if (!isFields(v))
219
+ return "excluded is not an object";
220
+ const comments = parseList(v.comments, parseCommentId, "excluded.comments");
221
+ if (typeof comments === "string")
222
+ return comments;
223
+ const concerns = parseList(v.concerns, parseConcernId, "excluded.concerns");
224
+ return typeof concerns === "string" ? concerns : { comments, concerns };
225
+ }
226
+ function asCommentId(item) {
227
+ return typeof item === "string" && isCommentId(item) ? item : null;
228
+ }
229
+ function parseCommentId(item) {
230
+ return asCommentId(item) ?? "not a comment id";
231
+ }
232
+ /** Boundary parse of reviews/vN.json. Files written before walk mode existed have no mode. */
233
+ export function parseReview(v) {
234
+ if (!isFields(v)) {
235
+ return "not an object";
236
+ }
237
+ if (v.schema !== "follow-me/review@1")
238
+ return 'schema is not "follow-me/review@1"';
239
+ const mode = v.mode ?? "split";
240
+ if (mode !== "split" && mode !== "walk")
241
+ return 'mode is not "split" or "walk"';
242
+ if (!isPlanVersion(v.planVersion))
243
+ return "planVersion is not a positive integer";
244
+ if (typeof v.planHash !== "string" || typeof v.endedAt !== "string") {
245
+ return "planHash and endedAt must be strings";
246
+ }
247
+ if (!isArray(v.comments))
248
+ return "comments is not an array";
249
+ const comments = parseList(v.comments, parseComment, "comments");
250
+ if (typeof comments === "string")
251
+ return comments;
252
+ const concerns = parseList(v.concerns, parseDecision, "concerns");
253
+ if (typeof concerns === "string")
254
+ return concerns;
255
+ const resolved = parseList(v.resolved, parseCommentId, "resolved");
256
+ if (typeof resolved === "string")
257
+ return resolved;
258
+ const excluded = parseExcluded(v.excluded);
259
+ if (typeof excluded === "string")
260
+ return excluded;
261
+ const anyExcluded = excluded.comments.length + excluded.concerns.length > 0;
262
+ return {
263
+ schema: v.schema,
264
+ mode,
265
+ planVersion: v.planVersion,
266
+ planHash: v.planHash,
267
+ endedAt: v.endedAt,
268
+ comments,
269
+ ...(concerns.length === 0 ? {} : { concerns }),
270
+ ...(resolved.length === 0 ? {} : { resolved }),
271
+ ...(anyExcluded ? { excluded } : {}),
272
+ };
273
+ }
274
+ function lenient(items, parse) {
275
+ if (!isArray(items))
276
+ return [];
277
+ return items.flatMap((item) => {
278
+ const parsed = parse(item);
279
+ return typeof parsed === "string" ? [] : [parsed];
280
+ });
281
+ }
282
+ /**
283
+ * Boundary parse of reviews/vN.draft.json. Unlike a finished review it is read leniently: an
284
+ * item that does not parse is dropped, so one bad entry never loses the rest of an autosave.
285
+ */
286
+ export function parseDraft(v) {
287
+ if (!isFields(v) || !isArray(v.comments)) {
288
+ return "not a review draft";
289
+ }
290
+ const excluded = parseExcluded(v.excluded);
291
+ return {
292
+ comments: lenient(v.comments, parseComment),
293
+ concerns: lenient(v.concerns, parseDecision),
294
+ resolved: lenient(v.resolved, parseCommentId),
295
+ excluded: typeof excluded === "string" ? { comments: [], concerns: [] } : excluded,
296
+ };
297
+ }
298
+ export function parseEvent(v) {
299
+ if (!isFields(v))
300
+ return "not an object";
301
+ const { seq } = v;
302
+ if (typeof seq !== "number" || !Number.isInteger(seq) || seq < 1)
303
+ return "seq is not a number";
304
+ switch (v.type) {
305
+ case "comment":
306
+ case "comment-updated": {
307
+ const comment = parseComment(v.comment);
308
+ return typeof comment === "string" ? comment : { seq, type: v.type, comment };
309
+ }
310
+ case "comment-deleted": {
311
+ const id = asCommentId(v.id);
312
+ return id === null ? "id is not a comment id" : { seq, type: v.type, id };
313
+ }
314
+ case "reply": {
315
+ const id = asCommentId(v.commentId);
316
+ const reply = parseReply(v.reply);
317
+ if (id === null)
318
+ return "commentId is not a comment id";
319
+ return typeof reply === "string" ? reply : { seq, type: v.type, commentId: id, reply };
320
+ }
321
+ case "concern": {
322
+ if (!isConcernId(v.concernId))
323
+ return "concernId is not a concern id";
324
+ const decision = withDecision(v.concernId, v);
325
+ if (typeof decision === "string")
326
+ return decision;
327
+ const { concern: concernId, ...rest } = decision;
328
+ return { seq, type: v.type, concernId, ...rest };
329
+ }
330
+ case "resolved": {
331
+ const id = asCommentId(v.commentId);
332
+ if (id === null || typeof v.resolved !== "boolean") {
333
+ return "malformed resolved event";
334
+ }
335
+ return { seq, type: v.type, commentId: id, resolved: v.resolved };
336
+ }
337
+ case "version": {
338
+ if (!isPlanVersion(v.version) || typeof v.hash !== "string")
339
+ return "malformed version event";
340
+ return { seq, type: v.type, version: v.version, hash: v.hash };
341
+ }
342
+ case "submitted":
343
+ return { seq, type: v.type };
344
+ case "ended":
345
+ return typeof v.review === "string"
346
+ ? { seq, type: v.type, review: v.review }
347
+ : "review is not a path";
348
+ default:
349
+ return "unknown event type";
350
+ }
351
+ }
352
+ /** The body of GET .../events: the latest seq and the events after the asked-for one. */
353
+ export function parseEventBatch(v) {
354
+ if (!isFields(v))
355
+ return "not an object";
356
+ if (typeof v.seq !== "number" || !Number.isInteger(v.seq) || v.seq < 0) {
357
+ return "seq is not a number";
358
+ }
359
+ const events = parseList(v.events, parseEvent, "events");
360
+ return typeof events === "string" ? events : { seq: v.seq, events };
361
+ }
package/dist/src/git.js CHANGED
@@ -54,6 +54,10 @@ async function resolveOptional(repo, ref) {
54
54
  throw error;
55
55
  }
56
56
  }
57
+ /** The ref refs/heads/<name> points at, or null when there is no such branch. */
58
+ export function branchHead(repo, name) {
59
+ return resolveOptional(repo, `refs/heads/${name}`);
60
+ }
57
61
  export async function treeOf(repo, rev) {
58
62
  return toSha(await gitText(repo, ["rev-parse", "--verify", "--end-of-options", `${rev}^{tree}`]), `${rev}^{tree}`);
59
63
  }
@@ -84,7 +88,7 @@ export async function checkedOutBranches(repo) {
84
88
  return found;
85
89
  }
86
90
  export async function branchExists(repo, name) {
87
- return (await resolveOptional(repo, `refs/heads/${name}`)) !== null;
91
+ return (await branchHead(repo, name)) !== null;
88
92
  }
89
93
  export async function defaultBaseRef(repo) {
90
94
  for (const name of ["main", "master"]) {
@@ -130,6 +134,29 @@ export async function loadDiff(repo, baseRef, tipRef) {
130
134
  const blobs = await catBlobs(repo, neededBlobs(rawZ, patchText));
131
135
  return parseDiff({ base, tip, baseTree, tipTree, rawZ, patch: patchText, blobs });
132
136
  }
137
+ /**
138
+ * Whether the configured user wrote every commit of base..tip, which is how follow-me tells its
139
+ * author's own branch from one it is asked to review. False when no user.email is set.
140
+ */
141
+ export async function isOwnWork(repo, base, tip) {
142
+ const me = await gitText(repo, ["config", "--default=", "user.email"]);
143
+ if (me === "")
144
+ return false;
145
+ const authors = await gitText(repo, ["log", "--format=%ae", `${base}..${tip}`]);
146
+ const emails = authors.split("\n").filter((line) => line !== "");
147
+ return emails.length > 0 && emails.every((email) => email === me);
148
+ }
149
+ export async function signing(repo, mode) {
150
+ const configured = await gitText(repo, [
151
+ "config",
152
+ "--type=bool",
153
+ "--default=false",
154
+ "commit.gpgsign",
155
+ ]);
156
+ const format = await gitText(repo, ["config", "--default=openpgp", "gpg.format"]);
157
+ const on = mode === "config" ? configured === "true" : mode === "on";
158
+ return { on, format };
159
+ }
133
160
  export class BranchRefusedError extends Error {
134
161
  name = "BranchRefusedError";
135
162
  }
@@ -201,6 +228,7 @@ async function adoptObjects(quarantine, objects) {
201
228
  * user's index and worktree are never touched, then moves refs/heads/<name> by compare-and-swap.
202
229
  * New objects go to a quarantine inside the object store and are adopted only right before the
203
230
  * ref moves, so a refused or failed write leaves neither refs nor objects behind.
231
+ * With opts.sign, commit-tree signs with gpg.format and user.signingkey.
204
232
  */
205
233
  export async function writeBranch(repo, diff, plan, opts) {
206
234
  const ref = `refs/heads/${opts.name}`;
@@ -242,7 +270,7 @@ export async function writeBranch(repo, diff, plan, opts) {
242
270
  await git(repo, ["update-index", "-z", "--index-info"], { env, input: Buffer.concat(lines) });
243
271
  tree = toSha(await gitText(repo, ["write-tree"], { env }), "write-tree");
244
272
  const message = commit.message.endsWith("\n") ? commit.message : `${commit.message}\n`;
245
- head = toSha(await gitText(repo, ["commit-tree", "--no-gpg-sign", tree, "-p", head, "-F", "-"], {
273
+ head = toSha(await gitText(repo, ["commit-tree", opts.sign ? "-S" : "--no-gpg-sign", tree, "-p", head, "-F", "-"], {
246
274
  input: message,
247
275
  env: { ...env, ...identityEnv(who, n - 1 - commit.index) },
248
276
  }), "commit-tree");
@@ -251,7 +279,8 @@ export async function writeBranch(repo, diff, plan, opts) {
251
279
  if (tree !== diff.tipTree) {
252
280
  throw new Error(`the last commit's tree ${tree} differs from the tip tree ${diff.tipTree}`);
253
281
  }
254
- const current = await resolveOptional(repo, ref);
282
+ const current = await branchHead(repo, opts.name);
283
+ // Unsigned commits, and deterministic signatures such as ssh ed25519, give the same SHAs.
255
284
  if (current === head)
256
285
  return { kind: "unchanged", branch: opts.name, head, commits };
257
286
  if (current !== null && !opts.force) {
package/dist/src/host.js CHANGED
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { closeSync, linkSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
+ import { parseEvent, parseEventBatch } from "./comments.js";
6
7
  import { isPlanVersion, serializePlanFile, validatePlan } from "./plan.js";
7
8
  import { HEALTH_APP, isSessionId, loadDraftComments, PortInUseError, RegistrationError, startServer, } from "./server.js";
8
9
  import { BranchMovedError, isAlive, openSessionAt, SessionFileError, SessionLockedError, } from "./session.js";
@@ -103,7 +104,7 @@ function removeRecord(dir, pid) {
103
104
  if (readServerRecord(dir)?.pid === pid)
104
105
  rmSync(join(dir, RECORD), { force: true });
105
106
  }
106
- async function loadHosted(registration) {
107
+ async function loadHosted(registration, held) {
107
108
  const { dir, mode, version, hash, lockToken } = registration;
108
109
  try {
109
110
  const session = await openSessionAt(dir);
@@ -112,8 +113,11 @@ async function loadHosted(registration) {
112
113
  if (stored === undefined || stored.hash !== hash) {
113
114
  throw new RegistrationError(`${branch} has no plan v${version} with hash ${hash}`);
114
115
  }
115
- if (session.reviews().some((r) => r.version === version)) {
116
- throw new RegistrationError(`plan v${version} of ${branch} already has a review`);
116
+ // Each mode has its own slot, so a walk can follow the author's review of the same plan.
117
+ const reviewed = mode === "walk" ? session.walkReviews() : session.reviews();
118
+ if (reviewed.some((r) => r.version === version)) {
119
+ const what = mode === "walk" ? "code review" : "review";
120
+ throw new RegistrationError(`plan v${version} of ${branch} already has a ${what}`);
117
121
  }
118
122
  const verdict = validatePlan(session.diff, serializePlanFile(stored.file), {
119
123
  lastReview: session.lastReview(),
@@ -121,14 +125,25 @@ async function loadHosted(registration) {
121
125
  if (!verdict.ok) {
122
126
  throw new RegistrationError(`plan v${version} no longer fits ${branch}; check it again`);
123
127
  }
124
- const reviewsDir = join(session.dir, "reviews");
128
+ const reviewsDir = session.reviewsDir();
125
129
  const comments = await loadDraftComments(reviewsDir, version);
126
- const command = mode === "walk" ? "walk" : "review";
127
- const lock = lockToken === null ? session.lock(command) : session.adoptLock(lockToken, command);
130
+ const prior = session.priorReview(mode, version);
131
+ const previous = prior === null || prior.comments.length === 0
132
+ ? null
133
+ : {
134
+ version: prior.planVersion,
135
+ comments: prior.comments,
136
+ responses: stored.file.responses,
137
+ };
138
+ const command = "review";
139
+ // A version swap runs inside the server that already owns this session's lock.
140
+ const release = held ??
141
+ (lockToken === null ? session.lock(command) : session.adoptLock(lockToken, command)).release;
128
142
  return {
129
143
  repo: session.root,
130
144
  reviewsDir,
131
- release: lock.release,
145
+ release,
146
+ previous,
132
147
  state: {
133
148
  mode,
134
149
  anchor: session.anchor,
@@ -235,14 +250,16 @@ async function call(port, method, path, body) {
235
250
  function parseSummary(raw) {
236
251
  if (!isFields(raw))
237
252
  return null;
238
- const { id, url, repo, branch, mode, version, comments } = raw;
253
+ const { id, url, repo, branch, mode, version, comments, openedAt } = raw;
239
254
  if (typeof url !== "string" || typeof repo !== "string")
240
255
  return null;
241
256
  if (typeof branch !== "string" || (mode !== "split" && mode !== "walk"))
242
257
  return null;
243
258
  if (!isSessionId(id) || !isPlanVersion(version) || typeof comments !== "number")
244
259
  return null;
245
- return { id, url, repo, branch, mode, version, comments };
260
+ if (typeof openedAt !== "string")
261
+ return null;
262
+ return { id, url, repo, branch, mode, version, comments, openedAt };
246
263
  }
247
264
  /** Registers a session with the server on this port; the same session yields the same URL. */
248
265
  export async function registerWith(port, registration) {
@@ -251,6 +268,73 @@ export async function registerWith(port, registration) {
251
268
  throw new RegistrationError("the server answered with a malformed session");
252
269
  return summary;
253
270
  }
271
+ /** Moves the hosted session to a newer frozen plan; its id and URL stay. */
272
+ export async function swapVersion(port, summary, version, hash) {
273
+ const swapped = parseSummary(await call(port, "POST", `${summary.url}api/version`, { version, hash }));
274
+ if (swapped === null)
275
+ throw new RegistrationError("the server answered with a malformed session");
276
+ return swapped;
277
+ }
278
+ /** One long poll of the session's event log: what happened after `since`, or nothing at timeout. */
279
+ export async function pollEvents(port, summary, since, timeoutMs) {
280
+ const path = `${summary.url}api/events?since=${since}&timeout=${timeoutMs}&audience=agent`;
281
+ const batch = parseEventBatch(await call(port, "GET", path));
282
+ if (typeof batch === "string")
283
+ throw new RegistrationError(`the server sent bad events: ${batch}`);
284
+ return batch;
285
+ }
286
+ /**
287
+ * Follows the session's event stream from `since`, calling `onEvent` for each event. Resolves when
288
+ * the server closes the stream, which it does after the `ended` event.
289
+ */
290
+ export async function followEvents(port, summary, since, onEvent) {
291
+ const res = await fetch(`${sessionUrl(port, summary)}api/events/stream?since=${since}&audience=agent`);
292
+ if (!res.ok || res.body === null) {
293
+ throw new RegistrationError(`GET api/events/stream failed with ${res.status}`);
294
+ }
295
+ const decoder = new TextDecoder();
296
+ const gap = "\n\n";
297
+ let buffer = "";
298
+ for await (const chunk of res.body) {
299
+ buffer += decoder.decode(chunk, { stream: true });
300
+ for (let end = buffer.indexOf(gap); end !== -1; end = buffer.indexOf(gap)) {
301
+ const frame = buffer.slice(0, end);
302
+ buffer = buffer.slice(end + 2);
303
+ const data = frame.split("\n").find((l) => l.startsWith("data: "));
304
+ if (data === undefined)
305
+ continue;
306
+ const event = parseEvent(JSON.parse(data.slice(6)));
307
+ if (typeof event === "string")
308
+ throw new RegistrationError(`bad event on the stream: ${event}`);
309
+ onEvent(event);
310
+ }
311
+ }
312
+ }
313
+ /** Posts the agent's reply under a comment and returns the new reply's id. */
314
+ export async function postReply(port, summary, commentId, reply) {
315
+ const path = `${summary.url}api/comments/${encodeURIComponent(commentId)}/replies`;
316
+ const body = await call(port, "POST", path, reply);
317
+ const id = isFields(body) ? body["id"] : undefined;
318
+ if (typeof id !== "string")
319
+ throw new RegistrationError("the server answered without a reply id");
320
+ return id;
321
+ }
322
+ /** Posts a comment the agent starts and returns its id; the server mints id and time. */
323
+ export async function postComment(port, summary, comment) {
324
+ const body = await call(port, "POST", `${summary.url}api/comments`, comment);
325
+ const id = isFields(body) ? body["id"] : undefined;
326
+ if (typeof id !== "string") {
327
+ throw new RegistrationError("the server answered without a comment id");
328
+ }
329
+ return id;
330
+ }
331
+ /** The consolidated report of the hosted session as it stands. */
332
+ export async function fetchReport(port, summary, format = "markdown") {
333
+ const res = await fetch(`${sessionUrl(port, summary)}api/report?format=${format}`);
334
+ if (!res.ok)
335
+ throw new RegistrationError(`GET api/report failed with ${res.status}`);
336
+ return res.text();
337
+ }
254
338
  export async function listSessions(port) {
255
339
  const raw = await call(port, "GET", "/api/sessions");
256
340
  if (!Array.isArray(raw))
@@ -263,6 +347,19 @@ export async function listSessions(port) {
263
347
  export function sessionUrl(port, summary) {
264
348
  return `http://127.0.0.1:${port}${summary.url}`;
265
349
  }
350
+ /** Whether the server on this port still serves the session's state; a gone server serves none. */
351
+ export async function stillHosted(port, summary) {
352
+ try {
353
+ const res = await fetch(`${sessionUrl(port, summary)}api/state`, {
354
+ signal: AbortSignal.timeout(2_000),
355
+ });
356
+ await res.body?.cancel();
357
+ return res.ok;
358
+ }
359
+ catch {
360
+ return false;
361
+ }
362
+ }
266
363
  /**
267
364
  * Stops the recorded server and waits for it to exit. Only a pid that answers as our server on
268
365
  * the recorded port is signalled, so a reused pid is never killed. A stale record is removed.
@@ -51,7 +51,7 @@ function chooseHarnesses(options, home) {
51
51
  * and the generic one) plan the same file once.
52
52
  */
53
53
  export function planSkillFiles(options, home, repoRoot) {
54
- const scope = options.user ? "user" : "project";
54
+ const scope = options.project ? "project" : "user";
55
55
  const byPath = new Map();
56
56
  for (const harness of chooseHarnesses(options, home)) {
57
57
  for (const file of plannedInstall(harness, scope, home, repoRoot)) {
@@ -122,6 +122,27 @@ export function runInstall(options, home, repoRoot, say, skillDir = findSkillDir
122
122
  }
123
123
  });
124
124
  }
125
+ /**
126
+ * Keeps the skill current in every harness found at user scope: writes what is missing or differs
127
+ * from the shipped copy and says so, one line per file; says nothing when all is current. A host
128
+ * with no harness, or a package without its skill folder, is not an error here.
129
+ */
130
+ export function syncSkills(home, say, skillDir) {
131
+ try {
132
+ const dir = skillDir ?? findSkillDir();
133
+ const targets = { harness: undefined, all: false, project: false, dryRun: false };
134
+ for (const file of planSkillFiles(targets, home, home)) {
135
+ const line = writeSkill(dir, file, false);
136
+ if (line.startsWith("wrote "))
137
+ say(line);
138
+ }
139
+ }
140
+ catch (error) {
141
+ if (error instanceof InstallError)
142
+ return;
143
+ process.stderr.write(`follow-me: could not update the skill: ${error instanceof Error ? error.message : error}\n`);
144
+ }
145
+ }
125
146
  export function runUninstall(options, home, repoRoot, say) {
126
147
  return reportingErrors(() => {
127
148
  for (const file of planSkillFiles(options, home, repoRoot)) {
@@ -0,0 +1,26 @@
1
+ import { PLAN_SCHEMA } from "./plan.js";
2
+ /**
3
+ * The plan that needs no planner: one commit per touched file in diff order, holding every unit
4
+ * of that file. It tells no story, but it runs the whole pipeline on a real branch. Returns the
5
+ * draft as the wire format, for validatePlan to judge like any other draft.
6
+ */
7
+ export function naiveDraft(diff) {
8
+ const byPath = new Map();
9
+ for (const unit of diff.units.values()) {
10
+ byPath.set(unit.path, [...(byPath.get(unit.path) ?? []), unit]);
11
+ }
12
+ const commits = [...byPath].map(([path, units]) => ({
13
+ message: `wip: ${path}${suffix(units)}`,
14
+ units: units.map((u) => u.id),
15
+ }));
16
+ return { schema: PLAN_SCHEMA, base: diff.base, tip: diff.tip, commits, responses: [] };
17
+ }
18
+ /** Names what a file with no text hunk is, so `wip: logo.bin (binary)` says why it has no lines. */
19
+ function suffix(units) {
20
+ if (units.some((u) => u.kind === "hunk"))
21
+ return "";
22
+ const first = units[0];
23
+ if (first === undefined)
24
+ return "";
25
+ return first.kind === "mode" ? " (mode)" : first.kind === "file" ? ` (${first.reason})` : "";
26
+ }